feat(fd): add read-only renewal preview endpoint, fix stale linked-account reads
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 12:40:55 +05:30
co-authored by Claude Sonnet 5
parent 331c553164
commit 2c49d6f637
3 changed files with 178 additions and 31 deletions
@@ -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<String, Object> result = this.depositAccountWritePlatformService.previewFdRenewal(accountId);
return this.gson.toJson(result);
}
}
@@ -1554,7 +1554,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// an account opened straight into Reinvest Principal Only can reach here with none set. // 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. // Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = fdAccount.getLinkedAccountId(); toSavingsId = resolveLinkedAccountId(fdAccount.getId());
} }
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); 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 // 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 // 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. // 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) { if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", 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, " "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 // 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 // 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. // window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
Integer renewalGracePeriodDays = null; final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
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 maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate(); final LocalDate today = DateUtils.getBusinessLocalDate();
@@ -1697,17 +1691,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
"This FD has not matured yet."); "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 maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate(); final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount)));
final Map<String, Object> result = new HashMap<>(); final Map<String, Object> result = new HashMap<>();
result.put("withinGrace", withinGrace); result.put("withinGrace", withinGrace);
@@ -1715,6 +1701,108 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
return result; 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<String, Object> previewFdRenewal(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
final Map<String, Object> 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 * 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 * 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. * two this method's sibling endpoint originally supported.
*/ */
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { 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) { if (linkedAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", 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 " "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."); + "before renewing late, so the gap-period interest has a rate to use.");
} }
final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
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 long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
: 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) { if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>(); final Set<Long> existingTransactionIds = new HashSet<>();
@@ -114,5 +114,14 @@ public interface DepositAccountWritePlatformService {
*/ */
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId); Map<String, Object> 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<String, Object> previewFdRenewal(Long fdAccountId);
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
} }