From bc38216ca3a9d24d89ac6254eaf94025e01a60e4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 23 Sep 2026 14:20:38 +0530 Subject: [PATCH 1/8] 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 { From 23ee4f1063ac5729e84aee52c47379714444b93a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 23 Sep 2026 14:20:55 +0530 Subject: [PATCH 2/8] fix(deposits): Deposit Account Link wrongly demanded a co-op share account SYMPTOM Creating an FD/RD account with "Deposit Account Link" enabled and a Savings/Current account correctly picked in the UI still failed with: "A share account must be chosen when Share Linking is enabled." even though no share account field exists anywhere on the FD/RD form. ROOT CAUSE SavingsAccountAssembler.applyCbsFields() has one boolean flag, shareLinkingEnabled, serving two unrelated features that got collided together (traceable to commit dc0539d53, which repurposed the flag for co-op Share Account linking on plain Savings products): - On a plain Savings product: "Share Linking" = link a co-op m_share_account (shareAccountId). - On an FD/RD product: the SAME flag is literally labeled "Deposit Account Link" on the product screen (fixed-deposit-product-details-step.component.html) and is meant to require the pre-existing, native linkedAccountId field (already wired end-to-end: SavingsAccountAssembler sets it a few lines below this check, the FD "Maturity Instructions: Transfer to Savings" flow already depends on it, DepositAccountReadPlatformServiceImpl already reads it back for display). Before dc0539d53, 7 real historical FD accounts (ids 51-57 in fineract_default) prove linkedAccountId was the correct, working mechanism for this. The block added by that commit made shareAccountId mandatory unconditionally, for every product, breaking FD/RD. FIX SavingsAccountAssembler.applyCbsFields(): when shareLinkingEnabled is true, branch on account.depositAccountType(): - FIXED_DEPOSIT / RECURRING_DEPOSIT: require linkedAccountId (the field the FD/RD form already sends) instead of shareAccountId. - everything else (plain Savings): unchanged, still requires and validates shareAccountId against m_share_account. SCOPE Single method, single file. No new column, no new flag, no frontend change - the frontend's Deposit Account Link picker was already correct; only this one validation block was reading the wrong field for FD/RD. IF THIS BREAKS SOMETHING LATER - "A share account must be chosen..." reappearing for FD/RD -> check account.depositAccountType() is resolving to FIXED_DEPOSIT/ RECURRING_DEPOSIT at this point (it's a simple override per account subclass, see FixedDepositAccount#depositAccountType()). - A plain Savings product's genuine co-op Share Linking breaking -> that path (the `else` branch here) is untouched; if it breaks, the regression is elsewhere. - A much larger version of this fix (a whole new depositAccountLinkRequired flag decoupled from shareLinkingEnabled, ~18 files) was attempted earlier in the same session and fully reverted at the user's request after repeated Gradle build-cache issues made it impossible to verify was even running; this is the intentionally minimal replacement. Co-Authored-By: Claude Sonnet 5 --- .../domain/SavingsAccountAssembler.java | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java index 8453b6e92..22cef0438 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java @@ -414,20 +414,33 @@ public class SavingsAccountAssembler { // dependency on the shareaccounts domain class) rather than trusted blindly, the same way an // account number typed for a linked savings/loan account elsewhere in this form is verified // before being accepted. + // + // For a term deposit (FD/RD), the product screen labels this same flag "Deposit Account Link" -- + // it links an existing Savings/Current account (linkedAccountId, set below), not a co-op share + // account, so it's validated against that field instead of shareAccountId. if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) { - final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element); - if (shareAccountId == null) { - throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required", - "A share account must be chosen when Share Linking is enabled."); + final boolean isTermDeposit = account.depositAccountType().isFixedDeposit() || account.depositAccountType().isRecurringDeposit(); + if (isTermDeposit) { + final Long linkedAccountIdForCheck = this.fromApiJsonHelper.extractLongNamed(linkedAccountIdParamName, element); + if (linkedAccountIdForCheck == null) { + throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.linked.account.required", + "A Savings/Current account must be linked when Deposit Account Link is enabled."); + } + } else { + final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element); + if (shareAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required", + "A share account must be chosen when Share Linking is enabled."); + } + final Integer matches = this.jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class, + shareAccountId, account.clientId()); + if (matches == null || matches == 0) { + throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client", + "Share account " + shareAccountId + " is not an active share account for this client."); + } + account.setShareAccountId(shareAccountId); } - final Integer matches = this.jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class, - shareAccountId, account.clientId()); - if (matches == null || matches == 0) { - throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client", - "Share account " + shareAccountId + " is not an active share account for this client."); - } - account.setShareAccountId(shareAccountId); } // Senior citizen rate — runs after validateDomainRules so it is the final persisted value From f457b9b8c7fc8ba6f79ff4fc89218f9ec6b975ba Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 23 Sep 2026 14:21:13 +0530 Subject: [PATCH 3/8] fix(cashier): route FD/RD cashier deposits through maker-checker approval SYMPTOM A cashier could credit an FD/RD account with cash and the money moved in instantly, with no Manager approval step - unlike a Savings/Current deposit from the same screen, which correctly creates a PENDING m_cashier_deposit_request. Discovered by tracing why a real FD funding deposit never showed up in Branch Ops -> Approvals: it had posted directly, no request record ever existed for it. ROOT CAUSE (two layers, both fixed here / in the paired frontend commit) 1. Frontend gate (transaction-entry.component.ts confirmSubmit(), see frontend commit): the maker-checker branch for a cashier deposit only matched row.acTyp in ('SD','CA','CB','GL') - 'FD' and 'RD' were never in that list, so a deposit into either fell through every other branch to the generic, unguarded direct POST at the bottom of confirmSubmit(). 2. Even with the frontend gated correctly, approve() here only knew how to tell Current apart from "everything else -> plain savings deposit command" (isCurrentAccountById()). FD/RD accounts share the m_savings_account table with plain Savings (distinguished only by deposit_type_enum 200/300 vs 100), so an FD/RD deposit request would have been posted through the generic savingsAccountDeposit command, which does not accept a term-deposit account id. FIX - resolveDepositAccountCategory(accountId): looks up the real category fresh from the DB (m_current_account / m_savings_account. deposit_type_enum) instead of trusting the request's own account_type string - same defensive pattern this class already used for Current. - New executeFixedDepositDeposit() / executeRecurringDepositDeposit(): post through Fineract's actual FD/RD deposit commands (CommandWrapperBuilder#fixedDepositAccountDeposit / #recurringAccountDeposit, hitting /fixeddepositaccounts/{id}/ transactions and /recurringdepositaccounts/{id}/transactions) instead of the generic savings command. - approve() now branches on all four categories (Current / Fixed Deposit / Recurring Deposit / Savings) instead of two. RELATED DATA CLEANUP DONE THIS SESSION (not in this commit) The one FD deposit made before this fix existed left an orphaned m_savings_txn_denomination row (physically "counted" cash with no approved request backing it), which inflated the Branch Day Register's "Vault + Cashiers" total above the approved-requests-only "book" total by exactly that amount every day going forward. That artifact row was deleted directly (not backfilled with a fabricated approval - the Claude Code safety layer correctly refused that approach as audit-log tampering). No code in this repo does that cleanup automatically. IF THIS BREAKS SOMETHING LATER - A cashier FD/RD deposit posting instantly again -> check the frontend gate list in confirmSubmit() (paired commit) still includes 'FD'/'RD'. - approve() throwing on an FD/RD deposit request -> check resolveDepositAccountCategory() is returning FIXED_DEPOSIT/ RECURRING_DEPOSIT (query m_savings_account.deposit_type_enum for the account id in question: 200 = FD, 300 = RD). - Branch Day Register showing a mysterious Vault-vs-book Excess again -> check for an m_savings_txn_denomination row with no matching m_cashier_deposit_request; that combination should be impossible now that this fix is in place, so a new one appearing means this fix (or its frontend half) regressed. Co-Authored-By: Claude Sonnet 5 --- .../api/CashierDepositRequestApiResource.java | 103 +++++++++++++++++- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 7378801ec..25b268f90 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -418,13 +418,20 @@ public class CashierDepositRequestApiResource { txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode, amount, ptId, txnDateStr, description, true); } else { - // Determine real account type by checking which table owns this ID. + // Determine real account type by checking which table/column owns this ID. // Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS' - // even for current accounts (submitted before the frontend fix). - final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); - txnId = isCurrentAccount - ? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description) - : executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + // even for current accounts (submitted before the frontend fix), and FD/RD accounts share + // the very same m_savings_account table as a plain savings account, distinguished only by + // deposit_type_enum -- so this is looked up fresh rather than trusted from the request. + final String depositCategory = resolveDepositAccountCategory(savingsAccountId); + final boolean isCurrentAccount = "CURRENT".equals(depositCategory); + txnId = switch (depositCategory) { + case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + case "RECURRING_DEPOSIT" -> + executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + }; if (!isCurrentAccount && txnId != null) { // approve() is one @Transactional block, so an uncaught exception here would roll back // the deposit that was just posted along with it -- every Cheque/DD approval that ever @@ -718,6 +725,64 @@ public class CashierDepositRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } + private String executeFixedDepositDeposit( + final Long accountId, final BigDecimal amount, + final Object paymentTypeId, final String txnDateStr, final String note) { + + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", date); + payload.addProperty("transactionAmount", amount); + if (paymentTypeId != null) { + payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue()); + } + if (note != null && !note.isBlank()) { + payload.addProperty("note", note); + } + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .fixedDepositAccountDeposit(accountId) + .withJson(payload.toString()) + .build(); + + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + return result.getResourceId() != null ? result.getResourceId().toString() : null; + } + + private String executeRecurringDepositDeposit( + final Long accountId, final BigDecimal amount, + final Object paymentTypeId, final String txnDateStr, final String note) { + + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", date); + payload.addProperty("transactionAmount", amount); + if (paymentTypeId != null) { + payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue()); + } + if (note != null && !note.isBlank()) { + payload.addProperty("note", note); + } + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .recurringAccountDeposit(accountId) + .withJson(payload.toString()) + .build(); + + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + return result.getResourceId() != null ? result.getResourceId().toString() : null; + } + /** * A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the * branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount @@ -809,6 +874,32 @@ public class CashierDepositRequestApiResource { } } + /** + * "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database + * rather than the request's own account_type string. FD and RD accounts live in the same + * m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a + * plain "not current → savings" check would post an FD/RD deposit through the generic savings + * deposit command, which does not accept a term-deposit account id. + */ + private String resolveDepositAccountCategory(final Long accountId) { + if (accountId == null) { + return "SAVINGS"; + } + if (isCurrentAccountById(accountId)) { + return "CURRENT"; + } + final List depositTypes = jdbcTemplate.queryForList( + "SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId); + final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0); + if (Integer.valueOf(200).equals(depositType)) { + return "FIXED_DEPOSIT"; + } + if (Integer.valueOf(300).equals(depositType)) { + return "RECURRING_DEPOSIT"; + } + return "SAVINGS"; + } + // Parses a Fineract transaction id string to Long. // Savings/current txn ids are decimal; GL journal entry ids are hex strings. private Long parseTxnIdToLong(final String txnId) { From dbc2737adab7566e2b9efa1896a1ceddc676ca36 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 23 Sep 2026 14:21:40 +0530 Subject: [PATCH 4/8] fix(deposits): FD interest posting silently missed its GL entry on single-period maturity SYMPTOM An FD account whose entire term fits inside one interest-posting period (e.g. a 27-day FD Single with Quarterly posting) matures with its balance and withdrawal correct, but the INTEREST_POSTING transaction that grew its balance has no matching acc_gl_journal_entry rows at all - confirmed by comparing against fineract_default's FD Double/Special accounts (ids 51/53), where every interest-posting transaction correctly has a Dr EXPENSES FOR ALL (Interest on Savings) Cr FIXED DEPOSITE (Savings Control) pair. Books still balance globally (the withdrawal's single lump-sum entry already carries the interest amount out of the FD's liability account), but the Interest Expense side is never recognized on its own, so P&L reporting for that period silently understates interest expense by exactly the missed amount. ROOT CAUSE updateMaturityDetails() snapshots existingTransactionIds ONCE at the very top, before calling updateMaturityStatus() (which creates the interest-posting transaction). The old comment here said this was deliberate - "interest postings ... must stay out of the snapshot so that final call can post them exactly once" - but when fdAccount.isMatured() is also true in this same run, handleFDAccountMaturityClosure() -> transferFunds() -> handleWithdrawal() runs its OWN, independent postJournalEntries() call with its OWN fresh snapshot taken at that moment - which, by then, already includes the just-created interest transaction, so IT treats it as "already accounted for" and skips it too. Neither call ever actually posts it: the outer method's own final postJournalEntries() call never gets the chance the old comment assumed it would, because by the time it runs the interest transaction id has already been folded in via markWithdrawalsAsAlreadyPosted() only covering the withdrawal, not the interest - and separately, whatever this inner independent snapshot saw first "claims" the transaction ID logically but the accounting processor never actually writes rows for it. Net effect: a real ledger transaction with zero GL entries, forever, for any FD whose interest posting and maturity coincide in the same job run. Multi-period FDs (a normal-length Double/Special posting across many separate nightly job runs) never hit this: on every non-maturing night isMatured() is false, the closure branch never runs, and the original single final postJournalEntries() call at the bottom correctly posts whatever interest was created that night - which is exactly what the fineract_default comparison confirms already works. FIX Inside the isFixedDeposit() branch, immediately after updateMaturityStatus(): flush + postJournalEntries() for whatever it just created (still using the ORIGINAL pre-updateMaturityStatus() snapshot, so the interest transaction is unambiguously "new"), then refresh existingTransactionIds/existingReversedTransactionIds before touching the maturity-closure branch at all. This gives the interest transaction exactly one correct GL posting regardless of whether maturity closure happens in the same run - and closes the same gap for FD Double/Special on the rare night their final period and maturity also coincide, not just FD Single. The closure branch itself (handleFDAccountMaturityClosure and everything inside it) is untouched - its own independent snapshot/ posting logic behaves exactly as before; it now correctly finds the interest transaction genuinely already posted instead of incorrectly assuming so. WHY THIS IS SAFE FOR THE EXISTING MULTI-PERIOD (DOUBLE/SPECIAL) PATH - On a non-maturing night: the new call posts the interest immediately instead of at the method's end - same net GL result, just earlier. If updateMaturityStatus() created nothing that night, the new call is a no-op (the exact same "call postJournalEntries() even when nothing changed" pattern the unconditional call at the very end of this method already used, every night, for every account, before this change - proven safe already). - The RecurringDepositAccount (else-if) branch is completely untouched. VERIFICATION DONE Not build/run-verified (no gradle, per this project's standing instruction) - verified by full manual trace of the exact snapshot/ posting interaction across updateMaturityDetails(), handleWithdrawal(), and postJournalEntries(), plus a direct DB comparison: fineract_default accounts 51 (DOUBLE) and 53 (SPECIAL) show correct interest GL entries for every posting (multi-period, never hits this bug); fineract_lincpay account 5 (SINGLE, 27-day, single-period) was missing exactly the entry this fix now produces. The specific missing entry on account 5 was backfilled by hand directly in the DB (not through this code) to match what this fix would have generated automatically. IF THIS BREAKS SOMETHING LATER - A DOUBLE/SPECIAL FD showing DUPLICATE interest GL entries for the same transaction -> check whether updateExistingTransactionsDetails() after the new postJournalEntries() call is actually being reached (i.e. no exception thrown by that call swallowing the refresh). - An FD's interest-posting transaction still missing its GL entry -> query acc_gl_journal_entry WHERE savings_transaction_id = and confirm 0 rows; if so, this fix (or its ordering relative to a future change here) has regressed. - Performance: this adds one extra saveAndFlush()+postJournalEntries() DB round trip per FD account per nightly "Update Deposit Accounts Maturity details" job run (which already iterates every ACTIVE FD/RD account unconditionally) - acceptable for a nightly batch job, but worth knowing if that job's runtime is ever investigated. Co-Authored-By: Claude Sonnet 5 --- ...WritePlatformServiceJpaRepositoryImpl.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 2ce19027e..788177150 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1512,6 +1512,23 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo if (depositAccountType.isFixedDeposit()) { ((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); FixedDepositAccount fdAccount = ((FixedDepositAccount) account); + + // Post the interest transaction(s) updateMaturityStatus() just created BEFORE any maturity + // closure below runs. The closure's own withdrawal (handleWithdrawal -> postJournalEntries) + // snapshots "existing" transactions the moment IT starts, which by then already includes + // this interest posting -- so its own posting treats the interest as already accounted for + // and skips it, while this method's own final postJournalEntries() call (previously relying + // on the interest staying out of existingTransactionIds so it would post there instead) + // never actually saw it get posted either: for an FD whose entire term is a single posting + // period -- interest posts and the account matures in this same run -- that left the + // interest transaction with a real ledger balance but no matching GL entry at all, forever. + // Posting it now, while it is still the only new thing on the account, gives it exactly one + // correct journal entry, and folding it into existingTransactionIds below (the same way + // markWithdrawalsAsAlreadyPosted() already does for the withdrawal) keeps the closure branch + // and the final call from ever trying to post it again. + this.savingAccountRepositoryWrapper.saveAndFlush(account); + postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds); + updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); // handle maturity instructions if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) { @@ -1524,11 +1541,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // Every maturity-instruction branch takes the balance out of this account through // SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal - // entries itself. existingTransactionIds was snapshotted at the top of this method -- - // before that withdrawal existed -- so the postJournalEntries() call at the end would - // treat it as new and post it a second time. Interest postings created by - // updateMaturityStatus() above are still genuinely unposted at this point and must stay - // out of the snapshot so that final call can post them exactly once. + // entries itself. existingTransactionIds was refreshed just above (after the interest was + // posted) -- before that withdrawal existed -- so the postJournalEntries() call at the end + // would treat it as new and post it a second time without this. markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); if (changes.get("reinvestedDepositId") != null) { From 26d4c241c764454ac289cb4a8ed13840055255c8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:54:03 +0530 Subject: [PATCH 5/8] fix(clerk-disbursement): validate savings withdrawal eligibility when a request is raised A clerk raising a disbursement request with a Dr leg against a savings account had no way to know the withdrawal would be refused (inactive account, still locked-in, or insufficient balance against its own minimum) until a manager tried to approve it and the whole request bounced back. Checks the same conditions SavingsAccount.withdraw() enforces at entry time instead, against the same columns, so the clerk sees the real reason immediately. Co-Authored-By: Claude Sonnet 5 --- .../ClerkDisbursementRequestApiResource.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 08d596d26..e75955127 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -177,6 +177,15 @@ public class ClerkDisbursementRequestApiResource { kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); } + // A Dr leg against a savings account is a withdrawal, and the clerk who raises it should + // not learn the account can't pay out until a manager tries to approve it and the whole + // request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would + // refuse this later -- inactive, closed, or still inside its lock-in period -- is checked + // here too, against the same columns, so the clerk sees the real reason at entry time. + if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType) + && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { + validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt); + } if ("Dr".equals(dbCr)) { totalDr = totalDr.add(entryAmt); } else { @@ -1179,6 +1188,51 @@ public class ClerkDisbursementRequestApiResource { .findFirst().orElse(null); } + /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** + * The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not + * active, or still inside its lock-in period -- checked here against the same columns, so the + * clerk sees the real reason when the request is raised rather than a generic failure once a + * manager tries to approve it. Balance is checked too, against the account's own minimum, since + * an entry that can never post is no more usable sitting in the approval queue than one refused + * outright. + */ + private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived, + min_required_balance, enforce_min_required_balance + FROM m_savings_account WHERE id = ? + """, savingsAccountId); + if (rows.isEmpty()) { + return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere + } + final Map row = rows.get(0); + final String accountNo = (String) row.get("account_no"); + final Number statusEnum = (Number) row.get("status_enum"); + if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active."); + } + final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived"); + if (lockedUntil != null) { + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate lockedUntilDate = lockedUntil.toLocalDate(); + if (today.isBefore(lockedUntilDate)) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + + ": account is locked-in until " + lockedUntilDate + "."); + } + } + final BigDecimal balance = (BigDecimal) row.get("account_balance_derived"); + final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance")); + final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null + ? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO; + if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance (" + + balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ")."); + } + } + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, final String requestType) { From a7cb09f15cb3d30cc77fabd4084489b21f0d10fa Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:54:57 +0530 Subject: [PATCH 6/8] feat(fd): add lean FD maturity dashboard read endpoint New GET /v1/fixeddepositaccounts/maturity-dashboard: a narrow, purpose-built query for the FD Dashboard's maturity ladder (account/client/product identity, maturity date, deposit/maturity amounts) instead of routing through the general FixedDepositAccountsApiResource#retrieveAll, whose mapper joins six extra tables for audit/currency/tax fields this screen never shows. Adds the index migration (0571) the dashboard's WHERE clause needs -- neither m_savings_account.deposit_type_enum/status_enum nor m_deposit_account_term_and_preclosure.maturity_date had one before, so the endpoint would otherwise run a full table scan on top of being wide. Co-Authored-By: Claude Sonnet 5 --- ...edDepositMaturityDashboardApiResource.java | 96 +++++++++++++++++++ ..._deposit_type_status_and_maturity_date.xml | 36 +++++++ 2 files changed, 132 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java new file mode 100644 index 000000000..c1a1c8b51 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java @@ -0,0 +1,96 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder + * needs (account/client/product identity, maturity date, deposit and maturity amounts). + * + * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that + * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, + * m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/ + * closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it + * needed for the single-account and full-list screens it serves, none of it needed here. Neither + * m_savings_account.deposit_type_enum/status_enum nor + * m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed + * directly against pg_indexes), so that query was a full table scan on top of being wide. This + * endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause + * this feature added (see migration 0571). + */ +@Path("/v1/fixeddepositaccounts/maturity-dashboard") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.") +@RequiredArgsConstructor +public class FixedDepositMaturityDashboardApiResource { + + /** Fineract's DepositAccountType.FIXED_DEPOSIT. */ + private static final int FIXED_DEPOSIT = 200; + /** SavingsAccountStatusType.ACTIVE. */ + private static final int ACTIVE = 300; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String retrieve() { + // Same permission as the single-account/full-list FD endpoints -- this is a narrower read of + // the same resource, not a new one, so no new permission is introduced for it. + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "id", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.display_name AS "clientName", + sp.name AS "productName", + sp.fd_type AS "fdType", + TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", + datp.deposit_amount AS "depositAmount", + datp.maturity_amount AS "maturityAmount", + datp.on_account_closure_enum AS "onClosureType", + sa.linked_account_id AS "linkedAccountId", + dptp.renewal_grace_period_days AS "renewalGracePeriodDays" + FROM m_savings_account sa + JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id + LEFT JOIN m_client c ON c.id = sa.client_id + WHERE sa.deposit_type_enum = ? AND sa.status_enum = ? + ORDER BY datp.maturity_date ASC + """, FIXED_DEPOSIT, ACTIVE); + + return this.gson.toJson(rows); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml new file mode 100644 index 000000000..4bf61c077 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + From ecfe8aa76a1dbbb8b0c7b410b8224d151d496a18 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:55:11 +0530 Subject: [PATCH 7/8] feat(fd): add Renewal Grace Period product field, both storage paths New per-FD-product "Renewal Grace Period (Days)" term field: null/0 (every existing product) keeps today's instant, same-night auto-renewal; a positive value is later read by the nightly maturity job and the manual Renew action to decide whether/how a matured FD waits for staff to act instead of renewing itself. Wired into the live product path end-to-end -- DepositsApiConstants param, DepositTermDetail column + diffing, DepositProductAssembler (create and update), DepositProductReadPlatformServiceImpl, FixedDepositProductData -- migration 0572 adds the column. Also wired into the maker-checker pending-edit mirror (m_saving_product_temp / FixedDepositEditFields / ProductPendingEditFieldMapper), a second, independent storage path a governed product edit uses -- without this the field silently dropped out of a checker's "Proposed changes" review even though it was submitted. Migration 0573 adds its column. Co-Authored-By: Claude Sonnet 5 --- .../savings/DepositsApiConstants.java | 9 ++++-- ...DepositProductReadPlatformServiceImpl.java | 2 ++ ...l_grace_period_to_deposit_product_term.xml | 26 +++++++++++++++++ ...al_grace_period_to_saving_product_temp.xml | 27 +++++++++++++++++ .../savings/data/FixedDepositProductData.java | 4 +++ .../domain/DepositProductAssembler.java | 20 ++++++++++--- .../savings/domain/DepositTermDetail.java | 29 +++++++++++++++++-- .../domain/FixedDepositEditFields.java | 3 ++ .../domain/ProductPendingEditFieldMapper.java | 5 ++++ 9 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml 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 c5704438a..03c685961 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 @@ -143,6 +143,9 @@ public final class DepositsApiConstants { public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm"; public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId"; public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType"; + // Days after maturity a depositor still has to walk in and renew at the original maturity date -- + // null/0 means this product keeps the instant, same-night auto-renewal every FD product had before. + public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays"; public static final String depositAmountParamName = "depositAmount"; public static final String depositMinAmountParamName = "minDepositAmount"; @@ -248,11 +251,13 @@ public final class DepositsApiConstants { private static final Set DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName, - inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); + inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName, + renewalGracePeriodDaysParamName)); private static final Set DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName, - inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); + inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName, + renewalGracePeriodDaysParamName)); private static final Set RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index d431acb5b..24f1b6aaf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -309,6 +309,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, "); sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, "); sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, "); + sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, "); sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, "); sqlBuilder.append("sp.customer_segment as customerSegment, "); sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, "); @@ -385,6 +386,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead // CBS Custom Fields result.setAccountType(rs.getString("accountType")); result.setFdType(rs.getString("fdType")); + result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays")); result.setProductCode(rs.getString("productCode")); result.setCustomerSegment(rs.getString("customerSegment")); result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId")); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml new file mode 100644 index 000000000..cce65c650 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml new file mode 100644 index 000000000..119cefcc6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java index 53c2e9199..88004012f 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java @@ -496,6 +496,7 @@ public final class FixedDepositProductData extends DepositProductData { private Boolean accountClosedZeroBalanceInDayClosing; private String balanceFrequency; private Boolean isPendingApproval; + private Integer renewalGracePeriodDays; // Template option setters public void setInterestCalculationTypeOption(Collection v) { this.interestCalculationTypeOption = v; } @@ -543,6 +544,7 @@ public final class FixedDepositProductData extends DepositProductData { public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; } public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; } + public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; } // CBS field getters public String getProductCode() { return productCode; } @@ -559,6 +561,7 @@ public final class FixedDepositProductData extends DepositProductData { public Long getDaysToInactive() { return daysToInactive; } public Long getDaysToDormancy() { return daysToDormancy; } public Long getDaysToEscheat() { return daysToEscheat; } + public Integer getRenewalGracePeriodDays() { return renewalGracePeriodDays; } public void copyCbsFieldsFrom(FixedDepositProductData src) { if (src == null) return; @@ -605,6 +608,7 @@ public final class FixedDepositProductData extends DepositProductData { this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; this.balanceFrequency = src.balanceFrequency; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; + this.renewalGracePeriodDays = src.renewalGracePeriodDays; } private Boolean withdrawalFeeForTransfers; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java index 96d4996d1..64402d2e7 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java @@ -36,6 +36,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepo import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName; @@ -473,8 +474,10 @@ public class DepositProductAssembler { final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); - return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, - inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, + maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + depositTermDetail.setRenewalGracePeriodDays(command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName)); + return depositTermDetail; } public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) { @@ -531,8 +534,17 @@ public class DepositProductAssembler { final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); - return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, - inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + Integer renewalGracePeriodDays = null; + if (command.parameterExists(renewalGracePeriodDaysParamName)) { + renewalGracePeriodDays = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName); + } else if (prodDepositTermDetail != null) { + renewalGracePeriodDays = prodDepositTermDetail.renewalGracePeriodDays(); + } + + final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, + maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + depositTermDetail.setRenewalGracePeriodDays(renewalGracePeriodDays); + return depositTermDetail; } public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java index 360e64ada..e892c733d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java @@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepo import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -61,6 +62,14 @@ public class DepositTermDetail { @Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true) private Integer inMultiplesOfDepositTermType; + // Days after maturity a depositor can still walk in and renew backdated to the original maturity + // date, at the currently active rate. Null/0 means this product keeps the instant, same-night + // auto-renewal every FD product had before this field existed -- see + // DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips the + // automatic renewal when this is a positive value. + @Column(name = "renewal_grace_period_days", nullable = true) + private Integer renewalGracePeriodDays; + public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm, final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType, final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) { @@ -123,6 +132,12 @@ public class DepositTermDetail { this.inMultiplesOfDepositTermType = newValue; } + if (command.isChangeInIntegerParameterNamed(renewalGracePeriodDaysParamName, this.renewalGracePeriodDays)) { + final Integer newValue = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName); + actualChanges.put(renewalGracePeriodDaysParamName, newValue); + this.renewalGracePeriodDays = newValue; + } + if (isMinDepositTermGreaterThanMaxDepositTerm()) { baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm", this.minDepositTerm, this.maxDepositTerm); @@ -166,6 +181,14 @@ public class DepositTermDetail { return this.inMultiplesOfDepositTermType; } + public Integer renewalGracePeriodDays() { + return this.renewalGracePeriodDays; + } + + public void setRenewalGracePeriodDays(final Integer renewalGracePeriodDays) { + this.renewalGracePeriodDays = renewalGracePeriodDays; + } + public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) { return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate); } @@ -260,7 +283,9 @@ public class DepositTermDetail { final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType .fromInt(this.inMultiplesOfDepositTermType()); - return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, - inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + final DepositTermDetail copy = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, + maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + copy.renewalGracePeriodDays = this.renewalGracePeriodDays; + return copy; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java index 8699d91cb..360a9d6dd 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java @@ -68,4 +68,7 @@ public class FixedDepositEditFields { @Column(name = "deposit_amount") private BigDecimal depositAmount; + + @Column(name = "renewal_grace_period_days") + private Integer renewalGracePeriodDays; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index 9fa9fe453..04534c5ac 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -942,6 +942,10 @@ public final class ProductPendingEditFieldMapper { f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount")); present.add("depositAmount"); } + if (command.hasParameter("renewalGracePeriodDays")) { + f.setRenewalGracePeriodDays(command.integerValueOfParameterNamed("renewalGracePeriodDays")); + present.add("renewalGracePeriodDays"); + } return present; } @@ -962,6 +966,7 @@ public final class ProductPendingEditFieldMapper { if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount()); if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount()); if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount()); + if (f.getRenewalGracePeriodDays() != null) putNumber(root, submitted, "renewalGracePeriodDays", f.getRenewalGracePeriodDays()); } public static Set populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) { From d2e13cc7b9363ba3c71c7846bd836ea3f1b468aa Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:55:25 +0530 Subject: [PATCH 8/8] feat(fd): dashboard-driven maturity renewal, plus fix stranded interest on Reinvest Principal Only A SINGLE/DOUBLE FD opened with an explicit maturity instruction still auto-processes on its own via the nightly job, exactly as before -- the grace period never delays that. An FD opened with NO instruction at all (the new default -- see the frontend's account-creation changes) is now never touched by the job; it just sits matured until staff renew it manually from the FD Dashboard, backdated to the original maturity date within the product's grace window, or from today with gap-period interest (at the linked account's rate) credited first once it's passed. New endpoints: - POST /fixeddepositaccounts/{id}/renew -- the Dashboard's own quick Reinvest choice (Principal+Interest / Principal Only), for the two-option case. - POST /fixeddepositaccounts/{id}/credit-gap-interest -- a pre-step the frontend's Payment dialog now calls before submitting the existing generic `payment` command, so the Dashboard's Renew action offers every closure type Payment does (Transfer to Savings, both Reinvest options, Partial Reinvest), not just two, while still crediting gap-period interest first. Also fixes a real, pre-existing bug found while building this: REINVEST_PRINCIPAL_ONLY never actually paid its leftover interest anywhere -- handleFDAccountMaturityClosure withdrew only the principal into the new deposit and never referenced its own toSavingsId parameter, silently stranding the interest on an account it also never closed. Now transfers the remainder to the target savings account and actually closes the old account. Co-Authored-By: Claude Sonnet 5 --- .../FixedDepositGapInterestApiResource.java | 66 +++++++ .../api/FixedDepositRenewalApiResource.java | 78 ++++++++ .../DepositAccountDomainServiceJpa.java | 28 +++ ...WritePlatformServiceJpaRepositoryImpl.java | 185 ++++++++++++++++++ .../savings/starter/SavingsConfiguration.java | 4 +- .../tenant/final-changelog-tenant.xml | 4 + ...l_grace_period_to_deposit_account_term.xml | 30 +++ .../DepositAccountWritePlatformService.java | 22 +++ 8 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java new file mode 100644 index 000000000..dd5191542 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic + * `payment` command (the same one the account's own Account Actions menu already uses) -- so the + * Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options, + * Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the + * gap-period interest credited first when the product's renewal grace window has passed. A no-op + * (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all -- + * see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.") +@RequiredArgsConstructor +public class FixedDepositGapInterestApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String creditGapInterest(@PathParam("accountId") final Long accountId) { + // Posts a real interest transaction when past grace, so it needs write/update, not just read. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java new file mode 100644 index 000000000..419e6247f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the + * original maturity date within the product's renewal grace window (0 days if none is configured), or + * from today with a gap-period interest credit (at the linked Savings account's rate) once the grace + * window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is + * made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored + * maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not + * consulted. All the actual renewal and gap-interest logic lives in + * DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same + * collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renew") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.") +@RequiredArgsConstructor +public class FixedDepositRenewalApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String renew(@PathParam("accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + // A renewal moves money (or credits gap-period interest), so it needs the write/update + // permission on this resource, not just read -- unlike the dashboard's own read-only endpoint. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject() + : JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject(); + final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull()) + ? body.get("reinvestType").getAsInt() + : null; + final Map result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 62d049e95..a884d9f19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -355,6 +355,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal reInvestAmount = account.getDepositAmount(); + // Whatever is left after the principal moves into the new deposit is interest the customer + // earned and has to actually be paid out somewhere -- it must not just sit on this account, + // which is about to be closed. Validated before anything below runs, so a missing target + // fails cleanly with nothing done rather than leaving a half-completed reinvestment. + final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount); + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { + throw new GeneralPlatformDomainRuleException( + "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", + "Reinvest Principal Only requires a Savings account to receive the leftover interest."); + } ExternalId externalId = this.externalIdFactory.create(); FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); @@ -384,6 +394,24 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); + // Pay out whatever didn't move into the new deposit, then actually close this account -- + // previously this branch left the leftover interest stranded on an account that was never + // closed, silently, because nothing past this point ever referenced toSavingsId at all. + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, + "Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null, + AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, + isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + account.updateClosedStatus(); + account.updateOnAccountClosureStatus(onClosureType); + } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 788177150..4484134c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -28,6 +28,7 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; @@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail; import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct; import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; @@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo private final WorkingDaysRepositoryWrapper workingDaysRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -1531,11 +1536,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); // handle maturity instructions + // An account opened WITH an explicit maturity instruction (Transfer to Savings or either + // Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the + // renewal grace period never delays this, no matter what the product has configured. Grace + // only matters for an account that was opened with NO instruction at all: that one is never + // touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a + // null closure type), so it just sits matured until a human renews it from the FD Dashboard + // -- see renewMaturedFixedDeposit(), which is where the grace window is actually read. if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); Map changes = new HashMap<>(); final AppUser user = context.authenticatedUser(); Long toSavingsId = fdAccount.getTransferToSavingsAccountId(); + // Reinvest Principal Only needs a target for the leftover interest (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening + // form only collects transferToSavingsId when Transfer to Savings itself was chosen, so + // an account opened straight into Reinvest Principal Only can reach here with none set. + // Falls back to the FD's own Deposit Account Link rather than failing the whole job run. + if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { + toSavingsId = fdAccount.getLinkedAccountId(); + } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1578,6 +1598,171 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds); } + @Transactional + @Override + public Map renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) { + final AppUser user = context.authenticatedUser(); + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet -- nothing to renew."); + } + // The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE + // FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because + // what happens at maturity is now decided here, by staff, at the moment of renewal. + final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType); + if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required", + "Choose Reinvest Principal + Interest or Reinvest Principal Only."); + } + // Only Principal pays the leftover interest out to the FD's own linked Savings account -- the + // account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this + // should always be present, but fail clearly rather than silently stranding the interest if not. + final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId(); + if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, " + + "so the leftover interest has somewhere to go."); + } + + // Renewal grace period only shrinks the backdate window below -- it is no longer a precondition + // for whether Renew is allowed at all. A product that never configured one behaves as a 0-day + // window: renewing on the maturity date itself backdates, any later renewal pays gap interest. + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { + renewalGracePeriodDays = 0; + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays); + final boolean withinGrace = !today.isAfter(graceDeadline); + + final Map result = new HashMap<>(); + BigDecimal gapInterestAmount = null; + + if (!withinGrace) { + // Late: the backdated renewal is no longer offered. The gap between the original maturity + // date and today is compensated at the linked Savings account's own product rate (simple + // interest for that stretch) before the FD renews from today, so the new term's principal + // already includes it. + gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today); + } + + final LocalDate renewalDate = withinGrace ? maturityDate : today; + final Map changes = new HashMap<>(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType, + chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null, + withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate", + changes); + markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + // REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId); + // REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that + // actually carries the new term's maturity date/amount either way. + FixedDepositAccount renewedAccount = fdAccount; + if (changes.get("reinvestedDepositId") != null) { + renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), + DepositAccountType.FIXED_DEPOSIT); + } + result.put("renewedAccountId", renewedAccount.getId()); + result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null); + result.put("maturityAmount", renewedAccount.maturityAmount()); + result.put("backdated", withinGrace); + if (gapInterestAmount != null) { + result.put("gapInterestAmount", gapInterestAmount); + } + return result; + } + + @Transactional + @Override + public Map creditGapInterestIfPastGrace(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet."); + } + + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { + renewalGracePeriodDays = 0; + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + + final Map result = new HashMap<>(); + result.put("withinGrace", withinGrace); + result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today)); + return result; + } + + /** + * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its + * linked Savings/Current account's product -- credited onto the FD itself as a genuine + * INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the + * cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD + * Dashboard's generic Payment command) reads the account's balance. Shared by + * {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's + * Renew action now calls the latter as a pre-step before submitting the same generic `payment` + * command the account's own Account Actions menu already uses, so every closure type (Transfer to + * Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the + * two this method's sibling endpoint originally supported. + */ + private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { + final Long linkedAccountId = fdAccount.getLinkedAccountId(); + if (linkedAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " + + "before renewing late, so the gap-period interest has a rate to use."); + } + final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" + SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, BigDecimal.class, linkedAccountId); + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 + : fdAccount.getInterestCalculationDaysInYearType(); + final MathContext mc = MoneyHelper.getMathContext(); + final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc) + .multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc) + .setScale(2, java.math.RoundingMode.HALF_UP); + + if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(), + today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true); + fdAccount.addTransaction(gapInterestTxn); + fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + } + return gapInterestAmount; + } + private void updateExistingTransactionsDetails(SavingsAccount account, Set existingTransactionIds, Set existingReversedTransactionIds) { existingTransactionIds.addAll(account.findExistingTransactionIds()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 1eef688a9..976d2e6ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -297,7 +297,7 @@ public class SavingsConfiguration { ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository, WorkingDaysRepositoryWrapper workingDaysRepository, DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, - DenominationBreakdownWriter denominationBreakdownWriter + DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate ) { return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, @@ -307,7 +307,7 @@ public class SavingsConfiguration { chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService, accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository, configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository, - denominationBreakdownWriter); + denominationBreakdownWriter, jdbcTemplate); } @Bean diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 545b6450b..9813f48f7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -116,4 +116,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml new file mode 100644 index 000000000..ac4917332 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 979941229..11b836c37 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -92,5 +92,27 @@ public interface DepositAccountWritePlatformService { void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType); + /** + * Manually renews a matured FD from the FD Dashboard -- backdated to the original maturity date if + * still within the product's renewal grace window (0 days if none is configured), or from today + * with a gap-period interest credit (at the linked Savings account's product rate) if not. + * {@code reinvestType} is the staff's choice made at renewal time (300 = Reinvest Principal and + * Interest, 400 = Reinvest Principal Only -- the rest of the balance is paid to the FD's linked + * Savings account); the account's own stored maturity instruction, if any, is not consulted. + * Returns a small result map: renewedAccountId, maturityDate, maturityAmount, backdated (boolean), + * gapInterestAmount (BigDecimal, present only when backdated is false). + */ + Map renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); + + /** + * Credits gap-period interest (simple interest, at the linked Savings/Current account's product + * rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning + * zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the + * generic `payment` command (the same one the account's own Account Actions menu uses), so every + * closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit} + * itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). + */ + Map creditGapInterestIfPastGrace(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); }