From ea39c8e82a9c1b457cc9fd0b482b2560c9c51c5d Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 11:22:31 +0530 Subject: [PATCH 1/8] fix(current-account): recognize Withdrawn and Rejected as terminal when checking for a duplicate product application countActiveByClientAndProduct only excluded status 600 (CLOSED), so a client whose prior application for the same product was Withdrawn (400) or Rejected (500) -- both terminal, per CurrentAccount.withdraw()/ reject() -- was still blocked from applying again, same as if that account were genuinely still open. Only 100/200/300 (submitted, approved, active) should collide with a fresh application. Co-Authored-By: Claude Sonnet 5 --- .../currentaccount/domain/CurrentAccountRepository.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java index 4c5617f97..944afff22 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java @@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository findActiveByClientId(@Param("clientId") Long clientId); - @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600") + // statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior + // account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw() + // /.reject(), are equally terminal and were still being counted as "still has one", blocking a + // fresh application the client should have been free to make. 100/200/300 (submitted, approved, + // active) are the only statuses a second application on the same product should actually collide + // with. + @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)") long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId); } From 8b2fb052d49509808491c360e054ed04a89e34e7 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:21 +0530 Subject: [PATCH 2/8] fix(fd): keep a genuinely-matured FD on the Dashboard instead of dropping it The maturity-dashboard query only ever selected ACTIVE(300) accounts. That was fine while nothing ever really reached MATURED(800) in this dev setup (test dates were only SQL-backdated, never run through the real nightly job), but the first time the job actually flipped an account's real status, it silently vanished from the ladder instead of showing under "Matured" -- exactly the state the ladder exists to surface. Now selects ACTIVE or MATURED. Co-Authored-By: Claude Sonnet 5 --- .../FixedDepositMaturityDashboardApiResource.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 index c1a1c8b51..9ab7d736f 100644 --- 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 @@ -33,8 +33,12 @@ 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). + * FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the + * real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no + * closure instruction to auto-process -- with just the fields the maturity ladder needs + * (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays + * here until it's actually renewed or closed, since that's exactly the "needs manual attention" + * state the ladder exists to surface. * * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, @@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource { private static final int FIXED_DEPOSIT = 200; /** SavingsAccountStatusType.ACTIVE. */ private static final int ACTIVE = 300; + /** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */ + private static final int MATURED = 800; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; @@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource { 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 = ? + WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) ORDER BY datp.maturity_date ASC - """, FIXED_DEPOSIT, ACTIVE); + """, FIXED_DEPOSIT, ACTIVE, MATURED); return this.gson.toJson(rows); } From 3fb008a22a846de0ebf95b1221a1230a40de564f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:28 +0530 Subject: [PATCH 3/8] fix(products): clearer error message for an FD product amount field that's too large The FD product's exception mapper fell back to a bare "Unknown data integrity issue with savings product." for any DB error it didn't recognize, with no detail -- unlike its sibling class for regular Savings products, which at least appends the underlying error. Added a specific, actionable message for the numeric-overflow case (an amount field entered too large for its column), and made the generic fallback include the real detail too. Co-Authored-By: Claude Sonnet 5 --- ...dDepositProductWritePlatformServiceJpaRepositoryImpl.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java index 301e3bbcf..053998bf9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -402,8 +402,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements msg = "Savings product with short name `" + shortName + "` already exists"; param = "shortName"; msgArgs = new Object[] { shortName, dae }; + } else if (checkEx.getMessage().contains("numeric field overflow")) { + msgCode += ".amount.too.large"; + msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again."; + msgArgs = new Object[] { dae }; } else { msgCode += ".unknown.data.integrity.issue"; + msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage(); msgArgs = new Object[] { dae }; } log.error("Error occured.", dae); From 331c55316439a3e907eb74ed36e9714e8c2a2def Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:46 +0530 Subject: [PATCH 4/8] fix(accounting): interest postings were never reaching the general ledger createInterestPostingJournalEntriesForSavings() documents a 3-stage posting (Dr Interest On Savings -> Cr Payable, Dr Payable -> Cr Paid, Dr Paid -> Cr Savings Control), but the first stage was never actually coded -- the interest expense account was never even fetched. For a product with no distinct Interest Payable/Paid accounts configured (every product in this system, checked directly), the remaining two stages then collapsed into a same-account no-op that correctly refuses to post, leaving interest silently added to the account's balance with zero GL entries. Affects every deposit type equally (Savings, FD, RD), not just FD -- confirmed by checking every product's accounting mappings. Added the missing stage. Co-Authored-By: Claude Sonnet 5 --- .../service/AccountingProcessorHelper.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index f8235a2a9..b5c375a61 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -651,6 +651,8 @@ public class AccountingProcessorHelper { final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); // The payable comes from the Interest Payable row on the product's Accounting tab, which is // where a bank actually picks it. The reserved name in the product's other GL mappings is @@ -664,13 +666,20 @@ public class AccountingProcessorHelper { final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) .orElse(payableAccount); - // The interest expense is not raised here. It is recognised as the interest accrues, day by - // day, and this posting settles that recognised debt - raising the expense again at payout - // would put the same interest on the P&L twice. This mirrors the pooled posting service, - // which pays a product's whole membership the same way; a closure reaching this path must - // produce the same entries as the nightly run, or the two disagree on what one payment did. + // Stage 1: the expense is recognised against whatever the liability sits in right now -- the + // named Payable account if the product has one, or straight against Savings Control if it + // doesn't. This was missing entirely: every product, configured or not, posted zero journal + // entries for interest until this leg existed, since a product with no Payable/Paid names + // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else + // to post in their place. + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // The interest expense is not raised again here. It was just raised above, and this posting + // only settles that recognised debt onward to where it is actually paid out from -- raising + // the expense a second time would put the same interest on the P&L twice. // - // Stage 1: the liability moves from the payable to the account it is paid out of. Where the + // Stage 2: the liability moves from the payable to the account it is paid out of. Where the // product names no paid account both resolve to the payable and this says nothing, so it is // skipped and the payout comes straight out of the payable. final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); @@ -679,7 +688,7 @@ public class AccountingProcessorHelper { isReversal); } - // Stage 2: into the member's own balance. + // Stage 3: into the member's own balance. final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); From 2c49d6f63766165194ca84022c5d22a3dbdab900 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:55 +0530 Subject: [PATCH 5/8] 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); } From 1e903232f042efab0033ca92efd833f1e0c78191 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 14:11:50 +0530 Subject: [PATCH 6/8] 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); From 87f3e591ce6aee9c51605dde39a6339125b3d89e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 10:34:39 +0530 Subject: [PATCH 7/8] fix(accounting): tag current-account GL entries entity_type_enum=CURRENT instead of reusing SAVING, everywhere this codebase links an entry to its account m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can be a real row in both. Every place that links a GL journal entry back to its account for display -- so the Journal Entries screen shows an Account No instead of a blank "-" -- was tagging entity_type_enum=2 (PortfolioProductType.SAVING) unconditionally, even when the account was actually a current account. Whenever a current account's id collided with an unrelated savings account's id, the entry resolved to the wrong one entirely: a customer's savings deposit could display someone else's current account number, or vice versa. Fixed at the write side, in every place found doing this: - ClerkDisbursementRequestApiResource (Bank Transfer, Collection, Disbursement) -- GlEntry now carries an isCurrentAccount flag, decided once where the account was already resolved, instead of being re-guessed. - ChequeBookIssuanceChargeService, ClearingJournalService, SponsorDDIssueRequestWritePlatformServiceImpl -- same pattern, each already knew savings vs current at the point it linked the entry; that answer is now threaded through instead of discarded. Also fixed on the read side: - JournalEntryReadPlatformServiceImpl: entity_type_enum=6 now resolves unambiguously to the current account; entity_type_enum=2 still falls back to savings_transaction_id (a reliable signal only a genuine savings transaction sets) before guessing, for older rows written before this fix. - JournalEntryReferralServiceImpl (AML referral resolution): the same ambiguity could attribute a suspicious-activity alert to the wrong client entirely -- a same-numbered stranger's account -- when a current account's entity_id collided with a savings account's. Resolved with the same entity_type=6 / savings_transaction_id precedence as the read-side fix. Not touched: JournalEntryAggregationJobReader (year-end aggregation batch job) has the same ambiguity for legacy entity_type_enum=2 rows, but only ever joins m_savings_account -- it has no current-account handling at all, standard-Fineract in origin. Extending it is a real feature addition, not a targeted fix, and touches a scheduled financial batch job this session cannot rebuild or run to verify; flagged for separate, deliberate work instead. Co-Authored-By: Claude Sonnet 5 --- .../JournalEntryReadPlatformServiceImpl.java | 27 ++++++----- .../JournalEntryReferralServiceImpl.java | 16 +++++-- .../ChequeBookIssuanceChargeService.java | 28 ++++++++--- .../service/ClearingJournalService.java | 18 ++++++-- .../ClerkDisbursementRequestApiResource.java | 46 +++++++++++++------ ...DIssueRequestWritePlatformServiceImpl.java | 35 ++++++++++---- 6 files changed, 123 insertions(+), 47 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 8115f66ea..7169ba0ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -105,17 +105,22 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append(" eao.external_id as externalAssetOwner, ") - // entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and - // current-account entries -- current products deliberately share SAVING's GL type - // space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two - // apart on its own, and m_savings_account/m_current_account each have their own id - // sequence: an entity_id can be a real row in both, making a plain - // COALESCE(ca.account_no, sa.account_no) pick the current account's number even for - // an entry that is genuinely a savings one. savings_transaction_id is the one - // unambiguous signal available here -- only a real savings transaction sets it -- so - // it decides first; only when it's absent (a current-account entry, or a savings - // entry with no linked transaction row) does the current-account guess apply. + // entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in + // m_current_account -- newer write paths tag current-account entries with it + // instead of reusing SAVING's type, specifically so this case needs no guessing. + // + // entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings + // and current-account entries by older write paths not yet updated to the above -- + // current products used to deliberately share SAVING's GL type space (see + // PortfolioProductType.CURRENT's own comment) -- and m_savings_account/ + // m_current_account each have their own id sequence, so an entity_id can be a real + // row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current + // account's number even for an entry that is genuinely a savings one. + // savings_transaction_id is the one unambiguous signal available for those older + // entries -- only a real savings transaction sets it -- so it decides first; only + // when it's absent does the current-account guess apply. .append(" CASE ") + .append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ") .append(" ELSE NULL ") @@ -141,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ") .append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ") .append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ") - .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 "); + .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) "); if (associationParametersData.isTransactionDetailsRequired()) { sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") .append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java index db4ec204d..0e1e65ff3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java @@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ * The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL * posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable. */ + // m_savings_account and m_current_account each keep their own identity sequence, so the same + // entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to + // be written for both savings and current-account entries alike, so esa/eca could both match the + // same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever + // client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of + // misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT) + // is what newer write paths tag current-account entries with instead, unambiguously; for older + // entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine + // savings transaction sets it), checked before the ambiguous current/savings guess. private static final String RESOLVE_SQL = """ SELECT je.amount AS amount, je.currency_code AS currencyCode, @@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ je.loan_transaction_id AS loanTxnId, je.entity_type_enum AS entityType, COALESCE( - esa.client_id, - eca.client_id, + CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END, + CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END, + CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END, eloan.client_id, CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END, sa.client_id, @@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ ) AS clientId FROM acc_gl_journal_entry je LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2 - LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2 + LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6) LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1 LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index 66d0c6950..1954371b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; @@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService { } postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, - accountId); + accountId, accountType); raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, chequeBookId); @@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService { /** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, - final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) { + final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId, + final ChequeAccountType accountType) { final JsonArray debitArr = new JsonArray(); final JsonObject debit = new JsonObject(); debit.addProperty("glAccountId", debitGlId); @@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService { // link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do. final String glTxnId = result.getTransactionId(); if (glTxnId != null) { - linkDebitEntryToAccount(glTxnId, accountId); + linkDebitEntryToAccount(glTxnId, accountId, accountType); } } - /** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */ - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an + * Account No. + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can + * be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of + * accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings + * account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually + * means "this id is in m_current_account" -- use it whenever accountType says so. + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = accountType == ChequeAccountType.CURRENT + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index 9180b5bf6..535ce45c9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; @@ -118,7 +119,7 @@ public class ClearingJournalService { } final String glTxnId = result.getTransactionId(); if (glTxnId != null && linkAccountId != null) { - linkDebitEntryToAccount(glTxnId, linkAccountId); + linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType); } raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); return glTxnId; @@ -191,7 +192,13 @@ public class ClearingJournalService { return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); } - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two + * account tables number independently, so entity_type_enum must say which one accountId names, not + * assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means + * "this id is in m_current_account". + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -200,8 +207,11 @@ public class ClearingJournalService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = "CURRENT".equalsIgnoreCase(accountType) + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } 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 b94837355..56f55e3ba 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 @@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherService; @@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource { ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); btTotalDr = btTotalDr.add(entryAmt); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); @@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); } @@ -681,7 +682,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(directCaTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true)); undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId)); } else { // Real CBS book transfer: direct credit to savings account. @@ -696,7 +697,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(directTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false)); undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId)); } } else { @@ -715,7 +716,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(debitTxnId)); withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); - collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount)); if (isCurrentAccount) { undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); } else { @@ -979,7 +980,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); } else { @@ -1005,7 +1006,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); } else { @@ -1568,20 +1569,27 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal amount; final String accountRef; // savings account number shown in GL comments; null for source/target GL final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries - final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries + final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries + final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId) { - this(glAccountId, amount, accountRef, txnId, null); + this(glAccountId, amount, accountRef, txnId, null, false); } GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, final Long savingsAccountId) { + this(glAccountId, amount, accountRef, txnId, savingsAccountId, false); + } + + GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, + final Long savingsAccountId, final boolean isCurrentAccount) { this.glAccountId = glAccountId; this.amount = amount; this.accountRef = accountRef; this.txnId = txnId; this.savingsAccountId = savingsAccountId; + this.isCurrentAccount = isCurrentAccount; } } @@ -1603,8 +1611,16 @@ public class ClerkDisbursementRequestApiResource { /** * After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) - * row to its savings account so the GL view shows ACCOUNT NO instead of "—". + * row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—". * Rows are matched to entries by insertion order (Fineract inserts in JSON array order). + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2 + * (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view + * would resolve a current account's entry to a same-numbered savings account or vice versa. + * entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's + * entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one + * this entry's id actually is, decided once already, where the account was first resolved. */ private void linkGlEntriesToSavingsAccounts(final String glTxnId, final List entries, final int typeEnum) { @@ -1618,11 +1634,15 @@ public class ClerkDisbursementRequestApiResource { Long.class, glTxnId, typeEnum, entries.size()); java.util.Collections.reverse(rowIds); for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) { - final Long savingsAccId = entries.get(i).savingsAccountId; + final GlEntry entry = entries.get(i); + final Long savingsAccId = entry.savingsAccountId; if (savingsAccId != null) { + final int entityType = entry.isCurrentAccount + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", - savingsAccId, rowIds.get(i)); + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", + entityType, savingsAccId, rowIds.get(i)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index d263de1bc..54471b2db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine; @@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; @@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); @@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD issuance - DD No " + ddNumber; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, - chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, @@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI Long resultingCurrentTransactionId = null; final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; // Book debit, no journal entry of its own -- see approveIssueRequest(). @@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException( @@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, - chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); for (final Map request : requests) { this.sponsorDDWritePlatformService.issueDemandDraft( @@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI * liability, so they get their own credit leg rather than being folded into the liability). Resolves * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's - * savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / - * entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching - * how Fineract's own auto-generated entries display. + * savings/current account for an account-debit DD), the Dr-side row is tagged + * entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so + * GL reports show the account number instead of a blank "—", matching how Fineract's own + * auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account + * each with their own sequence) can't resolve a same-numbered stranger's account instead. */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, - txnDate, comments, linkedAccountId); + txnDate, comments, linkedAccountId, false); } /** @@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, - final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { + final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId, + final boolean linkedAccountIsCurrent) { final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; @@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly. if (linkedAccountId != null) { + // m_current_account and m_savings_account each keep their own identity sequence, so the same + // numeric id can be a real row in both -- entity_type_enum must say which one linkedAccountId + // names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit + // branch resolves either a savingsAccountId or a currentAccountId into this same parameter). + final int entityType = linkedAccountIsCurrent + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = (" + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = (" + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + " ORDER BY id DESC LIMIT 1)", - linkedAccountId, transactionId); + entityType, linkedAccountId, transactionId); } final List> rows = this.jdbcTemplate.queryForList( From 2155c94161e0209792d54a2904ead7256775c421 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 11:41:18 +0530 Subject: [PATCH 8/8] fix(fd): Reinvest Principal Only renews in place, same account, instead of opening a new one Reinvest Principal Only was still on the legacy close-and-reopen path (open a new FD with just the principal, close the old one) while Reinvest Principal + Interest and Partial Reinvest had already moved to renewInPlace -- the same customer choosing a different split ended up with two different outcomes: same account number, or a brand new one. Unified all three onto renewInPlace: Principal Only is now exactly Partial Reinvest with the split fixed at the closing principal, so it renews on the same account with only the leftover interest actually leaving (to the chosen/linked Savings account), in both handleFDAccountPayment (Payment dialog / FD Dashboard Renew) and handleFDAccountMaturityClosure (the nightly job's path for an account with an already-stored instruction). Also fixes a related bug found while testing this: recordInPlaceRenewal re-archived the closing term's full rate chart on every renewal of the same term, even when it had already been archived by an earlier renewal -- archiveChartSlabs now only runs the first time a term's closing row is created. Co-Authored-By: Claude Sonnet 5 --- .../DepositAccountDomainServiceJpa.java | 193 ++++++------------ ...WritePlatformServiceJpaRepositoryImpl.java | 23 +-- 2 files changed, 72 insertions(+), 144 deletions(-) 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 a884d9f19..acdd50fdc 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 @@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.setClosedOnDate(closedDate); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest()) { + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) { // Renews on the SAME account, so the customer keeps one account number however many times - // the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new - // term's principal, so there is no withdrawal, no deposit and no journal entry for the - // rollover itself -- which also removes the transfer-suspense round trip the old - // close-and-reopen flow needed. + // the deposit rolls over -- Reinvest Principal Only renews just the closing principal here + // (below), with only the leftover interest actually leaving, rather than the legacy + // close-and-reopen this branch used to use, which left the customer with a second account + // number for something that renews here just as cleanly as Reinvest Principal + Interest + // does. Full reinvest itself still moves nothing: the whole matured balance simply becomes + // the new term's principal, so there is no withdrawal, no deposit and no journal entry for + // the rollover itself. final BigDecimal closingPrincipal = account.getDepositAmount(); final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null : maturedBalance.subtract(closingPrincipal); + final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance; + // Whatever doesn't renew is interest the customer earned and has to actually be paid out + // somewhere -- 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(renewedAmount); + if (onClosureType.isReinvestPrincipal() && 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."); + } // The active CBS base interest-rate version for this FD type: the same rate card the Base // Interest Rates screen shows and the account-opening wizard prices from. Used for both the @@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer closingTenure = account.depositPeriod(); final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); - account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); this.savingsAccountRepository.save(account); - recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate, + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate, closingTenure, closingTenureFrequencyEnum); changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - } else if (onClosureType.isReinvest()) { - // "Re-invest principal only" still closes and reopens, because only the principal carries - // forward and the interest has to leave the deposit -- which needs a payout destination that - // 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); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - // Renewal moves the balance internally into the new deposit -- no cash leaves the branch -- - // so this leg is posted as an account transfer (against the transfer-suspense GL) rather - // than as a counter withdrawal, which would wrongly credit the cash/vault account. - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - // handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as - // already accounted for -- exactly as the transfer-to-savings branch below does -- or the - // postJournalEntries() call at the end of this method posts it a second time. - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - // Reached from the maturity job processing the account's standing maturity instruction. - 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. + // Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is + // zero (renewedAmount already equals maturedBalance), so this stays a no-op for it. if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); @@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } - account.updateClosedStatus(); - account.updateOnAccountClosureStatus(onClosureType); } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, @@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); + // Only on this first-ever close of this term -- otherwise a term that renews more than once + // (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the + // same chart against the same closingRow every time, piling up duplicate slab rows for a + // chart that only ever needs archiving once. + archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); } closingRow.recordOutcome(closingInterestEarned, maturedBalance); closingRow.markMatured(); this.fixedDepositRenewalHistoryRepository.save(closingRow); - archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); - // The new term's own interest/maturity stay unset until it closes in turn. this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, @@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support * -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest - * transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through - * {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what - * the existing manual Close command still uses today. + * transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and + * Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just + * Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with + * only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest} + * this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's + * own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an + * already-stored instruction). *

* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as @@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { - // These two branches keep the account open, so they never reach close()/prematureClosure() and - // would otherwise accept any date at all -- including one behind already-posted interest, which - // the renewal would then turn into a pivot that reverses and re-posts it. + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { + // These three branches keep the account open, so they never reach close()/prematureClosure() + // and would otherwise accept any date at all -- including one behind already-posted interest, + // which the renewal would then turn into a pivot that reverses and re-posts it. account.validateRenewalDate(closedDate); final BigDecimal closingPrincipal = account.getDepositAmount(); @@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", "The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount); } + } else if (onClosureType.isReinvestPrincipal()) { + // Exactly the closing term's own principal -- the fixed point on the same split Partial + // Reinvest lets staff pick freely. Renews in place same as the other two, on just this + // account, with only the interest actually leaving (below) -- no second account, unlike the + // legacy close-and-reopen this branch used to do. + renewedAmount = closingPrincipal; } else { renewedAmount = maturedBalance; } @@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - if (onClosureType.isPartialReinvest()) { + if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { // The kept portion never left the account -- renewInPlace() just relabels it as the new - // term's principal -- so only the remainder is an actual money movement, posted the same - // way the Transfer to Savings branch below posts its full-balance transfer. + // term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is + // an actual money movement, posted the same way the Transfer to Savings branch below posts + // its full-balance transfer. final BigDecimal remainder = maturedBalance.subtract(renewedAmount); - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS, - PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null, - AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, - isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - } else if (onClosureType.isReinvestPrincipal()) { - // Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch -- - // the interest has to leave the deposit, which needs a payout destination an in-place renewal - // has nowhere to send. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - final ExternalId externalId = this.externalIdFactory.create(); - - final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); - - // Only the principal moved into the new deposit -- the interest earned on this term is still - // sitting in this account's balance. Pay it out to the chosen savings account now, before - // closing, rather than stranding it here with nowhere left to go. - final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount); - if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) { - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder, - PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, - null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, - toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - if (alreadyMatured) { - account.close(user, command, changes); - } else { - account.prematureClosure(user, command, changes); + if (remainder.compareTo(BigDecimal.ZERO) > 0) { + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, + null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, + toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } } } else if (onClosureType.isTransferToSavings()) { 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 5f4b58c09..42e258e8e 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 @@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); - // Reinvest Principal Only opens a second account and the domain method withdrew the principal out - // of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually - // land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. + // Reinvest Principal Only now renews in place on this same account (see + // DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually + // set any more, but this guard is left as a harmless no-op rather than removed, in case a future + // closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler @@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // would treat it as new and post it a second time without this. markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); + // Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, + // in case a future closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); - // activateWithBalance() always returns zero here -- it exists to stop a manually - // opened FD from auto-crediting itself at activation (funding is supposed to be a - // separate, clerk-posted deposit). A reinvestment isn't that: the balance was just - // withdrawn from the matured account and needs to land in the new one as part of - // this same automated closure, so it has to come from what reInvest() actually - // carried over onto the new account, not from that unrelated activation guard. Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); Money activationChargeAmount = getActivationCharge(reinvestAccount); if (activationChargeAmount.isGreaterThanZero()) { @@ -1663,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo 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. + // Both reinvest types now renew this same account in place (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, in case + // a future closure type reintroduces a genuine close-and-reopen. FixedDepositAccount renewedAccount = fdAccount; if (changes.get("reinvestedDepositId") != null) { renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),