From 1e903232f042efab0033ca92efd833f1e0c78191 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 14:11:50 +0530 Subject: [PATCH] fix(fd): pay late-renewal gap interest to the linked Savings account, not the FD Gap-period interest is earned at the linked account's rate, not the FD's -- folding it onto the FD's own balance meant "Re-Invest Maturity Amount" silently carried savings interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" paid it out through the FD's own closure math instead of it already being where it belongs. Now posted directly onto the linked account as its own INTEREST_POSTING transaction; the FD's balance is left untouched, so every closure type renews/transfers its real maturity amount. Renewal Preview updated to match (projectedBalance is now just the FD's own balance) and now also returns the linked account's own account number, so staff can see exactly where the gap interest is about to land before confirming. Co-Authored-By: Claude Sonnet 5 --- ...WritePlatformServiceJpaRepositoryImpl.java | 63 ++++++++++++------- .../DepositAccountWritePlatformService.java | 21 ++++--- 2 files changed, 54 insertions(+), 30 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 a992682a9..5f4b58c09 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 @@ -1704,9 +1704,11 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo /** * Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment * dialog before the staff member confirms anything -- the actual current balance, the linked - * account's rate, the gap-day count, the computed gap-interest amount, and the balance the FD will - * carry into its new term once that interest lands. Mirrors {@link #creditGapInterest} exactly but - * never posts anything; a within-grace account gets zero for the gap fields since nothing is owed. + * account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid + * onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so + * projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews. + * Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets + * zero for the gap fields since nothing is owed. */ @Transactional(readOnly = true) @Override @@ -1750,12 +1752,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo result.put("projectedBalance", null); return result; } + result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId)); final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); result.put("linkedAccountRate", linkedAccountRate); result.put("gapInterestAmount", gapInterestAmount); - result.put("projectedBalance", currentBalance.add(gapInterestAmount)); + // Paid onto the linked savings account, not this FD -- what renews is still just the FD's own + // current balance. + result.put("projectedBalance", currentBalance); return result; } @@ -1790,6 +1795,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo """, BigDecimal.class, linkedAccountId); } + /** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} -- + * shown in the Renewal Preview so staff can see exactly which account the gap interest is about + * to land on, not just that "a linked account" exists. */ + private String resolveLinkedAccountNo(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId); + } + /** Simple interest on the FD's own current balance, for the stretch between its maturity date and * today, at the linked account's product rate -- the pure calculation, shared by the posting path * ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number @@ -1805,23 +1817,30 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo /** * 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. + * linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine + * INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in + * the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at + * the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding + * it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings + * interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay + * it out through the FD's own closure math instead of it already being where it belongs. The FD's + * own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or + * the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap + * interest already sitting separately in the linked account. 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 = resolveLinkedAccountId(fdAccount.getId()); 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."); + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it " + + "before renewing late, so the gap-period interest has somewhere to be paid."); } + final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT); final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); @@ -1829,14 +1848,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { final Set existingTransactionIds = new HashSet<>(); final Set existingReversedTransactionIds = new HashSet<>(); - updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + updateExistingTransactionsDetails(linkedAccount, 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); + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount, + linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true); + linkedAccount.addTransaction(gapInterestTxn); + linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount); + postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds); } return gapInterestAmount; } 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 b588f565e..3c18db4df 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 @@ -105,12 +105,15 @@ public interface DepositAccountWritePlatformService { 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). + * Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto + * that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has + * passed; a no-op, returning zero, if still within it. Paid to the linked account because it was + * earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it + * renews from its real maturity amount regardless of which closure type follows. 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); @@ -118,8 +121,10 @@ public interface DepositAccountWritePlatformService { * Read-only preview of what renewing this matured FD right now would do -- shown in the FD * Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns * isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace, - * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountRate, - * gapInterestAmount and projectedBalance (the balance the new term would start from). + * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo, + * linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and + * projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually + * renews). */ Map previewFdRenewal(Long fdAccountId);