From a535ee682aa7592b9a6a197281070eba420fee0a Mon Sep 17 00:00:00 2001 From: "mark.vituska" Date: Thu, 17 Sep 2026 16:27:58 +0200 Subject: [PATCH 1/2] FINERACT-2455: Fix COB amortizing discount fee before payment, fix WC loan closedOnDate wrong when backdated transaction closes --- ...pitalAmortizationScheduleMatrixPt1.feature | 6 +- ...pitalAmortizationScheduleMatrixPt2.feature | 4 +- ...italDiscountAmortizationAdjustment.feature | 19 + ...lDiscountAmortizationRecalculation.feature | 24 + ...ingCapitalLoanRepaymentOverpayment.feature | 2 +- .../ProjectedAmortizationScheduleModel.java | 82 ++- ...rkingCapitalLoanTransactionRepository.java | 52 ++ ...oanDiscountFeeAmortizationServiceImpl.java | 14 +- ...orkingCapitalLoanTransactionProcessor.java | 116 ++- ...ngCapitalLoanWritePlatformServiceImpl.java | 11 +- ...ProjectedAmortizationScheduleAsOfTest.java | 161 +++++ ...CapitalLoanCobCatchUpAmortizationTest.java | 662 ++++++++++++++++++ ...apitalLoanDiscountFeeAmortizationTest.java | 31 +- 13 files changed, 1150 insertions(+), 34 deletions(-) create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanCobCatchUpAmortizationTest.java diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature index bbfae4897ea..01452fe5939 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature @@ -74,7 +74,7 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt1 When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount # --- Repayment --- - When Admin sets the business date to "02 January 2026" + When Admin sets the business date to "03 January 2026" And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan And Admin runs inline COB job for Working Capital Loan by loanId # --- Journal entries verification --- @@ -82,7 +82,7 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt1 | Type | Account code | Account name | Debit | Credit | | ASSET | 112601 | Loans Receivable | 1000.0 | | | LIABILITY | 240005 | Deferred Interest Revenue | | 1000.0 | - And Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "01 January 2026" which has the following Journal entries: + And Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "02 January 2026" which has the following Journal entries: | Type | Account code | Account name | Debit | Credit | | LIABILITY | 240005 | Deferred Interest Revenue | 9.61 | | | INCOME | 404000 | Interest Income | | 9.61 | @@ -426,4 +426,4 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt1 | 1 | 2026-01-02 | 100.00 | 0.00 | 0.00 | 50.00 | 0.00 | 0.00 | 50.00 | 0.00 | | 2 | 2026-01-03 | 50.00 | 0.00 | 0.00 | | | 0.00 | | | And The retrieved amortization schedule has no negative monetary amounts - And The retrieved amortization schedule has exactly 3 payment rows \ No newline at end of file + And The retrieved amortization schedule has exactly 3 payment rows diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature index 186dff72e44..9831f4e8e2a 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature @@ -507,7 +507,7 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt2 When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount # --- Repayment --- - When Admin sets the business date to "02 January 2026" + When Admin sets the business date to "03 January 2026" And Customer makes repayment on "02 January 2026" with 1234 transaction amount on Working Capital loan And Admin runs inline COB job for Working Capital Loan by loanId # --- Amortization schedule verification --- @@ -566,6 +566,8 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt2 And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan # --- Rate change --- And Admin update Working Capital period payment rate with "9" value + # --- COB --- + When Admin sets the business date to "03 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId # --- Amortization schedule verification --- And Admin retrieves the projected amortization schedule diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature index 165c99fabb8..d9300dbb8c9 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature @@ -1146,6 +1146,11 @@ Feature: Working Capital Discount Fee Amortization Adjustment And Working capital loan account has the correct data: | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | | 10000.0 | 9900.0 | 0.0 | 1000.0 | 0.0 | + # 100 of the 10000 is still owed, so nothing has settled: the loan carries no settlement dates at all + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | null | + | timeline.actualMaturityDate | null | And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | @@ -1166,6 +1171,14 @@ Feature: Working Capital Discount Fee Amortization Adjustment And Working capital loan account has the correct data: | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | | 9500.0 | 9500.0 | 500.0 | 0.0 | 400.0 | + # The adjustment - not the 02 January repayment - is what met the obligations: until it posted the loan still owed + # 100, and a discount fee adjustment is effective from its own date. So 03 January is the day the loan matured, and + # the day its overpayment began. The repayment on 02 January is reallocated, but that restates how the money is + # apportioned, not when the obligation existed. + And Working capital loan details has the following field values: + | overpaidOnDate | 2026-01-03 | + | timeline.closedOnDate | null | + | timeline.actualMaturityDate | 2026-01-03 | And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | @@ -1202,6 +1215,12 @@ Feature: Working Capital Discount Fee Amortization Adjustment | 03 January 2026 | Discount Fee Adjustment | 500.0 | 500.0 | 0.0 | 0.0 | false | | 03 January 2026 | Discount Fee Amortization Adjustment | 499.84 | | | | false | | 04 January 2026 | Credit Balance Refund | 400.0 | 0.0 | 0.0 | 0.0 | false | + # A refund is a real money movement and the account was not closed until it happened, so it closes the loan on its + # own date - as core's state machine does, stamping both dates from the refund. + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | 2026-01-04 | + | timeline.actualMaturityDate | 2026-01-04 | @TestRailId:C94062 Scenario: Working Capital loan raises Discount Fee Amortization Adjustment transaction business event when the amortization runs backwards diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature index 0b563aae316..cca79dd0419 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature @@ -338,6 +338,30 @@ Feature: Working Capital Discount Fee Amortization Recalculation And Working capital loan account has the correct data: | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | | 9500.0 | 50.0 | 5.14 | 494.86 | 0.0 | + # The same rule for a backdated repayment, not only for a discount fee adjustment. COB last closed 04 January; the + # business date jumps to 08 January and a repayment is booked for 06 January, so the catch-up replays 05, 06 and 07 + # January in turn. The amortization the repayment earns belongs on 06 January - the day the money came in - and must + # not be booked on 05 January, the earliest day the catch-up happens to replay. + When Admin sets the business date to "08 January 2026" + And Customer makes repayment on "06 January 2026" with 50 transaction amount on Working Capital loan + When Admin runs inline COB job for Working Capital Loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 04 January 2026 | Discount Fee Adjustment | 500.0 | 500.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Discount Fee Amortization Adjustment | 4.47 | | | | false | + | 06 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 06 January 2026 | Discount Fee Amortization | 5.12 | | | | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "06 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 5.12 | | + | INCOME | 404000 | Interest Income | | 5.12 | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 9500.0 | 100.0 | 10.26 | 489.74 | 0.0 | @TestRailId:C85367 Scenario: Verify discount fee amortization transaction on working capital loan after repayment overpays loan - UC7 diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentOverpayment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentOverpayment.feature index 825fcaebe2e..2cd22654f00 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentOverpayment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentOverpayment.feature @@ -225,7 +225,7 @@ Feature: Working Capital Loan Repayment - Overpayment And Working capital loan details has the following field values: | overpaidOnDate | 2026-01-04 | | balance.overpaymentAmount | 200.0 | - # full CBR closes the loan, overpaidOnDate must be cleared + # full CBR closes the loan on its own date, overpaidOnDate must be cleared And Customer makes credit balance refund on "06 January 2026" with 200.0 transaction amount on Working Capital loan Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" And Working capital loan details has the following field values: diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java index e436de690e3..8d56a9b565d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java @@ -360,28 +360,82 @@ public BigDecimal totalActualAmortization() { .map(p -> p.actualAmortizationAmount().getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add); } - public BigDecimal totalActualAmortizationWithDiscount(final BigDecimal asOfDiscount) { - if (asOfDiscount == null || discountFeeAmount == null || asOfDiscount.compareTo(discountFeeAmount.getAmount()) == 0) { - return totalActualAmortization(); + /** + * The amortization earned by the payments dated on or before {@code asOfDate}, measured against + * {@code asOfDiscount}. + */ + public BigDecimal totalActualAmortizationAsOf(final BigDecimal asOfDiscount, final LocalDate asOfDate) { + Objects.requireNonNull(asOfDate, "asOfDate"); + Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); + // The ordinary case, and the cheap one: the discount that was in force on asOfDate is the one the live schedule + // was built with, so its rows already carry the right figures. A row's actual amortization is a function of the + // payments up to its own day, so the as-of answer is simply the rows up to that day - no rebuild needed. This + // is the path every COB day takes. + if (asOfDiscount == null || asOfDiscount.compareTo(discountFeeAmount.getAmount()) == 0) { + return totalActualAmortizationUpTo(asOfDate); + } + // The discounts differ, which means an adjustment landed after asOfDate. The model keeps only one discount, the + // latest, and restates the whole schedule whenever it changes - so every row now reports what it would have + // earned under the new discount, including the rows dated before the adjustment. Those rows are answering the + // wrong question, and filtering cannot fix it: it drops rows, it never restates the ones it keeps. The schedule + // has to be rebuilt at the discount that was actually in force on the day. + return asOfModel(asOfDiscount, asOfDate).totalActualAmortization(); + } + + /** + * Sum of {@code actualAmortizationAmount} across the applied payment periods dated on or before {@code asOfDate}. + */ + private BigDecimal totalActualAmortizationUpTo(final LocalDate asOfDate) { + materializeDerivedPayments(); + if (projectedPayments == null) { + return BigDecimal.ZERO; } - return withDiscount(asOfDiscount).totalActualAmortization(); + return projectedPayments.stream() + .filter(p -> p.paymentNo() > 0 && p.actualAmortizationAmount() != null && !p.date().isAfter(asOfDate)) + .map(p -> p.actualAmortizationAmount().getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add); } - private ProjectedAmortizationScheduleModel withDiscount(final BigDecimal asOfDiscount) { - final LocalDate asOfDate = calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate; + /** + * The model as it stood at the end of {@code asOfDate}: the discount in force then, and only the payments, rate + * changes and principal adjustments dated on or before it. {@code calculatedTillDate} is pinned to that day too, so + * the days after it are not billed as elapsed. + */ + private ProjectedAmortizationScheduleModel asOfModel(final BigDecimal asOfDiscount, final LocalDate asOfDate) { + final LocalDate reachedDate = calculatedTillDate != null && calculatedTillDate.isBefore(asOfDate) ? calculatedTillDate : asOfDate; + // Rebuilt through whichever generator the live schedule was built with: an annual EIR model carries no total + // payment volume or period payment rate to regenerate from. final ProjectedAmortizationScheduleModel asOfModel = annualEir != null ? generateFromAnnualEir(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(), annualEir, npvDayCount, - expectedDisbursementDate, mc, currency, asOfDate) + expectedDisbursementDate, mc, currency, reachedDate) : generate(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(), totalPaymentVolume.getAmount(), - periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, asOfDate); - asOfModel.copyPrincipalAdjustmentsFrom(this); - for (final ActualPayment payment : actualPayments) { - asOfModel.applyPayment(payment.date(), payment.amount().getAmount()); + periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, reachedDate); + // Rate changes first: a payment is walked at the rate in force on its day, so the rates have to be in place + // before any payment is applied. periodPaymentRate is the rate the schedule was generated at and nothing moves + // it - the changes live only in this list - so a copy that skipped them would amortize a re-rated loan at its + // original rate. + if (rateChanges != null) { + for (final RateChange rateChange : rateChanges) { + if (!rateChange.effectiveDate().isAfter(reachedDate)) { + asOfModel.rateChanges.add(rateChange); + } + } } - if (calculatedTillDate != null) { - asOfModel.updateCalculatedTillDate(calculatedTillDate); - asOfModel.rebuildPayments(); + if (principalAdjustments != null) { + for (final PrincipalAdjustment adjustment : principalAdjustments) { + if (!adjustment.date().isAfter(reachedDate)) { + asOfModel.principalAdjustments.add(adjustment); + } + } + } + // Added straight to the list, the way regenerate does: these dates were already resolved to their allocation + // day when they were first applied, and applyPayment would rebuild the whole schedule once per payment. + for (final ActualPayment payment : actualPayments) { + if (!payment.date().isAfter(reachedDate)) { + asOfModel.actualPayments.add(payment); + } } + asOfModel.updateCalculatedTillDate(reachedDate); + asOfModel.rebuildPayments(); return asOfModel; } 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 b88ba4ae791..54f541ff928 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 @@ -65,6 +65,24 @@ and t.transactionType in (:amortizationType, :adjustmentType) BigDecimal sumNetAmortization(@Param("wcLoanId") Long wcLoanId, @Param("amortizationType") LoanTransactionType amortizationType, @Param("adjustmentType") LoanTransactionType adjustmentType); + /** + * The same net, restricted to the transactions dated on or before {@code asOfDate}. A COB day compares what the + * schedule has earned by that day against what has been posted by that day; summing every amortization whatever its + * date would measure the two over different periods, so a replayed day would correct itself against income that + * belongs to a later one. + */ + @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) + and t.transactionDate <= :asOfDate + """) + BigDecimal sumNetAmortizationAsOf(@Param("wcLoanId") Long wcLoanId, @Param("amortizationType") LoanTransactionType amortizationType, + @Param("adjustmentType") LoanTransactionType adjustmentType, @Param("asOfDate") LocalDate asOfDate); + /** * Total amount of non-reversed discount fee adjustments dated strictly after {@code date} (not yet effective then). */ @@ -156,6 +174,40 @@ List findActiveByTypesOrderByDateDesc(@Param("wc List findFirstActiveTransactionDateAndAmountByLoanIdWithOverpaidPortion( @Param("wcLoanId") Long wcLoanId, @Param("transactionTypes") List transactionTypes, Pageable pageable); + /** + * The date of the latest non-reversed transaction of the given types that was actually needed to meet the loan's + * obligations - that is, whose allocation is not wholly overpayment - or {@code null} when there is none. + *

+ * Money is allocated in date order, so every transaction up to and including that one paid down something that was + * due, and the ones after it are pure excess. That makes its date the day the obligations were met. A plain + * {@code MAX(transactionDate)} would answer the same on a loan that is settled exactly, where nothing is excess, + * but would name a surplus payment on a loan that is or once was overpaid - a payment the loan never needed, and + * which therefore says nothing about when it was settled. + *

+ * The left join keeps transactions that carry no allocation at all rather than silently dropping them. + */ + @Query(""" + SELECT MAX(t.transactionDate) FROM WorkingCapitalLoanTransaction t + LEFT JOIN t.allocation a + WHERE t.wcLoan.id = :wcLoanId + AND t.reversed = FALSE + AND t.transactionType in :transactionTypes + AND (a IS NULL OR COALESCE(a.overpaymentPortion, 0) < t.transactionAmount) + """) + LocalDate findLatestActiveTransactionDateWithDuePortion(@Param("wcLoanId") Long wcLoanId, + @Param("transactionTypes") List transactionTypes); + + /** Whether the loan carries a non-reversed transaction of the given type dated exactly on {@code date}. */ + @Query(""" + SELECT CASE WHEN COUNT(t) > 0 THEN TRUE ELSE FALSE END FROM WorkingCapitalLoanTransaction t + WHERE t.wcLoan.id = :wcLoanId + AND t.reversed = FALSE + AND t.transactionType = :transactionType + AND t.transactionDate = :date + """) + boolean existsActiveTransactionOn(@Param("wcLoanId") Long wcLoanId, @Param("transactionType") LoanTransactionType transactionType, + @Param("date") LocalDate date); + /** * Non-reversed transactions of the loan whose type is none of {@code excludedTypes}, latest first in the * (transaction date, id) order the replay uses. Used to find the last user transaction without loading the loan's 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 3dd52e8c17f..a78e5e590c6 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 @@ -76,7 +76,12 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final final boolean fullyPaid = loan.isOverpaid() || loan.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()); + // Bounded by the same date as the target above: a replayed day corrects itself against the income posted by + // that day, or it would reverse income dated after it and date the reversal before what it reverses. + // The closing top-up is not bounded: its target is the whole discount, not the schedule as of a day, so it is + // measured against everything posted. A backdated settlement can precede income COB already posted against a + // payment that has since become surplus, and leaving that income out would recognize it a second time. + final BigDecimal alreadyPosted = fullyPaid ? queryNetAmortized(loan.getId()) : queryNetAmortizedAsOf(loan.getId(), transactionDate); if (MathUtil.isZero(scheduleAmortization) && !fullyPaid && MathUtil.isZero(alreadyPosted)) { log.debug("Skipping discount fee amortization for WC loan [{}] - no amortization on schedule", loan.getId()); @@ -353,10 +358,15 @@ private BigDecimal queryNetAmortized(final Long loanId) { LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT); } + private BigDecimal queryNetAmortizedAsOf(final Long loanId, final LocalDate asOfDate) { + return transactionRepository.sumNetAmortizationAsOf(loanId, LoanTransactionType.DISCOUNT_FEE_AMORTIZATION, + LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT, asOfDate); + } + private BigDecimal calculateScheduleAmortization(final WorkingCapitalLoan loan, final LocalDate cobDate) { final MathContext mc = MoneyHelper.getMathContext(); return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan)) - .map(model -> model.totalActualAmortizationWithDiscount(discountInForceOn(loan, model, cobDate))).orElse(BigDecimal.ZERO); + .map(model -> model.totalActualAmortizationAsOf(discountInForceOn(loan, model, cobDate), cobDate)).orElse(BigDecimal.ZERO); } /** diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java index 1341e4596d9..94335b3a87d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java @@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; import org.apache.fineract.portfolio.workingcapitalloan.data.TransactionDateAndAmountHolder; @@ -142,10 +143,11 @@ public void processRepaymentLikeTransaction(final WorkingCapitalLoan loan, final stateMachine.determineAndTransition(loan, transactionDate); recalculateOverpaidOnDate(loan, transaction); + recalculateSettlementDates(loan); triggerInlineAmortizationIfLoanClosed(loan, transactionDate); // On early closure the loan leaves the COB scope, so any charge whose due-date accrual has not been posted yet - // is accrued as of the closing date to make sure the income is recognized before the loan is closed. - chargeAccrualService.accrueOnClosure(loan, transactionDate); + // is accrued as of the day the loan settled - not the day the closing transaction happens to carry. + chargeAccrualService.accrueOnClosure(loan, closureIncomeDate(loan, transactionDate)); } /** @@ -212,8 +214,116 @@ public void triggerInlineAmortizationIfLoanClosed(final WorkingCapitalLoan loan, && MathUtil.isGreaterThanZero(loan.getBalance().getRealizedIncomeFromDiscountFee()); if (MathUtil.isGreaterThanZero(discount) || adjustmentNeeded) { - discountFeeAmortizationService.processDiscountFeeAmortization(loan, transactionDate); + discountFeeAmortizationService.processDiscountFeeAmortization(loan, settlementDate(loan, transactionDate)); } } } + + /** + * Re-derives the two dates published as {@code timeline.closedOnDate} and {@code timeline.actualMaturityDate} from + * the loan's history, correcting the optimistic values the lifecycle state machine stamps when a loan settles. + *

+ * The machine stamps whichever transaction triggered the transition, which is the day the loan settled only when + * that transaction is also the one that finished the settlement. A backdated repayment completing an already + * part-paid loan is not, and neither is a surplus payment, a refund or an undo on an already-settled loan. It is + * the same defect {@link #recalculateOverpaidOnDate} fixes on {@code overpaidOnDate}, reaching the API the same way + * through {@code WorkingCapitalLoanSummaryMapper.buildTimeline}. + *

+ * Must be called wherever a transaction can settle the loan, alongside + * {@link #recalculateOverpaidOnDate(WorkingCapitalLoan, WorkingCapitalLoanTransaction)}: the repayment path here, a + * discount fee adjustment and a transaction undo. {@link #settlementDate} reads what this writes, so a site that + * settles a loan without correcting these dates would date the closing amortization and the closure accruals from + * the raw stamp. + *

+ * A credit balance refund is deliberately not one of those sites. It is a real money movement rather than a + * reallocation of one, and the account is not closed until it happens, so it closes the loan on its own date the + * way core's {@code DefaultLoanLifecycleStateMachine} does. + */ + public void recalculateSettlementDates(final WorkingCapitalLoan loan) { + // Mirrors determineAndTransition's own tolerance of a not-yet-established status: a freshly built loan with no + // balance is left unset. + final LoanStatus status = loan.getLoanStatus(); + if (status == null || (!status.isOverpaid() && !status.isClosedObligationsMet())) { + return; + } + final LocalDate settledOn = settlementDateFromHistory(loan); + if (settledOn == null) { + return; + } + // An overpaid loan has met its obligations too, so it carries a maturity date even though it is not closed. + loan.setMaturedOnDate(settledOn); + if (status.isClosedObligationsMet()) { + loan.setClosedOnDate(settledOn); + } + } + + /** + * The day the loan's obligations were met, read off its history rather than off the event in hand: the last + * repayment the loan actually needed. Money is allocated in date order, so everything after that payment - a + * surplus payment, and the refund or undo that takes the surplus away again - is excess, and excess says nothing + * about when the loan was settled. Being a function of history alone it lands on the same day whichever event + * recalculates it, including when that moves the answer earlier. + *

+ * A discount fee adjustment can settle a loan too, by reducing what is owed to what has already been paid. It is + * not a repayment-like transaction, so the query cannot see it, and the stamp is the only record of that day - kept + * for exactly as long as an adjustment on that day backs it up, so a payment-derived stamp cannot outlive the + * payment it came from. + */ + private LocalDate settlementDateFromHistory(final WorkingCapitalLoan loan) { + final LocalDate obligationsMetOn = transactionRepository.findLatestActiveTransactionDateWithDuePortion(loan.getId(), + LoanTransactionType.getRepaymentLikeTransactionTypes()); + final LocalDate stamped = loan.getMaturedOnDate(); + if (DateUtils.isAfter(stamped, obligationsMetOn) + && transactionRepository.existsActiveTransactionOn(loan.getId(), LoanTransactionType.DISCOUNT_FEE_ADJUSTMENT, stamped)) { + return stamped; + } + return obligationsMetOn; + } + + /** + * The day the loan actually became settled, which is the day the whole discount is earned - not the date of + * whichever transaction happened to trigger the recalculation. + *

+ * The two are the same only when the triggering transaction is also the chronologically last one. A backdated + * repayment that completes an already part-paid loan is not: it carries an earlier date, while the money that + * finished the settlement arrived later. Dating the closing amortization on the trigger would recognize the income + * before that cash came in. + *

+ * Mirrors core's {@code getFinalAccrualTransactionDate}: it reads the loan's settlement state rather than the + * transaction in hand. It reads {@code maturedOnDate}, which + * {@link #recalculateSettlementDates(WorkingCapitalLoan)} re-derives immediately before this runs and maintains on + * an overpaid loan as well as a closed one, so the income, the closure accruals and the closure the API reports are + * all dated from the same answer rather than drifting apart. + *

+ * Never earlier than the triggering transaction. Settlement can be completed by something that is not a repayment + * at all - a write-off, a waiver, a charge adjustment - and for those the trigger's own date remains the best + * answer, so this only ever moves the recognition later, never earlier than it is booked today. + */ + private LocalDate settlementDate(final WorkingCapitalLoan loan, final LocalDate transactionDate) { + final LocalDate settledOn = settledOn(loan); + return settledOn == null || !settledOn.isAfter(transactionDate) ? transactionDate : settledOn; + } + + /** + * The day the loan settled, for the income that belongs to the closure itself rather than to whichever transaction + * revealed it. + *

+ * Unlike {@link #settlementDate} this is not floored at the transaction in hand, because the two answer different + * questions. The discount is measured against a schedule, and a schedule changes on the day it is changed, so an + * adjustment arriving after the settlement earns its correction on its own later day. A charge accrual is not: it + * recognizes income the closure leaves unrecognized, and the closure happened when the loan met its obligations. A + * surplus payment, a credit balance refund or an undo arriving afterwards hands back or removes money the loan + * never needed, so none of them closed it and none of them may date its income. + *

+ * Falls back to {@code transactionDate} for a loan with no settlement date of its own - a write-off, where the + * closing transaction is the settlement. + */ + public LocalDate closureIncomeDate(final WorkingCapitalLoan loan, final LocalDate transactionDate) { + final LocalDate settledOn = settledOn(loan); + return settledOn == null ? transactionDate : settledOn; + } + + private static LocalDate settledOn(final WorkingCapitalLoan loan) { + return loan.isOverpaid() || loan.isClosedObligationsMet() ? loan.getMaturedOnDate() : null; + } } 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 bcbad2fbeda..80e708a842a 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 @@ -696,9 +696,10 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina stateMachine.determineAndTransition(loan, transactionDate); transactionProcessor.recalculateOverpaidOnDate(loan, adjustmentTransaction); + transactionProcessor.recalculateSettlementDates(loan); transactionProcessor.triggerInlineAmortizationIfLoanClosed(loan, transactionDate); // A discount-fee adjustment can pay down principal and close the loan, so accrue any pending charge income. - chargeAccrualService.accrueOnClosure(loan, transactionDate); + chargeAccrualService.accrueOnClosure(loan, transactionProcessor.closureIncomeDate(loan, transactionDate)); changes.put("status", loan.getLoanStatus()); loanRepository.saveAndFlush(loan); @@ -960,12 +961,17 @@ public CommandProcessingResult creditBalanceRefund(final Long loanId, final Json : BigDecimal.ZERO; if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0 && overpaymentAmount.compareTo(BigDecimal.ZERO) == 0) { this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_CREDIT_BALANCE_REFUND_IN_FULL, loan, transactionDate); + // A refund is a real money movement, and until it happened the account was not closed - so the refund + // is what closes the loan and both dates carry its own date, with no correction from the payment + // history. This deliberately matches core: DefaultLoanLifecycleStateMachine stamps closedOnDate and + // actualMaturityDate with the refund's date on the same transition. The WC transition stamps only the + // closure, so the maturity is set here. loan.setMaturedOnDate(transactionDate); } } // Closing an overpaid loan via a full credit balance refund must still recognize any pending charge accrual. - chargeAccrualService.accrueOnClosure(loan, transactionDate); + chargeAccrualService.accrueOnClosure(loan, transactionProcessor.closureIncomeDate(loan, transactionDate)); changes.put("status", loan.getLoanStatus()); handleNote(loan, command, changes); @@ -1141,6 +1147,7 @@ public CommandProcessingResult undoTransaction(final WorkingCapitalLoan loan, fi stateMachine.determineAndTransition(loan, DateUtils.getBusinessLocalDate()); transactionProcessor.recalculateOverpaidOnDate(loan, transaction); + transactionProcessor.recalculateSettlementDates(loan); changes.put("status", loan.getLoanStatus()); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java new file mode 100644 index 00000000000..0d39b73173b --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java @@ -0,0 +1,161 @@ +/** + * 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.calc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.domain.ActionContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * The as-of reading of the schedule: what the amortization had earned by the end of a given day, measured against the + * discount that was in force on that day. It is what a replayed COB day compares itself against, so it has to answer + * for the day it is asked about and not for the schedule's latest state. + */ +class ProjectedAmortizationScheduleAsOfTest { + + private static final MathContext MC = MathContext.DECIMAL128; + private static final CurrencyData CURRENCY = new CurrencyData("USD", 2, null); + + private static final BigDecimal DISCOUNT_FEE = new BigDecimal("1000"); + private static final BigDecimal REDUCED_DISCOUNT_FEE = new BigDecimal("600"); + private static final BigDecimal NET_DISBURSEMENT = new BigDecimal("9000"); + private static final BigDecimal TPV = new BigDecimal("100000"); + private static final BigDecimal RATE = new BigDecimal("18"); + private static final BigDecimal NEW_RATE = new BigDecimal("12"); + private static final int DAY_COUNT = 360; + + private static final LocalDate DISBURSEMENT_DATE = LocalDate.of(2026, 1, 1); + private static final LocalDate FIRST_PAYMENT_DATE = LocalDate.of(2026, 1, 3); + private static final LocalDate AS_OF_DATE = LocalDate.of(2026, 1, 5); + private static final LocalDate LATER_PAYMENT_DATE = LocalDate.of(2026, 1, 8); + private static final BigDecimal PAYMENT_AMOUNT = new BigDecimal("4000"); + + @BeforeEach + void setBusinessDate() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "UTC", null)); + ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, DISBURSEMENT_DATE))); + } + + @AfterEach + void resetContext() { + ThreadLocalContextUtil.reset(); + } + + private ProjectedAmortizationScheduleModel model(final BigDecimal discountFee) { + return ProjectedAmortizationScheduleModel.generateEir(discountFee, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, DISBURSEMENT_DATE, MC, + CURRENCY, DISBURSEMENT_DATE); + } + + // --- fast path: the discount asked about is the one the live schedule carries --- + + @Test + void asOfTheDiscountInForce_countsOnlyThePaymentsUpToThatDay() { + final ProjectedAmortizationScheduleModel model = model(DISCOUNT_FEE); + model.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + model.applyPayment(LATER_PAYMENT_DATE, PAYMENT_AMOUNT); + + final ProjectedAmortizationScheduleModel withoutTheLaterPayment = model(DISCOUNT_FEE); + withoutTheLaterPayment.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + withoutTheLaterPayment.acknowledgeElapsedPeriods(AS_OF_DATE); + + assertEquals(0, + model.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE).compareTo(withoutTheLaterPayment.totalActualAmortization())); + assertTrue(model.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE).compareTo(model.totalActualAmortization()) < 0); + } + + @Test + void asOfADayAfterEveryPayment_isTheWholeSchedule() { + final ProjectedAmortizationScheduleModel model = model(DISCOUNT_FEE); + model.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + model.applyPayment(LATER_PAYMENT_DATE, PAYMENT_AMOUNT); + + assertEquals(0, model.totalActualAmortizationAsOf(DISCOUNT_FEE, LATER_PAYMENT_DATE).compareTo(model.totalActualAmortization())); + } + + // --- rebuild path: an adjustment landed after the day being asked about --- + + @Test + void asOfADiscountTheScheduleNoLongerCarries_rebuildsAtThatDiscount() { + // The live schedule has been restated down to 600 by an adjustment dated after AS_OF_DATE, so every one of its + // rows - including the ones before the adjustment - now reports what it would have earned under 600. + final ProjectedAmortizationScheduleModel restated = model(REDUCED_DISCOUNT_FEE); + restated.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + restated.applyPayment(LATER_PAYMENT_DATE, PAYMENT_AMOUNT); + + final ProjectedAmortizationScheduleModel asItStoodThen = model(DISCOUNT_FEE); + asItStoodThen.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + asItStoodThen.acknowledgeElapsedPeriods(AS_OF_DATE); + + assertEquals(0, restated.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE).compareTo(asItStoodThen.totalActualAmortization())); + // Filtering the restated rows would have answered the wrong question; the rebuild is what makes them differ. + assertNotEquals(0, restated.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE) + .compareTo(restated.totalActualAmortizationAsOf(REDUCED_DISCOUNT_FEE, AS_OF_DATE))); + } + + @Test + void rebuildingAsOf_keepsARateChangeThatWasAlreadyInForce() { + final ProjectedAmortizationScheduleModel restated = model(REDUCED_DISCOUNT_FEE); + restated.applyRateChange(NEW_RATE, FIRST_PAYMENT_DATE, FIRST_PAYMENT_DATE); + restated.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + restated.applyPayment(LATER_PAYMENT_DATE, PAYMENT_AMOUNT); + + final ProjectedAmortizationScheduleModel asItStoodThen = model(DISCOUNT_FEE); + asItStoodThen.applyRateChange(NEW_RATE, FIRST_PAYMENT_DATE, FIRST_PAYMENT_DATE); + asItStoodThen.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + asItStoodThen.acknowledgeElapsedPeriods(AS_OF_DATE); + + final ProjectedAmortizationScheduleModel neverRerated = model(DISCOUNT_FEE); + neverRerated.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + neverRerated.acknowledgeElapsedPeriods(AS_OF_DATE); + + assertEquals(0, restated.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE).compareTo(asItStoodThen.totalActualAmortization())); + // A rebuild that dropped the rate change would have amortized the re-rated loan at its original rate. + assertNotEquals(0, asItStoodThen.totalActualAmortization().compareTo(neverRerated.totalActualAmortization())); + } + + @Test + void rebuildingAsOf_leavesOutARateChangeThatWasNotInForceYet() { + final ProjectedAmortizationScheduleModel restated = model(REDUCED_DISCOUNT_FEE); + restated.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + restated.applyRateChange(NEW_RATE, LATER_PAYMENT_DATE, LATER_PAYMENT_DATE); + restated.applyPayment(LATER_PAYMENT_DATE, PAYMENT_AMOUNT); + + final ProjectedAmortizationScheduleModel neverRerated = model(REDUCED_DISCOUNT_FEE); + neverRerated.applyPayment(FIRST_PAYMENT_DATE, PAYMENT_AMOUNT); + neverRerated.applyPayment(LATER_PAYMENT_DATE, PAYMENT_AMOUNT); + + assertEquals(0, restated.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE) + .compareTo(neverRerated.totalActualAmortizationAsOf(DISCOUNT_FEE, AS_OF_DATE))); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanCobCatchUpAmortizationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanCobCatchUpAmortizationTest.java new file mode 100644 index 00000000000..f67906c5641 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanCobCatchUpAmortizationTest.java @@ -0,0 +1,662 @@ +/** + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.JournalEntryTransactionItem; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest.AccountingRuleEnum; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper; +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.FeignJournalEntryHelper; +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.accounting.Account; +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.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The discount fee amortization COB step must never date an amortization before the payment that earns it. + * + *

+ * The step posts one delta transaction dated on the COB date it is running for, but derives the target from the whole + * amortization model - every payment ever applied, regardless of date. So a payment dated after the COB date being + * processed is already in the target, and its share of the income is booked on the earlier day. Two ways to get there: + * + *

    + *
  • Case A - the COB date trails the business date by a day, so a repayment made today is always ahead of the + * COB date the nightly run processes.
  • + *
  • Case B - the COB is behind and catches up: the inline executor replays each missing day from + * {@code lastClosedBusinessDate + 1}, and the earliest replayed day sees the whole delta, including payments dated days + * later.
  • + *
+ * + *

+ * Both cases put the income journal entry before the cash was received. + */ +public class FeignWorkingCapitalLoanCobCatchUpAmortizationTest extends FeignIntegrationTest { + + private static final String DISCOUNT_FEE_AMORTIZATION_CODE = "loanTransactionType.discountFeeAmortization"; + private static final String DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT_CODE = "loanTransactionType.discountFeeAmortizationAdjustment"; + + private static final BigDecimal PRINCIPAL = BigDecimal.valueOf(9000); + private static final BigDecimal DISCOUNT = BigDecimal.valueOf(1000); + private static final BigDecimal REPAYMENT = BigDecimal.valueOf(50); + + /** Amortization earned by the first daily 50 payment on this 9000 / 100000 / 18 / 1000 loan. */ + private static final BigDecimal DAY_1_AMORTIZATION = new BigDecimal("9.61"); + /** Amortization earned by the second daily 50 payment. */ + private static final BigDecimal DAY_2_AMORTIZATION = new BigDecimal("9.57"); + + private FeignWorkingCapitalLoanHelper wcLoanHelper; + private FeignClientHelper clientHelper; + private FeignBusinessDateHelper businessDateHelper; + private FeignJournalEntryHelper journalHelper; + private WorkingCapitalLoanProductHelper productHelper; + + private Account fundSourceAccount; + private Account loanPortfolioAccount; + private Account transfersSuspenseAccount; + private Account incomeFromDiscountFeeAccount; + private Account feesReceivableAccount; + private Account penaltiesReceivableAccount; + private Account incomeFromFeeAccount; + private Account incomeFromPenaltyAccount; + private Account incomeFromRecoveryAccount; + private Account writeOffAccount; + private Account overpaymentAccount; + private Account deferredIncomeAccount; + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + + @BeforeAll + void setupHelpers() { + final var feignClient = fineractClient(); + wcLoanHelper = new FeignWorkingCapitalLoanHelper(feignClient); + clientHelper = new FeignClientHelper(feignClient); + businessDateHelper = new FeignBusinessDateHelper(feignClient); + journalHelper = new FeignJournalEntryHelper(feignClient); + productHelper = new WorkingCapitalLoanProductHelper(); + + final FeignAccountHelper accountHelper = new FeignAccountHelper(feignClient); + fundSourceAccount = accountHelper.createLiabilityAccount("wcCobFundSrc"); + loanPortfolioAccount = accountHelper.createAssetAccount("wcCobLoanPort"); + transfersSuspenseAccount = accountHelper.createAssetAccount("wcCobXferSusp"); + incomeFromDiscountFeeAccount = accountHelper.createIncomeAccount("wcCobIncDisc"); + feesReceivableAccount = accountHelper.createAssetAccount("wcCobFeesRcv"); + penaltiesReceivableAccount = accountHelper.createAssetAccount("wcCobPenRcv"); + incomeFromFeeAccount = accountHelper.createIncomeAccount("wcCobIncFee"); + incomeFromPenaltyAccount = accountHelper.createIncomeAccount("wcCobIncPen"); + incomeFromRecoveryAccount = accountHelper.createIncomeAccount("wcCobIncRec"); + writeOffAccount = accountHelper.createExpenseAccount("wcCobWrtOff"); + overpaymentAccount = accountHelper.createLiabilityAccount("wcCobOverpay"); + deferredIncomeAccount = accountHelper.createLiabilityAccount("wcCobDefInc"); + } + + @AfterAll + void cleanupEntities() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + createdProductIds.clear(); + } + + /** + * Repayments on 02 and 03 January; the COB run on business date 03 January processes COB date 02 January. Only the + * 02 January payment has been earned by then, so exactly 9.61 may be recognized on 02 January. The 03 January + * payment belongs to the next COB. + */ + @Test + @DisplayName("Case A: a COB run must not amortize a payment dated after its COB date") + void normalCob_doesNotAmortizePaymentDatedAfterCobDate() { + businessDateHelper.runAt("2026-01-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 January 2026"); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "02 January 2026")); + + // Business date 03 January -> COB date 02 January. The 03 January repayment exists but is not yet earned. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-03"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "03 January 2026")); + wcLoanHelper.executeInlineWCCOB(loanId); + + final List afterFirstCob = amortizations(loanId); + assertEquals(1, afterFirstCob.size(), + () -> "COB for 02 January must post exactly one amortization, got: " + describe(afterFirstCob)); + final GetWorkingCapitalLoanTransactionIdResponse firstAmortization = afterFirstCob.getFirst(); + assertEquals(LocalDate.of(2026, 1, 2), firstAmortization.getTransactionDate(), + "the amortization must be dated on the COB date being processed"); + assertAmount(DAY_1_AMORTIZATION, firstAmortization.getTransactionAmount(), + "COB for 02 January must recognize only the 02 January payment (9.61), not also the 03 January one (+9.57)"); + + // The next COB (business date 04 January -> COB date 03 January) picks up the 03 January payment. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-04"); + wcLoanHelper.executeInlineWCCOB(loanId); + + final List afterSecondCob = amortizations(loanId); + assertEquals(2, afterSecondCob.size(), + () -> "COB for 03 January must post the second day's amortization, got: " + describe(afterSecondCob)); + final GetWorkingCapitalLoanTransactionIdResponse secondAmortization = afterSecondCob.get(1); + assertEquals(LocalDate.of(2026, 1, 3), secondAmortization.getTransactionDate(), + "the second amortization must be dated on the second COB date"); + assertAmount(DAY_2_AMORTIZATION, secondAmortization.getTransactionAmount(), + "the second COB must recognize the 03 January payment's share (9.57)"); + }); + } + + /** + * One COB closes 01 January. The business date then jumps to 06 January, and a repayment is booked for 04 January. + * The next COB run replays 02, 03, 04, and 05 January in turn. The amortization must land on 04 January (the + * payment date) or later - never on 02 or 03 January, which precedes the cash. + */ + @Test + @DisplayName("Case B: catch-up COB must not date an amortization before the backdated payment it amortizes") + void catchUpCob_doesNotDateAmortizationBeforeTheBackdatedPayment() { + businessDateHelper.runAt("2026-01-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 January 2026"); + + // Close 01 January so the catch-up below starts replaying from 02 January. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-02"); + wcLoanHelper.executeInlineWCCOB(loanId); + assertTrue(amortizations(loanId).isEmpty(), "no payment has been made yet, so COB for 01 January must amortize nothing"); + + // COB is now four days behind. The repayment is dated 04 January, inside the gap. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-06"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "04 January 2026")); + + // Business date 06 January -> COB date 05 January: the run replays 02, 03, 04, 05 January. + wcLoanHelper.executeInlineWCCOB(loanId); + + final List amortizations = amortizations(loanId); + assertFalse(amortizations.isEmpty(), "the caught-up COB must recognize the 04 January payment"); + + final LocalDate paymentDate = LocalDate.of(2026, 1, 4); + for (final GetWorkingCapitalLoanTransactionIdResponse amortization : amortizations) { + assertNotNull(amortization.getTransactionDate(), "amortization must carry a transaction date"); + assertFalse(amortization.getTransactionDate().isBefore(paymentDate), + () -> "an amortization must never be dated before the payment it amortizes - payment on " + paymentDate + + ", amortization on " + amortization.getTransactionDate() + "; all amortizations: " + + describe(amortizations)); + } + + // The total recognized is still exactly the one day's worth the single payment earned. + assertAmount(DAY_1_AMORTIZATION, sum(amortizations), + "the caught-up COB must recognize exactly the 04 January payment's share of the discount"); + + // The income journal entry inherits the transaction date, so it must not predate the cash either. + for (final GetWorkingCapitalLoanTransactionIdResponse amortization : amortizations) { + for (final JournalEntryTransactionItem entry : journalEntriesFor(amortization.getId())) { + assertNotNull(entry.getTransactionDate(), "journal entry must carry a transaction date"); + assertFalse(entry.getTransactionDate().isBefore(paymentDate), + () -> "the discount fee income journal entry must not be dated before the cash was received - payment on " + + paymentDate + ", entry on " + entry.getTransactionDate()); + } + } + }); + } + + /** + * A loan is part paid, then a repayment lands on a later day, then a backdated repayment completes it. The + * backdated one triggers the closure while carrying the earliest date - but by value date the loan was only settled + * once the later payment was counted, so the closing top-up of the discount belongs on that later day. Dating it on + * the trigger would recognize the last of the income before the cash that completed the settlement arrived. + */ + @Test + @DisplayName("a backdated repayment that closes the loan dates the closing amortization on the real settlement day") + void backdatedClosingRepayment_datesClosingAmortizationOnTheSettlementDay() { + businessDateHelper.runAt("2026-03-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 March 2026"); + + // 02 March: 9900 of the 10000 owed (9000 principal + 1000 discount), leaving 100. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-03-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9900), "02 March 2026")); + + // COB recognizes most of the discount against that payment. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-03-04"); + wcLoanHelper.executeInlineWCCOB(loanId); + + // 10 March: 50 more, leaving 50. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-03-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "10 March 2026")); + + // 05 March, booked on 12 March: the last 50. Cumulative by value date reaches 10000 only on 10 March, so + // that - not 05 March - is the day the loan actually settled. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-03-12"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "05 March 2026")); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", loan.getStatus().getCode(), + "the backdated repayment must close the loan"); + assertAmount(DISCOUNT, loan.getBalance().getRealizedIncomeFromDiscountFee(), + "closing the loan must recognize the whole discount"); + + // The dates the API publishes must name the same day, not the transaction that happened to trigger the + // closure. A client reading the timeline would otherwise be told the loan closed five days before the money + // that closed it arrived. + assertNotNull(loan.getTimeline(), "the loan must carry a timeline"); + assertEquals(LocalDate.of(2026, 3, 10), loan.getTimeline().getClosedOnDate(), + "timeline.closedOnDate must be the day the loan actually settled (10 March), not the backdated repayment " + + "that triggered the closure (05 March)"); + assertEquals(LocalDate.of(2026, 3, 10), loan.getTimeline().getActualMaturityDate(), + "timeline.actualMaturityDate must agree with the day the loan actually settled"); + + final List amortizations = amortizations(loanId); + final GetWorkingCapitalLoanTransactionIdResponse closing = amortizations.getLast(); + assertEquals(LocalDate.of(2026, 3, 10), closing.getTransactionDate(), + () -> "the closing amortization belongs on the day the loan actually settled (10 March), not on the backdated " + + "repayment that triggered it (05 March); all amortizations: " + describe(amortizations)); + + // Nothing may be dated before the cash that earned it. + for (final GetWorkingCapitalLoanTransactionIdResponse amortization : amortizations) { + assertFalse(amortization.getTransactionDate().isAfter(LocalDate.of(2026, 3, 10)), + "no amortization may be dated after the settlement day"); + } + }); + } + + @Test + @DisplayName("undoing the overpaying repayment dates the closure on the settlement it falls back onto") + void undoOfOverpayingRepayment_datesClosureOnTheEarlierSettlementDay() { + businessDateHelper.runAt("2026-04-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 April 2026"); + + // 02 April: 9900 of the 10000 owed (9000 principal + 1000 discount), leaving 100. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9900), "02 April 2026")); + + // 05 April: the last 100. The loan settles exactly, on this day. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-05"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(100), "05 April 2026")); + + // 08 April: 50 more on a loan that owes nothing, which tips it into overpayment. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-08"); + final Long overpayingTransactionId = wcLoanHelper.makeRepayment(loanId, + WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "08 April 2026")); + + final GetWorkingCapitalLoansLoanIdResponse overpaid = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", overpaid.getStatus().getCode(), "the extra payment must overpay the loan"); + assertEquals(LocalDate.of(2026, 4, 5), overpaid.getTimeline().getActualMaturityDate(), + "an extra payment does not move the day the loan met its obligations"); + + // 12 April: the overpaying repayment is undone, which leaves the loan settled exactly again. The settlement + // it falls back onto happened on 05 April - the undo is not what closed it. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-12"); + wcLoanHelper.undoTransaction(loanId, overpayingTransactionId); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", loan.getStatus().getCode(), + "undoing the excess must leave the loan settled exactly"); + assertEquals(LocalDate.of(2026, 4, 5), loan.getTimeline().getClosedOnDate(), + "timeline.closedOnDate must be the day the loan met its obligations (05 April), not the day the excess was " + + "undone (12 April)"); + assertEquals(LocalDate.of(2026, 4, 5), loan.getTimeline().getActualMaturityDate(), + "timeline.actualMaturityDate must not drift away from timeline.closedOnDate"); + }); + } + + @Test + @DisplayName("a backdated repayment that overpays dates the maturity on the payment that finished the settlement") + void backdatedOverpayingRepayment_datesMaturityOnTheSettlingPayment() { + businessDateHelper.runAt("2026-05-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 May 2026"); + + // 02 May: 50 against the 10000 owed (9000 principal + 1000 discount). + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-05-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, "02 May 2026")); + + // 10 May: 9000 more, leaving 950 - the loan is still part-paid. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-05-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9000), "10 May 2026")); + + // 05 May, booked on 12 May: 1000, which overpays the loan by 50. Allocated in date order the running total + // reaches 10000 only at the 10 May payment, so that is the day the obligations were met - not 05 May, the + // backdated payment that triggered the transition. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-05-12"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), "05 May 2026")); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", loan.getStatus().getCode(), "the backdated repayment must overpay the loan"); + assertNotNull(loan.getTimeline(), "the loan must carry a timeline"); + assertEquals(LocalDate.of(2026, 5, 10), loan.getTimeline().getActualMaturityDate(), + "timeline.actualMaturityDate must be the day the obligations were met (10 May), not the backdated repayment " + + "that triggered the transition (05 May)"); + }); + } + + @Test + @DisplayName("a backdated repayment that settles the loan earlier moves the maturity back") + void backdatedRepaymentSettlingEarlier_movesTheMaturityBack() { + businessDateHelper.runAt("2026-06-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 June 2026"); + + // 02 June: 9000 of the 10000 owed (9000 principal + 1000 discount), leaving 1000. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-06-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9000), "02 June 2026")); + + // 10 June: 1500 more, which settles the last 1000 and overpays by 500. As it stands this is the day the + // obligations were met. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-06-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1500), "10 June 2026")); + + final GetWorkingCapitalLoansLoanIdResponse overpaid = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", overpaid.getStatus().getCode(), "the second repayment must overpay the loan"); + assertEquals(LocalDate.of(2026, 6, 10), overpaid.getTimeline().getActualMaturityDate(), + "the 10 June payment is what met the obligations, so far"); + + // 05 June, booked on 12 June: 1000. Allocated in date order the loan is now square at 05 June and the whole + // 10 June payment is surplus - a payment the loan never needed, which cannot be what settled it. The + // settlement day moves earlier, which is the direction a stored maturity is never able to follow on its + // own. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-06-12"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), "05 June 2026")); + + final GetWorkingCapitalLoansLoanIdResponse settledEarlier = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", settledEarlier.getStatus().getCode(), "the backdated repayment leaves it overpaid"); + assertEquals(LocalDate.of(2026, 6, 5), settledEarlier.getTimeline().getActualMaturityDate(), + "timeline.actualMaturityDate must be the day the loan became square (05 June), not the payment it no longer " + + "needed (10 June)"); + + // 15 June: the 1500 surplus is refunded, which closes the loan. A refund is a real money movement and the + // account was not closed until it happened, so it carries its own date - matching core, which stamps both + // dates from the refund on the same transition. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-06-15"); + wcLoanHelper.creditBalanceRefund(loanId, + WorkingCapitalLoanRequestBuilders.creditBalanceRefund(BigDecimal.valueOf(1500), "15 June 2026")); + + final GetWorkingCapitalLoansLoanIdResponse closed = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", closed.getStatus().getCode(), + "refunding the whole surplus must close the loan"); + assertEquals(LocalDate.of(2026, 6, 15), closed.getTimeline().getClosedOnDate(), + "timeline.closedOnDate must be the day the refund closed the account (15 June)"); + assertEquals(LocalDate.of(2026, 6, 15), closed.getTimeline().getActualMaturityDate(), + "timeline.actualMaturityDate must not drift away from timeline.closedOnDate"); + }); + } + + /** + * COB recognizes income against a payment that a later backdated repayment turns into surplus. The closing top-up + * is dated on the earlier settlement day, but it must still count the income COB already posted after that day - + * otherwise that income is recognized twice and the total exceeds the discount. + */ + @Test + @DisplayName("a backdated settlement before already-amortized income recognizes the discount exactly once") + void backdatedSettlementBeforePostedIncome_recognizesTheDiscountExactlyOnce() { + businessDateHelper.runAt("2026-07-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 July 2026"); + + // 02 July: 9000 of the 10000 owed (9000 principal + 1000 discount), leaving 1000. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-07-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9000), "02 July 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-07-03"); + wcLoanHelper.executeInlineWCCOB(loanId); + + // 10 July: 500 more, leaving 500. COB recognizes this payment's share on 10 July. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-07-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(500), "10 July 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-07-11"); + wcLoanHelper.executeInlineWCCOB(loanId); + + final List beforeSettlement = amortizations(loanId); + assertTrue(beforeSettlement.stream().anyMatch(txn -> LocalDate.of(2026, 7, 10).equals(txn.getTransactionDate())), + () -> "COB must have recognized the 10 July payment's share on 10 July; all amortizations: " + + describe(beforeSettlement)); + + // 05 July, booked on 12 July: 1000. Allocated in date order the loan is square on 05 July and the whole + // 10 July payment becomes surplus, so the loan is overpaid and settled on 05 July. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-07-12"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), "05 July 2026")); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", loan.getStatus().getCode(), "the backdated repayment must overpay the loan"); + assertEquals(LocalDate.of(2026, 7, 5), loan.getTimeline().getActualMaturityDate(), + "the loan settled on 05 July, the 10 July payment is surplus"); + assertAmount(DISCOUNT, loan.getBalance().getRealizedIncomeFromDiscountFee(), + "the realized income must be the whole discount, not the discount plus the income COB posted on 10 July"); + + final List amortizations = amortizations(loanId); + assertAmount(DISCOUNT, sum(amortizations), + "the live amortizations must add up to the discount exactly; all amortizations: " + describe(amortizations)); + }); + } + + /** + * A part-paid loan with income already recognized against its payments is overpaid by a backdated repayment that + * carries the excess itself. The discount is recognized in full once, and nothing that follows - a further surplus + * payment, the refund that closes the loan - recognizes any more of it. + */ + @Test + @DisplayName("a backdated overpaying repayment on a part-paid loan never recognizes more than the discount") + void backdatedOverpayingRepaymentOnPartPaidLoan_neverRecognizesMoreThanTheDiscount() { + businessDateHelper.runAt("2026-08-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 August 2026"); + + // 02 August: 9000 of the 10000 owed (9000 principal + 1000 discount), leaving 1000. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9000), "02 August 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-03"); + wcLoanHelper.executeInlineWCCOB(loanId); + + // 10 August: 500 more, leaving 500. COB recognizes this payment's share on 10 August. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(500), "10 August 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-11"); + wcLoanHelper.executeInlineWCCOB(loanId); + + final GetWorkingCapitalLoansLoanIdResponse partPaid = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.active", partPaid.getStatus().getCode(), "the loan must still be part-paid"); + assertTrue(DISCOUNT.compareTo(netAmortized(loanId)) > 0, "a part-paid loan must not have recognized the whole discount yet"); + + // 05 August, booked on 12 August: 1500. Allocated in date order it pays the last 1000 on 05 August and + // carries 500 of excess itself, and the 10 August payment becomes surplus too: overpaid by 1000. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-12"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1500), "05 August 2026")); + + final GetWorkingCapitalLoansLoanIdResponse overpaid = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.overpaid", overpaid.getStatus().getCode(), "the backdated repayment must overpay the loan"); + assertAmount(BigDecimal.valueOf(1000), overpaid.getBalance().getOverpaymentAmount(), "the loan must be overpaid by 1000"); + assertEquals(LocalDate.of(2026, 8, 5), overpaid.getTimeline().getActualMaturityDate(), + "the backdated repayment is what met the obligations"); + assertDiscountRecognizedExactlyOnce(loanId, overpaid, "after the backdated overpaying repayment"); + + // 13 August: a further surplus payment. The loan is already fully paid, so it earns nothing. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-13"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(200), "13 August 2026")); + assertDiscountRecognizedExactlyOnce(loanId, wcLoanHelper.getLoanDetails(loanId), "after a further surplus payment"); + + // 14 August: the whole 1200 surplus is refunded, which closes the loan. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-14"); + wcLoanHelper.creditBalanceRefund(loanId, + WorkingCapitalLoanRequestBuilders.creditBalanceRefund(BigDecimal.valueOf(1200), "14 August 2026")); + final GetWorkingCapitalLoansLoanIdResponse closed = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", closed.getStatus().getCode(), + "refunding the whole surplus must close the loan"); + assertDiscountRecognizedExactlyOnce(loanId, closed, "after the refund closed the loan"); + }); + } + + /** + * Undoing the settling repayment reopens the loan but leaves the closing top-up in place, so the discount is fully + * recognized on a loan that is no longer paid. Repaying it in full again must top up only what is missing, whether + * the loan is re-settled straight away on a day before the original settlement, or COB has first corrected the + * income back down to the schedule. + */ + @Test + @DisplayName("reopening a settled loan by undo and repaying it again recognizes the discount exactly once") + void undoReopenAndRepayAgain_recognizesTheDiscountExactlyOnce() { + businessDateHelper.runAt("2026-09-01", () -> { + final Long loanId = createDisbursedLoanWithDiscount("01 September 2026"); + + // 02 September: 9900 of the 10000 owed (9000 principal + 1000 discount), leaving 100. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-02"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9900), "02 September 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-03"); + wcLoanHelper.executeInlineWCCOB(loanId); + + // 05 September: the last 100 closes the loan. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-05"); + final Long firstSettlingRepaymentId = wcLoanHelper.makeRepayment(loanId, + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(100), "05 September 2026")); + final GetWorkingCapitalLoansLoanIdResponse firstClosure = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", firstClosure.getStatus().getCode(), "the last 100 must close the loan"); + assertDiscountRecognizedExactlyOnce(loanId, firstClosure, "after the first closure"); + + // 07 September: the settling repayment is undone, which reopens the loan. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-07"); + wcLoanHelper.undoTransaction(loanId, firstSettlingRepaymentId); + assertEquals("loanStatusType.active", wcLoanHelper.getLoanDetails(loanId).getStatus().getCode(), + "undoing the settling repayment must reopen the loan"); + + // Same day, no COB in between: the missing 100 is booked for 04 September, before the original settlement + // and so before the closing top-up it left behind. That top-up must still count against the new closure. + final Long secondSettlingRepaymentId = wcLoanHelper.makeRepayment(loanId, + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(100), "04 September 2026")); + final GetWorkingCapitalLoansLoanIdResponse secondClosure = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", secondClosure.getStatus().getCode(), + "repaying the missing 100 must close the loan again"); + assertEquals(LocalDate.of(2026, 9, 4), secondClosure.getTimeline().getClosedOnDate(), "the loan now settled on 04 September"); + assertDiscountRecognizedExactlyOnce(loanId, secondClosure, "after re-settling without a COB in between"); + + // 08 September: undone again. This time COB runs while the loan is open and corrects the income down to + // what the schedule has earned. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-08"); + wcLoanHelper.undoTransaction(loanId, secondSettlingRepaymentId); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-09"); + wcLoanHelper.executeInlineWCCOB(loanId); + final GetWorkingCapitalLoansLoanIdResponse reopened = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.active", reopened.getStatus().getCode(), "the second undo must reopen the loan"); + assertTrue(DISCOUNT.compareTo(netAmortized(loanId)) > 0, + "COB on the reopened loan must bring the recognized income back below the discount"); + + // 10 September: repaid in full once more. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(100), "10 September 2026")); + final GetWorkingCapitalLoansLoanIdResponse thirdClosure = wcLoanHelper.getLoanDetails(loanId); + assertEquals("loanStatusType.closed.obligations.met", thirdClosure.getStatus().getCode(), + "repaying the missing 100 must close the loan a third time"); + assertEquals(LocalDate.of(2026, 9, 10), thirdClosure.getTimeline().getClosedOnDate(), "the loan now settled on 10 September"); + assertDiscountRecognizedExactlyOnce(loanId, thirdClosure, "after re-settling with a COB in between"); + }); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Long createDisbursedLoanWithDiscount(final String date) { + final Long clientId = clientHelper.createClient(date); + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplication(clientId, productId, + PRINCIPAL, WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT, date, date).discount(DISCOUNT)); + createdLoanIds.add(loanId); + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approveWithDiscount(date, PRINCIPAL, date, DISCOUNT)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburseWithDiscount(date, PRINCIPAL, DISCOUNT)); + return loanId; + } + + private Long createAccrualWithDeferredRevenueAmortizationProduct() { + final String uniqueName = "WCL CobAmort " + UUID.randomUUID().toString().substring(0, 8); + // Short names are four characters, so the space is small enough to collide on a database that accumulates + // products across runs. Drawn from letters and digits rather than a UUID's hex, which is 26x the room. + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).withRepaymentEvery(1) + .withRepaymentFrequencyType("DAYS").withAllowAttributeOverrides(Map.of("discountDefault", Boolean.TRUE)) + .withAccountingRule(AccountingRuleEnum.ACC_DEF_REV_AM) + .withFundSourceAccountId(fundSourceAccount.getAccountID().longValue()) + .withLoanPortfolioAccountId(loanPortfolioAccount.getAccountID().longValue()) + .withTransfersInSuspenseAccountId(transfersSuspenseAccount.getAccountID().longValue()) + .withIncomeFromDiscountFeeAccountId(incomeFromDiscountFeeAccount.getAccountID().longValue()) + .withReceivableFeeAccountId(feesReceivableAccount.getAccountID().longValue()) + .withReceivablePenaltyAccountId(penaltiesReceivableAccount.getAccountID().longValue()) + .withIncomeFromFeeAccountId(incomeFromFeeAccount.getAccountID().longValue()) + .withIncomeFromPenaltyAccountId(incomeFromPenaltyAccount.getAccountID().longValue()) + .withIncomeFromRecoveryAccountId(incomeFromRecoveryAccount.getAccountID().longValue()) + .withWriteOffAccountId(writeOffAccount.getAccountID().longValue()) + .withOverpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue()) + .withDeferredIncomeLiabilityAccountId(deferredIncomeAccount.getAccountID().longValue()).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + /** Live discount fee amortizations, in posting order. */ + private List amortizations(final Long loanId) { + return wcLoanHelper + .getTransactions(loanId).stream().filter(txn -> txn.getType() != null + && DISCOUNT_FEE_AMORTIZATION_CODE.equals(txn.getType().getCode()) && !Boolean.TRUE.equals(txn.getReversed())) + .toList(); + } + + /** Live discount fee amortizations net of the live amortization adjustments. */ + private BigDecimal netAmortized(final Long loanId) { + return wcLoanHelper.getTransactions(loanId).stream() + .filter(txn -> txn.getType() != null && !Boolean.TRUE.equals(txn.getReversed()) && txn.getTransactionAmount() != null) + .map(txn -> switch (txn.getType().getCode()) { + case DISCOUNT_FEE_AMORTIZATION_CODE -> txn.getTransactionAmount(); + case DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT_CODE -> txn.getTransactionAmount().negate(); + default -> BigDecimal.ZERO; + }).reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private void assertDiscountRecognizedExactlyOnce(final Long loanId, final GetWorkingCapitalLoansLoanIdResponse loan, + final String when) { + assertAmount(DISCOUNT, loan.getBalance().getRealizedIncomeFromDiscountFee(), + "the realized income must be exactly the discount " + when); + assertAmount(DISCOUNT, netAmortized(loanId), "the net live amortization must be exactly the discount " + when); + } + + private List journalEntriesFor(final Long wcTransactionId) { + final GetJournalEntriesTransactionIdResponse response = journalHelper.getJournalEntriesByTransactionId("WC" + wcTransactionId); + return response == null || response.getPageItems() == null ? List.of() : response.getPageItems(); + } + + private static BigDecimal sum(final List transactions) { + return transactions.stream().map(GetWorkingCapitalLoanTransactionIdResponse::getTransactionAmount) + .filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static String describe(final List transactions) { + return transactions.stream().map(txn -> txn.getTransactionDate() + " " + txn.getTransactionAmount()).toList().toString(); + } + + private static void assertAmount(final BigDecimal expected, final BigDecimal actual, final String message) { + assertNotNull(actual, message + " - amount was null"); + assertEquals(0, expected.compareTo(actual), message + " - expected: " + expected + " but was: " + actual); + } +} 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 33cf79cec67..a3e54be1251 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 @@ -60,6 +60,13 @@ *

  • Idempotency: repeated COB without new payments creates no duplicate transactions
  • *
  • Incremental amortization after additional repayments
  • * + * + *

    + * Note on dates: the COB date trails the business date by one day, and the step only amortizes payments dated on or + * before the COB date it is running for. So a repayment is recognized by the COB run made on the following + * business date, never by one made on the same day - which is why the repayments and COB runs below are staggered. + * Recognition that ran ahead of the cash is the defect covered by + * {@link FeignWorkingCapitalLoanCobCatchUpAmortizationTest}. */ public class FeignWorkingCapitalLoanDiscountFeeAmortizationTest extends FeignIntegrationTest { @@ -140,7 +147,10 @@ void testDiscountFeeAmortizationCreatedAfterRepaymentAndCOB() { // Make a repayment wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "01 January 2026")); - // Run WC COB — this triggers the discount fee amortization business step + // Run WC COB — this triggers the discount fee amortization business step. The business date moves to the + // 2nd first, because the COB date trails it by a day: a COB run on the 1st would process 31 December and + // find nothing, the repayment not having happened yet on the day being closed. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-02"); wcLoanHelper.executeInlineWCCOB(loanId); // Verify amortization transaction was created @@ -239,8 +249,9 @@ void testCOBIdempotencyNoNewPayment() { WorkingCapitalLoanRequestBuilders.approveWithDiscount("01 April 2026", principal, "01 April 2026", discount)); wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburseWithDiscount("01 April 2026", principal, discount)); - // Make a repayment and run COB + // Make a repayment, then COB the day it fell on — which is the run made on the following business date. wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "01 April 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-02"); wcLoanHelper.executeInlineWCCOB(loanId); final List firstRunTxns = filterByType(wcLoanHelper.getTransactions(loanId), @@ -249,7 +260,7 @@ void testCOBIdempotencyNoNewPayment() { final BigDecimal firstAmount = firstRunTxns.get(0).getTransactionAmount(); // Advance business date and run COB again — no new payments - businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-02"); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-04-03"); wcLoanHelper.executeInlineWCCOB(loanId); final List secondRunTxns = filterByType(wcLoanHelper.getTransactions(loanId), @@ -277,8 +288,9 @@ void testIncrementalAmortizationAcrossMultipleRepayments() { WorkingCapitalLoanRequestBuilders.approveWithDiscount("01 May 2026", principal, "01 May 2026", discount)); wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburseWithDiscount("01 May 2026", principal, discount)); - // First repayment + COB → first partial amortization + // First repayment, then the COB that closes the day it fell on → first partial amortization wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(50), "01 May 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-05-02"); wcLoanHelper.executeInlineWCCOB(loanId); final List firstRun = filterByType(wcLoanHelper.getTransactions(loanId), @@ -288,9 +300,9 @@ void testIncrementalAmortizationAcrossMultipleRepayments() { assertTrue(firstAmount.compareTo(BigDecimal.ZERO) > 0, "First amortization should be positive"); assertTrue(firstAmount.compareTo(discount) < 0, "First amortization should be less than full discount — was: " + firstAmount); - // Second repayment + COB → second incremental amortization - businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-05-02"); + // Second repayment on the 2nd, then the COB that closes that day → second incremental amortization wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(50), "02 May 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-05-03"); wcLoanHelper.executeInlineWCCOB(loanId); final List secondRun = filterByType(wcLoanHelper.getTransactions(loanId), @@ -504,9 +516,10 @@ void testBackdatedRepaymentKeepsAmortizationConsistent() { 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 + // Repayment on Sep 5, then the COB that closes Sep 5 → first partial amortization businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-05"); wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "05 September 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-06"); wcLoanHelper.executeInlineWCCOB(loanId); final List afterFirst = filterByType(wcLoanHelper.getTransactions(loanId), @@ -516,8 +529,10 @@ void testBackdatedRepaymentKeepsAmortizationConsistent() { 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. + // discount. The business date moves on again so the loan is genuinely behind — a COB run whose date the + // loan has already closed processes nothing at all, and the backdated payment would go unnoticed. wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "02 September 2026")); + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-07"); wcLoanHelper.executeInlineWCCOB(loanId); final BigDecimal netAfterBackdated = netAmortization(loanId); From e6f3ef2f3e7b811e72983fb8d7741a6757bfb74f Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Mon, 21 Sep 2026 14:28:53 +0200 Subject: [PATCH 2/2] FINERACT-2455: updated e2e scenario for validation of Fix COB amortizing discount fee before payment, fix WC loan closedOnDate wrong when backdated transaction closes --- ...italDiscountAmortizationAdjustment.feature | 66 ++++ ...lDiscountAmortizationRecalculation.feature | 286 ++++++++++++++++++ 2 files changed, 352 insertions(+) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature index d9300dbb8c9..c9d4c0c13da 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationAdjustment.feature @@ -1372,3 +1372,69 @@ Feature: Working Capital Discount Fee Amortization Adjustment And The retrieved amortization schedule actual payments plus future expected payments total "9500.00" And The retrieved amortization schedule has no negative monetary amounts Then Admin closes the Working Capital loan with a full repayment on "08 January 2026" + + @TestRailId:C106808 + Scenario: Verify settlement dates - undo of the discount fee adjustment that settled the loan exactly clears the dates again + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "02 January 2026" with 9900.0 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds Discount fee adjustment with "100" amount on transaction date "03 January 2026" on Working Capital loan account for last discount + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | 2026-01-03 | + | timeline.actualMaturityDate | 2026-01-03 | + When Admin sets the business date to "04 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin undo the Discount fee adjustment with "100" amount on Working Capital loan account + Then Working Capital loan status will be "ACTIVE" + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | null | + | timeline.actualMaturityDate | null | + + @TestRailId:C106809 + Scenario: Verify settlement dates - a discount fee adjustment that settles the loan exactly dates the closure, a later extra payment keeps it and the refund closes it on its own date + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "02 January 2026" with 9900.0 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds Discount fee adjustment with "100" amount on transaction date "03 January 2026" on Working Capital loan account for last discount + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | 2026-01-03 | + | timeline.actualMaturityDate | 2026-01-03 | + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 50.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan details has the following field values: + | overpaidOnDate | 2026-01-05 | + | timeline.closedOnDate | null | + | timeline.actualMaturityDate | 2026-01-03 | + When Admin sets the business date to "06 January 2026" + And Customer makes credit balance refund on "06 January 2026" with 50.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | 2026-01-06 | + | timeline.actualMaturityDate | 2026-01-06 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature index cca79dd0419..a3c73145c86 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAmortizationRecalculation.feature @@ -459,3 +459,289 @@ Feature: Working Capital Discount Fee Amortization Recalculation | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | | 02 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + + @TestRailId:C106726 + Scenario: Verify COB amortizes only the payments dated on or before its COB date and the later payment on its own day - UC13 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Customer makes repayment on "03 January 2026" with 50 transaction amount on Working Capital loan + # COB runs for 02 January while a payment dated 03 January already exists: only the 02 January payment is amortized + When Admin runs inline COB job for Working Capital Loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "02 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 9.61 | | + | INCOME | 404000 | Interest Income | | 9.61 | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 100.0 | 9.61 | 990.39 | 0.0 | + # The 03 January payment is amortized by the COB of its own day + When Admin sets the business date to "04 January 2026" + And Admin runs inline COB job for Working Capital Loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Amortization | 9.57 | | | | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "03 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 9.57 | | + | INCOME | 404000 | Interest Income | | 9.57 | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 100.0 | 19.18 | 980.82 | 0.0 | + + @TestRailId:C106727 + Scenario: Verify catch-up COB dates the amortization of two backdated repayments on their own days - UC14 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan + # COB last closed 01 January; two repayments are booked in the past and the catch-up replays 02 to 05 January + When Admin sets the business date to "06 January 2026" + And Customer makes repayment on "03 January 2026" with 50 transaction amount on Working Capital loan + And Customer makes repayment on "05 January 2026" with 50 transaction amount on Working Capital loan + When Admin runs inline COB job for Working Capital Loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 05 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Amortization | 9.57 | | | | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "03 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 9.61 | | + | INCOME | 404000 | Interest Income | | 9.61 | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 100.0 | 19.18 | 980.82 | 0.0 | + + @TestRailId:C106728 + Scenario: Verify a backdated repayment that closes a part-paid loan dates closedOnDate and the closing amortization on the real settlement day - UC15 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 5000 transaction amount on Working Capital loan + When Admin sets the business date to "06 January 2026" + And Admin runs inline COB job for Working Capital Loan + # The backdated repayment completes the settlement, but the money that finished it arrived on 05 January + When Admin sets the business date to "07 January 2026" + And Customer makes repayment on "03 January 2026" with 5000 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | timeline.closedOnDate | 2026-01-05 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Repayment | 5000.0 | 5000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 5000.0 | 5000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Amortization | 739.9 | | | | false | + | 05 January 2026 | Discount Fee Amortization | 260.1 | | | | false | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 10000.0 | 1000.0 | 0.0 | 0.0 | + # Undoing the backdated repayment reopens the loan and clears the closing date + When Customer undo "1"th "Repayment" transaction made on "03 January 2026" on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And Working capital loan details has the following field values: + | timeline.closedOnDate | null | + + @TestRailId:C106756 + Scenario: Verify undoing the overpaying repayment dates closedOnDate and actualMaturityDate on the earlier settlement day, not on the undo day - UC16 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 9900 transaction amount on Working Capital loan + # 05 January: the last 100 settles the loan exactly + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 100 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | timeline.closedOnDate | 2026-01-05 | + | timeline.actualMaturityDate | 2026-01-05 | + # 08 January: an extra 50 tips the settled loan into overpayment; the day the obligations were met does not move + When Admin sets the business date to "08 January 2026" + And Customer makes repayment on "08 January 2026" with 50 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan details has the following field values: + | timeline.actualMaturityDate | 2026-01-05 | + | overpaidOnDate | 2026-01-08 | + # 12 January: the overpaying repayment is undone; the loan falls back onto the settlement of 05 January + When Admin sets the business date to "12 January 2026" + And Customer undo "1"th "Repayment" transaction made on "08 January 2026" on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | timeline.closedOnDate | 2026-01-05 | + | timeline.actualMaturityDate | 2026-01-05 | + | overpaidOnDate | null | + + @TestRailId:C106757 + Scenario: Verify a credit balance refund that clears the overpayment closes the loan on the refund day - UC17 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 9900 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 150 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan details has the following field values: + | timeline.actualMaturityDate | 2026-01-05 | + | overpaidOnDate | 2026-01-05 | + # 09 January: the 50 excess is refunded. A refund is a real money movement and the account was not closed until it + # happened, so both dates take the refund's own day - matching core's state machine. + When Admin sets the business date to "09 January 2026" + And Customer makes credit balance refund on "09 January 2026" with 50.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | timeline.closedOnDate | 2026-01-09 | + | timeline.actualMaturityDate | 2026-01-09 | + | overpaidOnDate | null | + + @TestRailId:C106758 + Scenario: Verify a backdated repayment that overpays a part-paid loan dates actualMaturityDate on the payment that finished the settlement - UC18 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 9000 transaction amount on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + # 12 January: 1000 dated 05 January; allocated in date order the running total reaches 10000 only with the 10 January payment + When Admin sets the business date to "12 January 2026" + And Customer makes repayment on "05 January 2026" with 1000 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan details has the following field values: + | timeline.actualMaturityDate | 2026-01-10 | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 10000.0 | 1000.0 | 0.0 | 50.0 | + + @TestRailId:C106807 + Scenario: Verify catch-up COB measures the already amortized income as of the day it replays, not as of today - UC19 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + # 02 January: COB is up to date here and then falls behind, so everything below is replayed on 09 January + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 5000 transaction amount on Working Capital loan + # 07 January: the loan is paid off, so the whole discount is recognized on the settlement day + When Admin sets the business date to "07 January 2026" + And Customer makes repayment on "07 January 2026" with 5000 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + # 08 January: undoing the closing repayment reopens the loan and leaves the closing amortization standing + When Admin sets the business date to "08 January 2026" + And Customer undo "1"th "Repayment" transaction made on "07 January 2026" on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + # 09 January: COB catches up over 02-08 January. Each replayed day compares the schedule as of that day against the + # income posted by that day: 05 January earns its 739.9, and only 07 January - where the stale closing amortization + # sits - takes the 1000 back off. Measured against everything posted to date instead, 02 January would have seen + # nothing earned and 1000 posted, and reversed income the loan had not earned yet on a day before it was booked. + When Admin sets the business date to "09 January 2026" + And Admin runs inline COB job for Working Capital Loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 5000.0 | 5000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Amortization | 739.9 | | | | false | + | 07 January 2026 | Repayment | 5000.0 | 5000.0 | 0.0 | 0.0 | true | + | 07 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 07 January 2026 | Discount Fee Amortization Adjustment | 1000.0 | | | | false | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 5000.0 | 739.9 | 260.1 | 0.0 | + + @TestRailId:C106810 + Scenario: Verify a backdated repayment that settles an overpaid loan earlier moves its maturity back, and a later refund closes it on the refund day - UC20 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 9000.0 transaction amount on Working Capital loan + # 10 January: 1500 against the 1000 still owed, the loan is overpaid by 500 and matured on 10 January + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 1500.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan details has the following field values: + | overpaidOnDate | 2026-01-10 | + | timeline.closedOnDate | null | + | timeline.actualMaturityDate | 2026-01-10 | + # 11 January: 1000 backdated to 05 January settles the loan exactly on 05 January; the 10 January payment is now pure surplus + When Admin sets the business date to "11 January 2026" + And Customer makes repayment on "05 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan details has the following field values: + | overpaidOnDate | 2026-01-10 | + | timeline.closedOnDate | null | + | timeline.actualMaturityDate | 2026-01-05 | + # 12 January: refunding the whole 1500 surplus closes the loan on the refund day + When Admin sets the business date to "12 January 2026" + And Customer makes credit balance refund on "12 January 2026" with 1500.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | 2026-01-12 | + | timeline.actualMaturityDate | 2026-01-12 |