From 2c49d6f63766165194ca84022c5d22a3dbdab900 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:55 +0530 Subject: [PATCH] feat(fd): add read-only renewal preview endpoint, fix stale linked-account reads GET /fixeddepositaccounts/{id}/renewal-preview -- shown in the FD Dashboard's Payment dialog before staff confirm a late renewal, so they see the actual numbers (linked account's rate, gap days, gap-interest amount, projected new balance) instead of confirming blind. Shares its calculation with the posting path (creditGapInterest) via a new computeGapInterestAmount helper, so the previewed number is exactly what gets credited. Also fixes fdAccount.getLinkedAccountId() returning null for an account confirmed to have linked_account_id set in the database -- every caller that needs the FD's Deposit Account Link now reads it fresh via SQL (resolveLinkedAccountId) instead of that stale JPA entity accessor. Co-Authored-By: Claude Sonnet 5 --- ...FixedDepositRenewalPreviewApiResource.java | 60 ++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 140 ++++++++++++++---- .../DepositAccountWritePlatformService.java | 9 ++ 3 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java new file mode 100644 index 000000000..7c3715caf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java @@ -0,0 +1,60 @@ +/** + * 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.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; + +/** + * Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms + * anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current + * balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the + * balance the new term will actually start from -- exactly the numbers + * {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the + * account, so this only needs read permission, unlike its posting sibling. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.") +@RequiredArgsConstructor +public class FixedDepositRenewalPreviewApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("accountId") final Long accountId) { + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.previewFdRenewal(accountId); + return this.gson.toJson(result); + } +} 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 4484134c7..a992682a9 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 @@ -1554,7 +1554,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // 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(); + toSavingsId = resolveLinkedAccountId(fdAccount.getId()); } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1620,7 +1620,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // 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(); + final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId()); 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, " @@ -1630,13 +1630,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // 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 int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate today = DateUtils.getBusinessLocalDate(); @@ -1697,17 +1691,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo "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 boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount))); final Map result = new HashMap<>(); result.put("withinGrace", withinGrace); @@ -1715,6 +1701,108 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo return result; } + /** + * 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. + */ + @Transactional(readOnly = true) + @Override + public Map previewFdRenewal(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + final Map result = new HashMap<>(); + if (!fdAccount.isMatured()) { + result.put("isMatured", false); + return result; + } + result.put("isMatured", true); + + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + final BigDecimal currentBalance = fdAccount.getAccountBalance(); + + result.put("maturityDate", maturityDate.toString()); + result.put("renewalGracePeriodDays", renewalGracePeriodDays); + result.put("withinGrace", withinGrace); + result.put("currentBalance", currentBalance); + + if (withinGrace) { + result.put("gapDays", 0L); + result.put("gapInterestAmount", BigDecimal.ZERO); + result.put("projectedBalance", currentBalance); + return result; + } + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + result.put("gapDays", gapDays); + + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); + result.put("linkedAccountId", linkedAccountId); + if (linkedAccountId == null) { + // No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking + // "link a Savings account first" message rather than a number, same as the posting path does. + result.put("gapInterestAmount", null); + result.put("projectedBalance", null); + return result; + } + + 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)); + return result; + } + + /** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */ + private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) { + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays; + } + + /** + * The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's + * {@code getLinkedAccountId()} -- an account whose link was set at creation through the real + * account-opening command reads back correctly there, but this call site kept returning null for + * an account confirmed to have {@code linked_account_id} set in the database, which every one of + * this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps + * whatever staleness that entity accessor has, rather than trying to chase it down across every + * caller that touches this account within the same request. + */ + private Long resolveLinkedAccountId(final Long savingsAccountId) { + return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + } + + private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) { + return 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); + } + + /** 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 + * shown to staff before they confirm is exactly the number that gets credited. */ + private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate, + final long gapDays) { + final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 + : fdAccount.getInterestCalculationDaysInYearType(); + final MathContext mc = MoneyHelper.getMathContext(); + return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc) + .divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP); + } + /** * 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 @@ -1728,25 +1816,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo * 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(); + 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."); } - 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 BigDecimal linkedAccountRate = resolveLinkedAccountRate(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); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { final Set existingTransactionIds = new HashSet<>(); 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 11b836c37..b588f565e 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 @@ -114,5 +114,14 @@ public interface DepositAccountWritePlatformService { */ Map creditGapInterestIfPastGrace(Long fdAccountId); + /** + * 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). + */ + Map previewFdRenewal(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); }