From bc38216ca3a9d24d89ac6254eaf94025e01a60e4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 23 Sep 2026 14:20:38 +0530 Subject: [PATCH] fix(deposits): FD Single Rate Calculator crashed for tenures under 30 days SYMPTOM Opening the FD account create screen for a "SINGLE" (Days < 1 Year) product, entering a Deposit Tenure under 30 days (e.g. 27), and clicking "Find Rate" returned a 400: "The parameter `tenureInMonths` must be greater than 0." ROOT CAUSE The Rate Calculator preview always converted the entered tenure to months before calling GET /fixeddepositaccounts/calculate-fd-interest (fixed-deposits.service.ts -> fixed-deposit-account-interest-rate- chart-step.component.ts, calculateRate()): tenureInMonths = Math.floor(enteredDays / 30) For any tenure under 30 days this floors to 0, and DepositAccountDataValidator.validateForInterestCalc() requires tenureInMonths > 0 unconditionally, so the request was rejected before it ever reached the calculation service. FIX Added `tenureInDays` as an alternative to `tenureInMonths` for this one calculator endpoint only (DepositsApiConstants. FIXED_DEPOSIT_ACCOUNT_INTEREST_CALCULATION_PARAMETERS) - not a general-purpose FD field, not touched anywhere else: - DepositAccountDataValidator: tenureInMonths is required unless tenureInDays is a positive value (either satisfies the check). - FixedDepositAccountsApiResource#calculateFixedDepositInterest: accepts the optional tenureInDays query param. - FixedDepositAccountInterestCalculationServiceImpl#calculateInterest: when tenureInDays > 0, the exact-calendar maturityDate is computed via submittedOnDate.plusDays(tenureInDays) instead of plusMonths(tenureInMonths) (which would silently compute a 0-day maturity window for a sub-month tenure). - Frontend (separate commit) now sends tenureInDays instead of a truncated-to-zero tenureInMonths whenever the entered tenure is under 30 days. SCOPE / WHAT THIS DOES NOT TOUCH Only the interest-preview calculator endpoint above. Real FD/RD account submission still uses depositPeriod + depositPeriodFrequencyId (Days=0) exactly as before; this commit does not change how an actual account's maturity date/amount gets computed at activation. IF THIS BREAKS SOMETHING LATER - "tenureInMonths must be greater than 0" reappearing for a SHORT FD tenure -> check the frontend is actually sending tenureInDays (network tab on the Find Rate call); the validator's OR-logic is in DepositAccountDataValidator.validateForInterestCalc(). - A wrong preview maturity amount for a sub-month FD Single -> check FixedDepositAccountInterestCalculationServiceImpl#calculateInterest's branch choosing plusDays vs plusMonths for maturityDate. - This bug and fix were previously implemented once, then fully reverted mid-session at the user's request (unrelated Deposit Account Link regression got tangled up with it), then reapplied cleanly here as a standalone, narrowly-scoped fix. Co-Authored-By: Claude Sonnet 5 --- .../portfolio/savings/DepositsApiConstants.java | 2 ++ .../savings/api/FixedDepositAccountsApiResource.java | 4 ++++ .../savings/data/DepositAccountDataValidator.java | 11 ++++++++++- ...dDepositAccountInterestCalculationServiceImpl.java | 9 ++++++--- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index 240914b13..c5704438a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -123,6 +123,7 @@ public final class DepositsApiConstants { public static final String isSeniorCitizenParamName = "isSeniorCitizen"; public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate"; public static final String tenureInMonthsParamName = "tenureInMonths"; + public static final String tenureInDaysParamName = "tenureInDays"; public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths"; // Preclosure parameters @@ -511,6 +512,7 @@ public final class DepositsApiConstants { fixedDepositInterestCalculationData.add(principalAmountParamName); fixedDepositInterestCalculationData.add(annualInterestRateParamName); fixedDepositInterestCalculationData.add(tenureInMonthsParamName); + fixedDepositInterestCalculationData.add(tenureInDaysParamName); fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName); fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName); // Exact calendar-based calculation params diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index d0b4aa3f5..830f6f5a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource { @QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount, @QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate, @QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths, + @QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays, @QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths, @QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths, @QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate, @@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource { HashMap request = new HashMap<>(); request.put("annualInterestRate", annualInterestRate); request.put("tenureInMonths", tenureInMonths); + if (tenureInDays != null) { + request.put("tenureInDays", tenureInDays); + } request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths); request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths); request.put("principalAmount", principalAmount); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java index 3aa401060..34d15fdab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java @@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName; @@ -681,8 +682,16 @@ public class DepositAccountDataValidator { baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull() .notLessThanMin(BigDecimal.valueOf(0)); + // FD Single accounts under 30 days are entered in whole days rather than months (a whole-month + // tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not + // an addition, so only one of the two needs to be a positive value. Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element); - baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero(); + Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element); + if (tenureInDays != null && tenureInDays > 0) { + baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero(); + } else { + baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero(); + } Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element); baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java index 061e1ce94..0aed9ad73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java @@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.savings.service; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName; import com.google.gson.JsonElement; @@ -52,6 +53,7 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element); BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element); Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element); + Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element); Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element); // Optional params for exact calendar-based calculation @@ -62,8 +64,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD if (dateStr != null && dateFormat != null) { submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat)); } - } catch (Exception ignored) { - // intentionally ignored + } catch (Exception ignored) { + // intentionally ignored } Integer daysInYear = null; final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element); @@ -73,7 +75,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD BigDecimal maturityAmount; if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) { // Exact calendar-based calculation — same algorithm as account creation - final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths); + final LocalDate maturityDate = (tenureInDays != null && tenureInDays > 0) ? submittedOnDate.plusDays(tenureInDays) + : submittedOnDate.plusMonths(tenureInMonths); maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate, interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear); } else {