From a9beddf3202591d13c9e6b2bfb2090364384d7f0 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Fri, 5 Jun 2026 15:37:55 +0200 Subject: [PATCH] FINERACT-2455: working capital transaction reprocessing --- .../WorkingCapitalLoanChargeRepository.java | 2 + ...nAmortizationScheduleWriteServiceImpl.java | 24 +- ...anDelinquencyRangeScheduleServiceImpl.java | 2 +- ...talLoanTransactionReprocessingService.java | 48 ++++ ...oanTransactionReprocessingServiceImpl.java | 65 +++++ ...ngCapitalLoanWritePlatformServiceImpl.java | 22 ++ ...apitalLoanTransactionReprocessingTest.java | 258 ++++++++++++++++++ 7 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java index 0a7bbc5e56d..22d4357f47b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java @@ -35,6 +35,8 @@ public interface WorkingCapitalLoanChargeRepository Long findIdByExternalId(ExternalId externalId); + boolean existsByLoanIdAndActiveTrue(Long loanId); + @Query("select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData(" + "lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol," + " oc.nameCode, lc.amount, lc.amountPaid, lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) from WorkingCapitalLoanCharge lc join fetch lc.charge c join OrganisationCurrency oc on c.currencyCode = oc.code join fetch lc.loan l where l.id = :loanId and lc.id = :id") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index a05f7c82911..3fcf0c1190c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -216,13 +216,8 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { final List preservedPayments = currentModel.snapshotActualPayments(); - final BigDecimal disbursedAmount = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() - && loan.getDisbursementDetails().getFirst().getActualAmount() != null - ? loan.getDisbursementDetails().getFirst().getActualAmount() - : BigDecimal.ZERO; - final LocalDate disbursementDate = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() - ? loan.getDisbursementDetails().getFirst().getActualDisbursementDate() - : null; + final BigDecimal disbursedAmount = resolveActualDisbursedAmount(loan); + final LocalDate disbursementDate = resolveActualDisbursementDate(loan); final ProjectedAmortizationScheduleModel restatedModel = generateProjectedAmortizationScheduleModel(loan, disbursedAmount, disbursementDate); @@ -241,4 +236,19 @@ private LocalDate resolveLoanDisbursementDate(final WorkingCapitalLoan loan) { } throw new IllegalStateException("Active loan " + loan.getId() + " has no actual disbursement date"); } + + private BigDecimal resolveActualDisbursedAmount(final WorkingCapitalLoan loan) { + if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() + && loan.getDisbursementDetails().getFirst().getActualAmount() != null) { + return loan.getDisbursementDetails().getFirst().getActualAmount(); + } + return BigDecimal.ZERO; + } + + private LocalDate resolveActualDisbursementDate(final WorkingCapitalLoan loan) { + if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()) { + return loan.getDisbursementDetails().getFirst().getActualDisbursementDate(); + } + return null; + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index 9119991a19c..f5dc1da3d02 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -145,7 +145,7 @@ public void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal am .findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(loanId, transactionDate, transactionDate); BigDecimal transactionAmount = amount; for (WorkingCapitalLoanDelinquencyRangeSchedule period : pastOpenPeriods) { - BigDecimal payAmount = MathUtil.min(amount, period.getOutstandingAmount(), true); + BigDecimal payAmount = MathUtil.min(transactionAmount, period.getOutstandingAmount(), true); transactionAmount = transactionAmount.subtract(payAmount); period.setPaidAmount(period.getPaidAmount().add(payAmount)); period.setOutstandingAmount(period.getOutstandingAmount().subtract(payAmount)); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java new file mode 100644 index 00000000000..fd83ab5d9ee --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java @@ -0,0 +1,48 @@ +/** + * 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.workingcapitalloan.service; + +import java.util.List; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +/** + * Reprocesses transaction allocations for a Working Capital loan after a backdated transaction or a transaction + * reversal changes the chronological order. + * + *

+ * Scope is deliberately narrow: only the allocation split (principal/fee/penalty portions) of affected transactions is + * recalculated, and only by the difference. Transactions themselves are never reversed or replayed, and the + * amortization, delinquency and breach schedules are not rebuilt here — those consume transaction amounts (not + * allocations) and are maintained incrementally by the regular transaction flows. + * + *

+ * Allocation order only matters when payments compete for charge buckets. A loan without charges allocates every + * repayment-like transaction to principal only, which is order-independent — reprocessing is a no-op in that case. + */ +public interface WorkingCapitalLoanTransactionReprocessingService { + + void reprocessTransactions(WorkingCapitalLoan loan); + + /** + * Reprocesses using the provided pre-loaded transaction list (avoids a redundant DB query when the caller has + * already fetched them). + */ + void reprocessTransactions(WorkingCapitalLoan loan, List allTransactions); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java new file mode 100644 index 00000000000..251439f41a2 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java @@ -0,0 +1,65 @@ +/** + * 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.workingcapitalloan.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class WorkingCapitalLoanTransactionReprocessingServiceImpl implements WorkingCapitalLoanTransactionReprocessingService { + + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanChargeRepository chargeRepository; + + @Override + public void reprocessTransactions(final WorkingCapitalLoan loan) { + final List allTransactions = transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loan.getId()); + reprocessTransactions(loan, allTransactions); + } + + @Override + public void reprocessTransactions(final WorkingCapitalLoan loan, final List allTransactions) { + // Allocation order only matters when payments compete for charge buckets. Without charges, + // every repayment-like transaction allocates to principal only — min(amount, outstanding) — + // which is order-independent, so a changed chronological order cannot change any allocation. + if (!chargeRepository.existsByLoanIdAndActiveTrue(loan.getId())) { + log.debug("Skipping transaction reprocessing for WC loan {}: no active charges, allocations are order-independent", + loan.getId()); + return; + } + + // Charge-aware re-allocation (recalculate the affected transactions from the change date + // forward until the charge is covered, then apply only the delta to balances/schedules) + // becomes implementable once the payment allocation strategy covers fees and penalties. + // Until then repayments never allocate to charges, so there is still nothing to redistribute. + log.warn("WC loan {} has active charges; charge-aware transaction reprocessing is not implemented yet " + + "({} transactions left untouched)", loan.getId(), allTransactions.size()); + } +} 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 3e639a2c0d8..a31586daf2e 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 @@ -105,6 +105,7 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanTransactionRelationRepository relationRepository; private final WorkingCapitalLoanPeriodPaymentRateChangeRepository rateChangeRepository; private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; + private final WorkingCapitalLoanTransactionReprocessingService transactionReprocessingService; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -671,10 +672,22 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, .forPrincipalAllocation(transaction, amountAppliedToOutstanding); this.allocationRepository.saveAndFlush(allocation); + // The incremental flow handles backdated dates correctly: the amortization model records the + // payment on its actual day and recalculates forward, balance math is order-independent, and + // delinquency/breach schedules allocate by transaction date. amortizationScheduleWriteService.applyRepayment(loan, transactionDate, amountAppliedToOutstanding); updateBalanceOnRepayment(loan, transactionAmount); internalWorkingCapitalLoanPaymentService.makePayment(loanId, amountAppliedToOutstanding, transactionDate); + // A backdated transaction can change how SUBSEQUENT transactions allocate to charges. The + // reprocessing engine recalculates only those allocations and no-ops when the loan has no + // charges (principal-only allocation is order-independent). + final List allTransactions = this.transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loanId); + if (isBackdatedTransaction(allTransactions, transaction)) { + transactionReprocessingService.reprocessTransactions(loan, allTransactions); + } + handleStateChanges(loan, transactionDate); triggerInlineAmortizationIfLoanClosed(loan, transactionDate); changes.put("status", loan.getLoanStatus()); @@ -959,6 +972,15 @@ private void updateBalanceOnCreditBalanceRefund(final WorkingCapitalLoan loan, f this.balanceRepository.saveAndFlush(balance); } + private boolean isBackdatedTransaction(final List allTransactions, + final WorkingCapitalLoanTransaction newTxn) { + // The same-date ID comparison is defensive only: the just-persisted transaction holds the highest + // ID, so in practice only a strictly later transaction date marks the new one as backdated. + return allTransactions.stream().filter(txn -> !txn.isReversed() && !txn.getId().equals(newTxn.getId())) + .anyMatch(txn -> txn.getTransactionDate().isAfter(newTxn.getTransactionDate()) + || (txn.getTransactionDate().equals(newTxn.getTransactionDate()) && txn.getId().compareTo(newTxn.getId()) > 0)); + } + private void reverseTransaction(final WorkingCapitalLoanTransaction txn) { txn.setReversed(true); txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java new file mode 100644 index 00000000000..2ea9ea87c45 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java @@ -0,0 +1,258 @@ +/** + * 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.integrationtests.client.feign.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for WC Transaction Reprocessing (generic). + * + * Backdated repayments are applied through the regular incremental flow (balance math is order-independent and the + * amortization model records payments on their actual day). The reprocessing engine only recalculates allocations of + * subsequent transactions when payments compete for charge buckets — without charges it is a no-op, which these tests + * verify by asserting that existing allocations stay untouched after a backdated repayment. + * + * TODO: add a charge-based re-allocation test once the payment allocation strategy covers fees and penalties + */ +public class FeignWorkingCapitalLoanTransactionReprocessingTest extends FeignIntegrationTest { + + private FeignWorkingCapitalLoanHelper wcLoanHelper; + private FeignClientHelper clientHelper; + private FeignBusinessDateHelper businessDateHelper; + private WorkingCapitalLoanProductHelper productHelper; + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + + @BeforeAll + void setupHelpers() { + wcLoanHelper = new FeignWorkingCapitalLoanHelper(fineractClient()); + clientHelper = new FeignClientHelper(fineractClient()); + businessDateHelper = new FeignBusinessDateHelper(fineractClient()); + productHelper = new WorkingCapitalLoanProductHelper(); + } + + @AfterAll + void cleanupEntities() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + createdProductIds.clear(); + } + + @Test + void testBackdatedRepayment_balanceReflectsBothPayments() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // First repayment on day 10 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterFirstRepayment = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterFirstRepayment.getBalance(), "Balance should exist after repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterFirstRepayment.getBalance().getPrincipalOutstanding(), + "Outstanding should be 6000 after 3000 repayment on 9000 loan"); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterFirstRepayment.getBalance().getPrincipalPaid(), + "Principal paid should be 3000 after first repayment"); + + // Backdated repayment on day 5 (before existing repayment on day 10) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "05 January 2026")); + + // Both repayments should be reflected — balance math is order-independent + GetWorkingCapitalLoansLoanIdResponse afterBackdated = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterBackdated.getBalance(), "Balance should exist after backdated repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(4000), afterBackdated.getBalance().getPrincipalOutstanding(), + "Outstanding should be 4000 after total 5000 repaid on 9000 loan"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), afterBackdated.getBalance().getPrincipalPaid(), + "Principal paid should be 5000 (2000 + 3000)"); + + // Both repayments fit into principal: the backdated one allocates fully, the earlier one stays untouched + List transactions = wcLoanHelper.getTransactions(loanId); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 5), BigDecimal.valueOf(2000)), BigDecimal.valueOf(2000)); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 10), BigDecimal.valueOf(3000)), BigDecimal.valueOf(3000)); + }); + } + + @Test + void testBackdatedRepayment_excessBecomesOverpayment() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // Partial repayment on day 10 (loan stays ACTIVE with 2000 outstanding) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(7000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterRepayment = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterRepayment.getBalance(), "Balance should exist after repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(2000), afterRepayment.getBalance().getPrincipalOutstanding(), + "Outstanding should be 2000 after 7000 repayment on 9000 loan"); + + // Backdated repayment on day 5 — total repaid (5000 + 7000 = 12000) exceeds principal (9000) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(5000), "05 January 2026")); + + // Totals are order-independent: 9000 principal repaid, 3000 overpayment + GetWorkingCapitalLoansLoanIdResponse afterBackdated = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterBackdated.getBalance(), "Balance should exist after backdated repayment"); + assertEqualBigDecimal(BigDecimal.ZERO, afterBackdated.getBalance().getPrincipalOutstanding(), + "Outstanding should be 0 — principal is fully repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(9000), afterBackdated.getBalance().getPrincipalPaid(), + "Principal paid should be 9000 — capped at total principal"); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterBackdated.getBalance().getOverpaymentAmount(), + "Overpayment should be 3000 (5000 + 7000 - 9000 principal)"); + + // Without charges, allocations are not redistributed: the day-10 repayment keeps its original + // 7000 principal allocation, and the backdated day-5 repayment allocates against the 2000 that + // was outstanding when it was booked (its excess 3000 is overpayment, not part of the allocation). + List transactions = wcLoanHelper.getTransactions(loanId); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 5), BigDecimal.valueOf(5000)), BigDecimal.valueOf(2000)); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 10), BigDecimal.valueOf(7000)), BigDecimal.valueOf(7000)); + }); + } + + @Test + void testMultipleBackdatedRepaymentsAccumulateCorrectly() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // First repayment on day 15 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "15 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterFirst = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterFirst.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterFirst.getBalance().getPrincipalOutstanding(), + "Outstanding should be 6000 after first repayment"); + + // Backdated repayment on day 5 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-20"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), "05 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterSecond = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterSecond.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), afterSecond.getBalance().getPrincipalOutstanding(), + "Outstanding should be 5000 after 4000 total repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(4000), afterSecond.getBalance().getPrincipalPaid(), + "Principal paid should be 4000 (1000 + 3000)"); + + // Another backdated repayment on day 10 (between existing ones) + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterThird = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterThird.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterThird.getBalance().getPrincipalOutstanding(), + "Outstanding should be 3000 after 6000 total repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterThird.getBalance().getPrincipalPaid(), + "Principal paid should be 6000 (1000 + 2000 + 3000)"); + }); + } + + @Test + void testNonBackdatedRepaymentDoesNotTriggerReprocessing() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // Sequential repayments (not backdated — each on or after the business date) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-05"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "05 January 2026")); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "10 January 2026")); + + // Verify balance is the simple sum — no reprocessing side effects + GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(loan.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(4000), loan.getBalance().getPrincipalOutstanding(), + "Outstanding should be 4000 after sequential 2000 + 3000 repayments"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), loan.getBalance().getPrincipalPaid(), + "Principal paid should be 5000 after sequential repayments"); + assertEqualBigDecimal(BigDecimal.ZERO, loan.getBalance().getOverpaymentAmount(), + "No overpayment expected for sequential repayments under principal"); + }); + } + + private Long createAndDisburseLoanOnDate(Long clientIdParam, BigDecimal principal, String date) { + Long productId = createProduct(); + Long loanId = submitAndTrack(clientIdParam, productId, principal, date); + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approve(date, principal, date)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburse(date, principal)); + return loanId; + } + + private Long submitAndTrack(Long clientIdParam, Long productId, BigDecimal principal, String date) { + Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplication(clientIdParam, productId, + principal, BigDecimal.valueOf(18), date, date)); + createdLoanIds.add(loanId); + return loanId; + } + + private Long createProduct() { + String uniqueName = "WCL Reprocess " + Utils.uniqueRandomStringGenerator("", 8); + String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private static GetWorkingCapitalLoanTransactionIdResponse findTransaction(List transactions, + LocalDate transactionDate, BigDecimal amount) { + return transactions.stream().filter(txn -> transactionDate.equals(txn.getTransactionDate())) + .filter(txn -> txn.getTransactionAmount() != null && amount.compareTo(txn.getTransactionAmount()) == 0).findFirst() + .orElseThrow(() -> new AssertionError("Transaction not found on " + transactionDate + " with amount " + amount)); + } + + private static void assertAllocation(GetWorkingCapitalLoanTransactionIdResponse transaction, BigDecimal expectedPrincipalPortion) { + String context = "Transaction on " + transaction.getTransactionDate() + " amount " + transaction.getTransactionAmount(); + assertEqualBigDecimal(expectedPrincipalPortion, transaction.getPrincipalPortion(), context + " — principal portion"); + assertEqualBigDecimal(BigDecimal.ZERO, transaction.getFeeChargesPortion(), context + " — fee charges portion"); + assertEqualBigDecimal(BigDecimal.ZERO, transaction.getPenaltyChargesPortion(), context + " — penalty charges portion"); + } + + private static void assertEqualBigDecimal(BigDecimal expected, BigDecimal actual, String message) { + assertNotNull(actual, message + " — value was null"); + assertEquals(0, expected.compareTo(actual), message + " — expected: " + expected + " but was: " + actual); + } +}