From dd678f41cf1cae309107948605638150b7cd9853 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Thu, 18 Jun 2026 15:00:56 +0200 Subject: [PATCH 1/2] FINERACT-2455: working capital amortization recalculation --- ...rkingCapitalLoanTransactionRepository.java | 15 ++++ ...talLoanDiscountFeeAmortizationService.java | 7 ++ ...oanDiscountFeeAmortizationServiceImpl.java | 38 +++++++-- ...ngCapitalLoanWritePlatformServiceImpl.java | 34 +++++--- ...apitalLoanDiscountFeeAmortizationTest.java | 79 +++++++++++++++++-- 5 files changed, 146 insertions(+), 27 deletions(-) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java index 2586eb60b87..fe6c38e5322 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.repository; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; import org.apache.fineract.infrastructure.core.domain.ExternalId; @@ -45,6 +46,20 @@ public interface WorkingCapitalLoanTransactionRepository extends JpaRepository findActiveByTypeOrderByIdDesc(@Param("wcLoanId") Long wcLoanId, @Param("transactionType") LoanTransactionType transactionType); + /** Net amortized discount fee income from non-reversed transactions: sum(amortization) - sum(adjustment). */ + // 'else 0' is required by EclipseLink's CASE grammar; EclipseLink also rejects unary negation in JPQL CASE, so the + // adjustment branch subtracts via (0 - amount) rather than -amount. + @Query(""" + select coalesce(sum(case when t.transactionType = :amortizationType then t.transactionAmount + when t.transactionType = :adjustmentType then (0 - t.transactionAmount) + else 0 end), 0) + from WorkingCapitalLoanTransaction t + where t.wcLoan.id = :wcLoanId and t.reversed = false + and t.transactionType in (:amortizationType, :adjustmentType) + """) + BigDecimal sumNetAmortization(@Param("wcLoanId") Long wcLoanId, @Param("amortizationType") LoanTransactionType amortizationType, + @Param("adjustmentType") LoanTransactionType adjustmentType); + Optional findByWcLoan_IdAndExternalId(Long wcLoanId, ExternalId externalId); boolean existsByExternalId(ExternalId externalId); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java index 3a7365985d1..a2708863afe 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java @@ -24,4 +24,11 @@ public interface WorkingCapitalLoanDiscountFeeAmortizationService { void processDiscountFeeAmortization(WorkingCapitalLoan loan, LocalDate transactionDate); + + /** + * Recomputes {@code realizedIncomeFromDiscountFee} on the loan balance from the database aggregate of non-reversed + * amortization transactions. Callers must flush any pending amortization transaction posts or reversals before + * invoking this method, otherwise the aggregate will not reflect them. + */ + void recalculateRealizedIncome(WorkingCapitalLoan loan); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java index d8b99dbffa8..82443fb0b02 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java @@ -53,10 +53,16 @@ public class WorkingCapitalLoanDiscountFeeAmortizationServiceImpl implements Wor @Transactional public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final LocalDate transactionDate) { final BigDecimal scheduleAmortization = calculateScheduleAmortization(loan); - final boolean loanOverpaid = loan.getLoanStatus().isOverpaid(); - final BigDecimal alreadyPosted = loan.getBalance() != null ? loan.getBalance().getRealizedIncomeFromDiscountFee() : BigDecimal.ZERO; + // Fully paid (obligations met) or overpaid: recognize the whole discount immediately. The schedule carries an + // NPV residual that a lump-sum payoff never fully consumes, so close must recognize the full discount, not the + // schedule amount. Written-off / charge-off states follow a separate write-off accounting path and are excluded + // here. + final boolean fullyPaid = loan.getLoanStatus().isOverpaid() || loan.getLoanStatus().isClosedObligationsMet(); + // Derive the already-amortized income from the (non-reversed) amortization transactions rather than the cached + // balance, so the recalculation stays correct after backdated payments, reversals or schedule config changes. + final BigDecimal alreadyPosted = queryNetAmortized(loan.getId()); - if (MathUtil.isZero(scheduleAmortization) && !loanOverpaid && MathUtil.isZero(alreadyPosted)) { + if (MathUtil.isZero(scheduleAmortization) && !fullyPaid && MathUtil.isZero(alreadyPosted)) { log.debug("Skipping discount fee amortization for WC loan [{}] - no amortization on schedule", loan.getId()); return; } @@ -64,7 +70,7 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() : BigDecimal.ZERO; - final BigDecimal amortizationAmount = loanOverpaid && MathUtil.isGreaterThanZero(discount) ? discount.subtract(alreadyPosted) + final BigDecimal amortizationAmount = fullyPaid && MathUtil.isGreaterThanZero(discount) ? discount.subtract(alreadyPosted) : scheduleAmortization.subtract(alreadyPosted); if (MathUtil.isZero(amortizationAmount)) { @@ -73,26 +79,42 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final return; } - final boolean isChargedOff = false; + // Charge-off accounting is out of scope here (see the full-discount note above), so amortization is always + // posted as not-charged-off. if (MathUtil.isGreaterThanZero(amortizationAmount)) { final WorkingCapitalLoanTransaction amortizationTxn = WorkingCapitalLoanTransaction.discountFeeAmortization(loan, amortizationAmount, transactionDate, externalIdFactory.create()); transactionRepository.saveAndFlush(amortizationTxn); - accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, isChargedOff); + accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, false); } else { final BigDecimal adjustmentAmount = amortizationAmount.negate(); final WorkingCapitalLoanTransaction adjustmentTxn = WorkingCapitalLoanTransaction.discountFeeAmortizationAdjustment(loan, adjustmentAmount, transactionDate, externalIdFactory.create()); linkToTriggeringDiscountAdjustment(loan, adjustmentTxn); transactionRepository.saveAndFlush(adjustmentTxn); - accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, isChargedOff); + accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, false); } - loan.getBalance().setRealizedIncomeFromDiscountFee(alreadyPosted.add(amortizationAmount)); + recalculateRealizedIncome(loan); log.debug("Posted discount fee amortization of {} for WC loan [{}]", amortizationAmount, loan.getId()); } + @Override + @Transactional + public void recalculateRealizedIncome(final WorkingCapitalLoan loan) { + if (loan.getBalance() == null) { + return; + } + // Requires any amortization transaction posts/reversals to be flushed first, so the aggregate sees them. + loan.getBalance().setRealizedIncomeFromDiscountFee(queryNetAmortized(loan.getId())); + } + + private BigDecimal queryNetAmortized(final Long loanId) { + return transactionRepository.sumNetAmortization(loanId, LoanTransactionType.DISCOUNT_FEE_AMORTIZATION, + LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT); + } + private BigDecimal calculateScheduleAmortization(final WorkingCapitalLoan loan) { final MathContext mc = MoneyHelper.getMathContext(); return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan)) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index e5eef766bcd..324408e0a05 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -992,10 +992,14 @@ private boolean isBackdatedTransaction(final List || (txn.getTransactionDate().equals(newTxn.getTransactionDate()) && txn.getId().compareTo(newTxn.getId()) > 0)); } - private void reverseTransaction(final WorkingCapitalLoanTransaction txn) { + private void markReversed(final WorkingCapitalLoanTransaction txn) { txn.setReversed(true); txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); txn.setReversalExternalId(ExternalId.generate()); + } + + private void reverseTransaction(final WorkingCapitalLoanTransaction txn) { + markReversed(txn); this.transactionRepository.save(txn); this.transactionRepository.flush(); } @@ -1015,17 +1019,22 @@ private WorkingCapitalLoanTransaction reverseDisbursementTransactionAndResetBala } final WorkingCapitalLoanTransaction txn = activeDisbursements.getFirst(); - transactions.forEach(this::reverseTransaction); + transactions.forEach(this::markReversed); + this.transactionRepository.saveAll(transactions); + this.transactionRepository.flush(); - final Optional balanceOpt = this.balanceRepository.findByWcLoan_Id(loan.getId()); - balanceOpt.ifPresent(b -> { + // Operate on loan.getBalance() directly: it is the single managed balance instance that + // recalculateRealizedIncome writes to, so all updates here apply to the same object that gets persisted. + final WorkingCapitalLoanBalance balance = loan.getBalance(); + if (balance != null) { // Restore balance to pre-disbursement state. - b.setPrincipal(loan.getApprovedPrincipal() != null ? loan.getApprovedPrincipal() : loan.getProposedPrincipal()); - b.setPrincipalPaid(BigDecimal.ZERO); - b.setRealizedIncomeFromDiscountFee(BigDecimal.ZERO); - b.setOverpaymentAmount(BigDecimal.ZERO); - this.balanceRepository.saveAndFlush(b); - }); + balance.setPrincipal(loan.getApprovedPrincipal() != null ? loan.getApprovedPrincipal() : loan.getProposedPrincipal()); + balance.setPrincipalPaid(BigDecimal.ZERO); + // All transactions were just reversed, so the single owner recomputes realized income to zero from them. + discountFeeAmortizationService.recalculateRealizedIncome(loan); + balance.setOverpaymentAmount(BigDecimal.ZERO); + this.balanceRepository.saveAndFlush(balance); + } return txn; } @@ -1056,15 +1065,14 @@ private void createNote(final String noteText, final WorkingCapitalLoan loan) { private void reverseDiscountFeeAmortizationAdjustments(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction discountAdjustment) { - final WorkingCapitalLoanBalance balance = loan.getBalance(); relationRepository.findAllByToTransactionAndFromTransactionReversedAndFromTransactionTransactionType(discountAdjustment, false, LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT).forEach(relation -> { final WorkingCapitalLoanTransaction txn = relation.getFromTransaction(); reverseTransaction(txn); accountingProcessor.postReversalJournalEntries(loan, txn); - balance.setRealizedIncomeFromDiscountFee( - MathUtil.nullToZero(balance.getRealizedIncomeFromDiscountFee()).add(txn.getTransactionAmount())); }); + // Realized income is recomputed from the (now-reversed) transactions by its single owner, not adjusted here. + discountFeeAmortizationService.recalculateRealizedIncome(loan); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanDiscountFeeAmortizationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanDiscountFeeAmortizationTest.java index 02dfb636269..92959bb1fce 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanDiscountFeeAmortizationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanDiscountFeeAmortizationTest.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; @@ -63,6 +64,7 @@ public class FeignWorkingCapitalLoanDiscountFeeAmortizationTest extends FeignIntegrationTest { private static final String DISCOUNT_FEE_AMORTIZATION_CODE = "loanTransactionType.discountFeeAmortization"; + private static final String DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT_CODE = "loanTransactionType.discountFeeAmortizationAdjustment"; private FeignWorkingCapitalLoanHelper wcLoanHelper; private FeignClientHelper clientHelper; @@ -442,9 +444,8 @@ void testAmortizationCreatedInlineOnLoanClose() { DISCOUNT_FEE_AMORTIZATION_CODE); assertEquals(1, amortTxns.size(), "Amortization should be created inline on loan close without COB"); final BigDecimal amortAmount = amortTxns.get(0).getTransactionAmount(); - assertTrue(amortAmount.compareTo(BigDecimal.ZERO) > 0, "Amortization amount should be positive, was: " + amortAmount); - assertTrue(amortAmount.compareTo(discount) <= 0, - "Amortization amount should not exceed discount — amort: " + amortAmount + ", discount: " + discount); + assertEquals(0, amortAmount.compareTo(discount), + "Closing the loan must recognize the full discount — amort: " + amortAmount + ", discount: " + discount); // Verify journal entries final List entries = getJournalEntriesForWCTransaction(amortTxns.get(0).getId()); @@ -482,12 +483,78 @@ void testAmortizationCreatedInlineOnLoanOverpay() { DISCOUNT_FEE_AMORTIZATION_CODE); assertEquals(1, amortTxns.size(), "Amortization should be created inline on loan overpay without COB"); final BigDecimal amortAmount = amortTxns.get(0).getTransactionAmount(); - assertTrue(amortAmount.compareTo(BigDecimal.ZERO) > 0, "Amortization amount should be positive, was: " + amortAmount); - assertTrue(amortAmount.compareTo(discount) <= 0, - "Amortization amount should not exceed discount — amort: " + amortAmount + ", discount: " + discount); + assertEquals(0, amortAmount.compareTo(discount), + "Overpaying the loan must recognize the full discount — amort: " + amortAmount + ", discount: " + discount); }); } + // Test 9: a backdated repayment must keep net amortization consistent (recognition is time-capped), and overpay + // must recognize the full discount exactly. + @Test + @Order(9) + void testBackdatedRepaymentKeepsAmortizationConsistent() { + businessDateHelper.runAt("2026-09-01", () -> { + final Long testClientId = clientHelper.createClient("01 September 2026"); + final Long productId = createCashBasedProductWithDiscount(); + final BigDecimal principal = BigDecimal.valueOf(9000); + final BigDecimal discount = BigDecimal.valueOf(1000); + + final Long loanId = submitLoanWithDiscount(testClientId, productId, principal, discount, "01 September 2026"); + wcLoanHelper.approve(loanId, + WorkingCapitalLoanRequestBuilders.approveWithDiscount("01 September 2026", principal, "01 September 2026", discount)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburseWithDiscount("01 September 2026", principal, discount)); + + // Repayment on Sep 5, then COB → first partial amortization + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-05"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "05 September 2026")); + wcLoanHelper.executeInlineWCCOB(loanId); + + final List afterFirst = filterByType(wcLoanHelper.getTransactions(loanId), + DISCOUNT_FEE_AMORTIZATION_CODE); + assertEquals(1, afterFirst.size(), "Expected 1 amortization transaction after the first repayment"); + final BigDecimal totalAfterFirst = sumAmounts(afterFirst); + assertTrue(totalAfterFirst.compareTo(BigDecimal.ZERO) > 0, "First amortization should be positive"); + + // Backdated repayment on Sep 2, then COB: net amortization must stay non-decreasing and bounded by the + // discount. + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "02 September 2026")); + wcLoanHelper.executeInlineWCCOB(loanId); + + final BigDecimal netAfterBackdated = netAmortization(loanId); + assertTrue(netAfterBackdated.compareTo(totalAfterFirst) >= 0, + "Net amortization must not decrease after a backdated repayment — before: " + totalAfterFirst + ", after: " + + netAfterBackdated); + assertTrue(netAfterBackdated.compareTo(discount) <= 0, + "Net amortization must never exceed the discount — net: " + netAfterBackdated + ", discount: " + discount); + + // Overpay (cumulative 11000 > principal + discount = 10000) → inline full recognition. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(6000), "10 September 2026")); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", loan.getStatus().getCode(), "Loan should be overpaid after the final repayment"); + + final BigDecimal netAfterOverpay = netAmortization(loanId); + assertEquals(0, netAfterOverpay.compareTo(discount), + "After overpay, net amortization (amortization - adjustment) must equal the discount exactly — net: " + netAfterOverpay + + ", discount: " + discount); + }); + } + + /** Net recognized discount fee income from transactions: sum(amortization) - sum(amortization adjustment). */ + private BigDecimal netAmortization(final Long loanId) { + final List txns = wcLoanHelper.getTransactions(loanId); + return sumAmounts(filterByType(txns, DISCOUNT_FEE_AMORTIZATION_CODE)) + .subtract(sumAmounts(filterByType(txns, DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT_CODE))); + } + + private static BigDecimal sumAmounts(List transactions) { + return transactions.stream() + .map(txn -> Objects.requireNonNull(txn.getTransactionAmount(), + () -> "transactionAmount must not be null for transaction " + txn.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + private void assertJournalEntry(List entries, String expectedType, Account expectedAccount, BigDecimal expectedAmount) { final boolean found = entries.stream().anyMatch(entry -> { From 4804ce97b08d6677e2a41327c5b6c3e75c39e057 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Thu, 18 Jun 2026 15:00:56 +0200 Subject: [PATCH 2/2] FINERACT-2455: fix test expectation bug --- .../features/WorkingCapitalDiscountAdjustment.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature index 26b9e36d17f..a8bfa98dbfb 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature @@ -207,14 +207,14 @@ Feature: Working Capital Discount Adjustment | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false | | 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false | | 02 January 2026 | Repayment | 112.0 | 112.0 | 0.0 | 0.0 | false | - | 02 January 2026 | Discount Fee Amortization | 12.19 | | | | false | + | 02 January 2026 | Discount Fee Amortization | 12.0 | | | | false | Then Add Discount fee adjustment with "12" amount and transaction date "02 January 2026" on Working Capital loan account failed due to not active loan And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false | | 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false | | 02 January 2026 | Repayment | 112.0 | 112.0 | 0.0 | 0.0 | false | - | 02 January 2026 | Discount Fee Amortization | 12.19 | | | | false | + | 02 January 2026 | Discount Fee Amortization | 12.0 | | | | false | @TestRailId:C83034 Scenario: Verify Discount fee adjustment failed when loan is overpaid - UC11