diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java index 56eb5ee4a7d..962d8e42f78 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java @@ -1878,7 +1878,10 @@ public void verifyTemplateAdvancedPaymentAllocationTypes(final DataTable table) public void createWorkingCapitalLoanProductWithPaymentAllocationOrder(final DataTable table) { final List rules = table.asList(); final String productName = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10); - final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest() // + // Allow attribute overrides so loans created from this product can supply their own discount (the loan + // creation step always sends a discount value). + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory + .defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest() // .name(productName) // .paymentAllocation(List.of(WorkingCapitalRequestFactory .createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), rules))); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature new file mode 100644 index 00000000000..1a68da3ec76 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature @@ -0,0 +1,322 @@ +@WorkingCapital +@WorkingCapitalTransactionReprocessingFeature +Feature: Working Capital Transaction Reprocessing + + @TestRailId:C85208 + Scenario: Verify backdated repayment is reflected in balances and existing allocations stay untouched (no charges) + 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + # First repayment on day 10 + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 6000.0 | + | totalPaidPrincipal | 3000.0 | + # Backdated repayment on day 5 (earlier than the existing day-10 repayment) + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + # Both repayments are reflected - balance math is order-independent + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + | totalPaidPrincipal | 5000.0 | + | overpaymentAmount | 0.0 | + # The backdated repayment allocates fully to principal; the earlier repayment stays untouched + 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 | + | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85209 + Scenario: Verify backdated repayment that overpays - excess becomes overpayment, allocations not redistributed + 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + # Partial repayment on day 10 (loan stays ACTIVE with 2000 outstanding) + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 7000 transaction amount on Working Capital loan + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 2000.0 | + # Backdated repayment on day 5 - total repaid (7000 + 5000 = 12000) exceeds principal (9000) + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 5000 transaction amount on Working Capital loan + # Totals are order-independent: 9000 principal repaid, 3000 overpayment + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 0.0 | + | totalPaidPrincipal | 9000.0 | + | overpaymentAmount | 3000.0 | + # Reprocessing is triggered (the day-5 repayment is backdated) but no-ops because the loan has no charges - + # principal-only allocation is order-independent. The day-10 repayment keeps its 7000 principal allocation, and the + # backdated day-5 repayment allocates against the 2000 outstanding when booked (its excess 3000 is overpayment). + 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 | + | 05 January 2026 | Repayment | 5000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 7000.0 | 7000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85210 + Scenario: Verify multiple backdated repayments accumulate correctly + 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + # First repayment on day 15 + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 3000 transaction amount on Working Capital loan + # Backdated repayment on day 5 + When Admin sets the business date to "20 January 2026" + And Customer makes repayment on "05 January 2026" with 1000 transaction amount on Working Capital loan + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 5000.0 | + | totalPaidPrincipal | 4000.0 | + # Another backdated repayment on day 10 (between the existing ones) + And Customer makes repayment on "10 January 2026" with 2000 transaction amount on Working Capital loan + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 3000.0 | + | totalPaidPrincipal | 6000.0 | + | overpaymentAmount | 0.0 | + 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 | + | 05 January 2026 | Repayment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85211 + Scenario: Verify sequential (non-backdated) repayments do not trigger reprocessing side effects + 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 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 3000 transaction amount on Working Capital loan + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + | totalPaidPrincipal | 5000.0 | + | overpaymentAmount | 0.0 | + 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 | + | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85212 + Scenario: Verify backdated repayment only settles a charge that is already due on the backdated 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 35.0 transaction amount + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + # Backdated repayment on day 5 -> reprocessing is triggered. The fee is due on day 10, so it is NOT yet due on + # day 5: the backdated repayment stays principal-only, while the day-10 repayment settles the fee (fee-first order). + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4035.0 | + | totalPaidPrincipal | 4965.0 | + # The day-5 repayment predates the fee due date, so it is principal-only; the day-10 repayment covers the fee + 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 | + | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 2965.0 | 35.0 | 0.0 | false | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Paid | Fee Outstanding | + | 35.0 | 35.0 | 0.0 | + + @TestRailId:C85213 + Scenario: Verify a repayment clearing more than one lapsed delinquency period distributes by remaining balance + 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + # Let period 1 (01 Jan - 30 Jan) lapse unpaid and be evaluated as not met + When Admin sets the business date to "31 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # Let period 2 (31 Jan - 01 Mar) lapse unpaid and be evaluated as not met + When Admin sets the business date to "02 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # Single repayment of 400 covering period 1 fully (270) and period 2 partially (130) + And Customer makes repayment on "02 March 2026" with 400 transaction amount on Working Capital loan + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 270.0 | 0.0 | true | 0.0 | 0 | + | 2 | 2026-01-31 | 2026-03-01 | 270.0 | 130.0 | 140.0 | false | 140.0 | 1 | + | 3 | 2026-03-02 | 2026-03-31 | 270.0 | 0.0 | 270.0 | null | null | null | + + @TestRailId:C85214 + Scenario: Verify backdated repayment is recorded on its actual day and the amortization balance is recalculated + 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 | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 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 + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + # Outstanding recomputed by the amortization model after the backdated payment + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + # The projected amortization schedule is intact (structure is a static projection); the recomputed outstanding + # balance above is the model's actual output that reflects the backdated payment. + When Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | totalPaymentVolume | periodPaymentRate | npvDayCount | expectedPaymentAmount | + | 0.00 | 9000.00 | 100000.00 | 18 | 360 | 50.00 | + + @TestRailId:C85215 + Scenario: Verify backdated repayment reduces the outstanding tracked by the breach schedule + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 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 + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + # Backdated repayment on day 5 + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has 1 period + + @TestRailId:C85216 + Scenario: Verify backdated repayment re-allocates the later transaction's fee portion to principal + # Txn#1 (day 10): pays 5 NSF-Fee + 25 principal (amount 30) + # Txn#2 backdated (day 5): pays 5 NSF-Fee + 20 principal (amount 25) + # => Txn#1 must be reprocessed and re-allocate to 30 principal (the fee is now taken by the earlier Txn#2) + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with payment allocation order: + | DUE_PENALTY | + | DUE_FEE | + | DUE_PRINCIPAL | + | IN_ADVANCE_PENALTY | + | IN_ADVANCE_FEE | + | IN_ADVANCE_PRINCIPAL | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 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 + # Add the fee while the business date is on its due date - charges cannot be dated before the business date + When Admin sets the business date to "05 January 2026" + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "05 January 2026" due date and 5.0 transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 30 transaction amount on Working Capital loan + # First, Txn#1 allocates 5 fee + 25 principal + Then 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 | + | 10 January 2026 | Repayment | 30.0 | 25.0 | 5.0 | 0.0 | false | + # Backdated Txn#2 on day 5 takes the fee; Txn#1 is reprocessed to 30 principal + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 25 transaction amount on Working Capital loan + Then 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 | + | 05 January 2026 | Repayment | 25.0 | 20.0 | 5.0 | 0.0 | false | + | 10 January 2026 | Repayment | 30.0 | 30.0 | 0.0 | 0.0 | false | + + @Skip + @TestRailId:C85217 + Scenario: Verify reversal of a transaction re-allocates the remaining transaction's fee portion + # Txn#1 (day 5): pays 5 NSF-Fee + 25 principal (amount 30) + # Txn#2 (day 10): pays 25 principal + # Txn#1 reverted => Txn#2 must be reprocessed and re-allocate to 5 NSF-Fee + 20 principal + # Requires: (a) fee/penalty payment allocation, (b) reversal-triggered reprocessing, (c) repayment-undo support + # (today undoTransaction rejects everything but DISCOUNT_FEE_ADJUSTMENT) - and a new "reverse repayment" step def. + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with payment allocation order: + | DUE_PENALTY | + | DUE_FEE | + | DUE_PRINCIPAL | + | IN_ADVANCE_PENALTY | + | IN_ADVANCE_FEE | + | IN_ADVANCE_PRINCIPAL | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 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 "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "05 January 2026" due date and 5.0 transaction amount + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 30 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 25 transaction amount on Working Capital loan + # Reverse Txn#1 (the day-5 repayment) -> Txn#2 must be reprocessed to take the fee + #And Admin reverses the "05 January 2026" repayment with 30 transaction amount on Working Capital loan + # Then 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 | + # | 05 January 2026 | Repayment | 30.0 | 25.0 | 5.0 | 0.0 | true | + # | 10 January 2026 | Repayment | 25.0 | 20.0 | 5.0 | 0.0 | false | + + @TestRailId:C85218 + Scenario: Verify a repayment splits across fee and principal per the product's allocation order + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with payment allocation order: + | DUE_PENALTY | + | DUE_FEE | + | DUE_PRINCIPAL | + | IN_ADVANCE_PENALTY | + | IN_ADVANCE_FEE | + | IN_ADVANCE_PRINCIPAL | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 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 + When Admin sets the business date to "10 January 2026" + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 5.0 transaction amount + And Customer makes repayment on "10 January 2026" with 30 transaction amount on Working Capital loan + # Fee-first allocation order: 5 to fee, 25 to principal + Then 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 | + | 10 January 2026 | Repayment | 30.0 | 25.0 | 5.0 | 0.0 | false | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Paid | Fee Outstanding | + | 5.0 | 5.0 | 0.0 | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java index 5e5da2b5fda..74416002f16 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java @@ -108,7 +108,7 @@ public WorkingCapitalLoanChargeData toData() { } public BigDecimal getAmountOutstanding() { - return getAmount().min(getAmountPaid()); + return getAmount().subtract(getAmountPaid() != null ? getAmountPaid() : BigDecimal.ZERO).max(BigDecimal.ZERO); } public static WorkingCapitalLoanCharge build(WorkingCapitalLoan loan, ExternalId externalId, Charge charge, BigDecimal amount, diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java index 686c4e69641..aeab0a79e4c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java @@ -70,6 +70,16 @@ public static WorkingCapitalLoanTransactionAllocation forPrincipalAllocation(fin return allocation; } + public static WorkingCapitalLoanTransactionAllocation forPortions(final WorkingCapitalLoanTransaction transaction, + final BigDecimal principalAmount, final BigDecimal feeAmount, final BigDecimal penaltyAmount) { + final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation(); + allocation.wcLoanTransaction = transaction; + allocation.principalPortion = MathUtil.nullToZero(principalAmount); + allocation.feeChargesPortion = MathUtil.nullToZero(feeAmount); + allocation.penaltyChargesPortion = MathUtil.nullToZero(penaltyAmount); + return allocation; + } + public static WorkingCapitalLoanTransactionAllocation forDisbursementDiscount(final WorkingCapitalLoanTransaction transaction, final BigDecimal principalAmount) { final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java index 0a7bbc5e56d..ad0a09a21fc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java @@ -35,6 +35,8 @@ public interface WorkingCapitalLoanChargeRepository Long findIdByExternalId(ExternalId externalId); + List findByLoan_IdAndActiveTrueOrderByDueDateAscIdAsc(Long loanId); + @Query("select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData(" + "lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol," + " oc.nameCode, lc.amount, lc.amountPaid, lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) from WorkingCapitalLoanCharge lc join fetch lc.charge c join OrganisationCurrency oc on c.currencyCode = oc.code join fetch lc.loan l where l.id = :loanId and lc.id = :id") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java index ee2d1370a16..93ea87a91bc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java @@ -18,7 +18,12 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.repository; +import java.util.Collection; +import java.util.List; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; import org.springframework.data.jpa.repository.JpaRepository; -public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository {} +public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository { + + List findByWcLoanTransaction_IdIn(Collection transactionIds); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java index 37d608891dd..d55b58766b2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java @@ -20,11 +20,16 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.List; import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; public interface WorkingCapitalLoanAmortizationScheduleWriteService { + /** A principal repayment applied to the amortization schedule on a given date. */ + record PrincipalPayment(LocalDate date, BigDecimal amount) { + } + void generateAndSaveAmortizationSchedule(Long loanId, ProjectedAmortizationScheduleGenerateRequest request); void generateAndSaveAmortizationScheduleOnDisbursement(WorkingCapitalLoan loan, BigDecimal disbursedAmount, LocalDate disbursementDate); @@ -44,4 +49,11 @@ public interface WorkingCapitalLoanAmortizationScheduleWriteService { * disbursement generation) and re-applies recorded actual repayments only. */ void applyDiscountFeeAdjustment(WorkingCapitalLoan loan); + + /** + * Rebuilds the projected schedule from scratch (as on disbursement) and re-applies the given principal payments in + * chronological order. Used by transaction reprocessing, where re-allocation can change the principal portion + * recorded on each transaction date. + */ + void rebuildScheduleFromPrincipalPayments(WorkingCapitalLoan loan, List principalPayments); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index a05f7c82911..28cc34226a0 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -216,13 +216,8 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { final List preservedPayments = currentModel.snapshotActualPayments(); - final BigDecimal disbursedAmount = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() - && loan.getDisbursementDetails().getFirst().getActualAmount() != null - ? loan.getDisbursementDetails().getFirst().getActualAmount() - : BigDecimal.ZERO; - final LocalDate disbursementDate = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() - ? loan.getDisbursementDetails().getFirst().getActualDisbursementDate() - : null; + final BigDecimal disbursedAmount = resolveActualDisbursedAmount(loan); + final LocalDate disbursementDate = resolveActualDisbursementDate(loan); final ProjectedAmortizationScheduleModel restatedModel = generateProjectedAmortizationScheduleModel(loan, disbursedAmount, disbursementDate); @@ -232,6 +227,23 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { scheduleRepositoryWrapper.writeModel(loan, restatedModel); } + @Override + public void rebuildScheduleFromPrincipalPayments(final WorkingCapitalLoan loan, final List principalPayments) { + Validate.notNull(loan, "loan must not be null"); + Validate.notNull(principalPayments, "principalPayments must not be null"); + + final BigDecimal disbursedAmount = resolveActualDisbursedAmount(loan); + final LocalDate disbursementDate = resolveActualDisbursementDate(loan); + + final ProjectedAmortizationScheduleModel model = generateProjectedAmortizationScheduleModel(loan, disbursedAmount, + disbursementDate); + principalPayments.stream().filter(payment -> payment.amount() != null && payment.amount().signum() > 0) + .sorted(Comparator.comparing(PrincipalPayment::date)) + .forEach(payment -> model.applyPayment(payment.date(), payment.amount())); + + scheduleRepositoryWrapper.writeModel(loan, model); + } + private LocalDate resolveLoanDisbursementDate(final WorkingCapitalLoan loan) { if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()) { final LocalDate actualDate = loan.getDisbursementDetails().getFirst().getActualDisbursementDate(); @@ -241,4 +253,19 @@ private LocalDate resolveLoanDisbursementDate(final WorkingCapitalLoan loan) { } throw new IllegalStateException("Active loan " + loan.getId() + " has no actual disbursement date"); } + + private BigDecimal resolveActualDisbursedAmount(final WorkingCapitalLoan loan) { + if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() + && loan.getDisbursementDetails().getFirst().getActualAmount() != null) { + return loan.getDisbursementDetails().getFirst().getActualAmount(); + } + return BigDecimal.ZERO; + } + + private LocalDate resolveActualDisbursementDate(final WorkingCapitalLoan loan) { + if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()) { + return loan.getDisbursementDetails().getFirst().getActualDisbursementDate(); + } + return null; + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index 9119991a19c..f5dc1da3d02 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -145,7 +145,7 @@ public void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal am .findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(loanId, transactionDate, transactionDate); BigDecimal transactionAmount = amount; for (WorkingCapitalLoanDelinquencyRangeSchedule period : pastOpenPeriods) { - BigDecimal payAmount = MathUtil.min(amount, period.getOutstandingAmount(), true); + BigDecimal payAmount = MathUtil.min(transactionAmount, period.getOutstandingAmount(), true); transactionAmount = transactionAmount.subtract(payAmount); period.setPaidAmount(period.getPaidAmount().add(payAmount)); period.setOutstandingAmount(period.getOutstandingAmount().subtract(payAmount)); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.java new file mode 100644 index 00000000000..32ab49f1023 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.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.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.loanproduct.domain.DueType; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPaymentAllocationRule; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType; +import org.springframework.stereotype.Component; + +/** + * Allocates a repayment-like amount across penalty charges, fee charges and principal following the payment allocation + * order configured on the loan (copied from the product at creation). The same allocation is used by the forward + * repayment flow and by {@link WorkingCapitalLoanTransactionReprocessingService} when transactions are replayed in a + * changed chronological order. + * + *

+ * The processor mutates the passed {@link WorkingCapitalLoanCharge charges} (amount paid / paid flag) and + * {@link WorkingCapitalLoanBalance balance} (principal/fee/penalty paid, overpayment) in place; persistence is the + * caller's responsibility. When the loan has no configured allocation order the amount falls back to principal-only, + * preserving the original behaviour for products that do not use charge allocation. + */ +@Component +public class WorkingCapitalLoanPaymentAllocationProcessor { + + /** + * @param charges + * active charges of the loan, ordered oldest due date first (so the oldest outstanding charge is settled + * first within a bucket) + */ + public AllocationResult allocate(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance, + final List charges, final LocalDate transactionDate, final BigDecimal amount) { + BigDecimal remaining = MathUtil.nullToZero(amount); + BigDecimal principalPortion = BigDecimal.ZERO; + BigDecimal feePortion = BigDecimal.ZERO; + BigDecimal penaltyPortion = BigDecimal.ZERO; + + for (final WorkingCapitalPaymentAllocationType allocationType : resolveAllocationOrder(loan)) { + if (!MathUtil.isGreaterThanZero(remaining)) { + break; + } + final DueType dueType = allocationType.getDueType(); + switch (allocationType.getAllocationType()) { + case PRINCIPAL -> { + final BigDecimal applied = allocateToPrincipal(balance, remaining); + principalPortion = principalPortion.add(applied); + remaining = remaining.subtract(applied); + } + case FEE -> { + final BigDecimal applied = allocateToCharges(charges, false, dueType, transactionDate, remaining, balance); + feePortion = feePortion.add(applied); + remaining = remaining.subtract(applied); + } + case PENALTY -> { + final BigDecimal applied = allocateToCharges(charges, true, dueType, transactionDate, remaining, balance); + penaltyPortion = penaltyPortion.add(applied); + remaining = remaining.subtract(applied); + } + default -> { + } + } + } + + final BigDecimal overpayment = remaining.max(BigDecimal.ZERO); + balance.setOverpaymentAmount(MathUtil.nullToZero(balance.getOverpaymentAmount()).add(overpayment)); + return new AllocationResult(principalPortion, feePortion, penaltyPortion, overpayment); + } + + private BigDecimal allocateToPrincipal(final WorkingCapitalLoanBalance balance, final BigDecimal remaining) { + final BigDecimal outstanding = MathUtil.nullToZero(balance.getPrincipalOutstanding()); + final BigDecimal applied = remaining.min(outstanding).max(BigDecimal.ZERO); + balance.setPrincipalPaid(MathUtil.nullToZero(balance.getPrincipalPaid()).add(applied)); + return applied; + } + + private BigDecimal allocateToCharges(final List charges, final boolean penalty, final DueType dueType, + final LocalDate transactionDate, final BigDecimal remaining, final WorkingCapitalLoanBalance balance) { + BigDecimal available = remaining; + BigDecimal totalApplied = BigDecimal.ZERO; + for (final WorkingCapitalLoanCharge charge : charges) { + if (!MathUtil.isGreaterThanZero(available)) { + break; + } + if (charge.isPenaltyCharge() != penalty || !matchesDueType(charge, dueType, transactionDate)) { + continue; + } + final BigDecimal outstanding = charge.getAmountOutstanding(); + if (!MathUtil.isGreaterThanZero(outstanding)) { + continue; + } + final BigDecimal applied = available.min(outstanding); + charge.setAmountPaid(MathUtil.nullToZero(charge.getAmountPaid()).add(applied)); + if (!MathUtil.isGreaterThanZero(charge.getAmountOutstanding())) { + charge.setPaid(true); + } + available = available.subtract(applied); + totalApplied = totalApplied.add(applied); + } + if (MathUtil.isGreaterThanZero(totalApplied)) { + if (penalty) { + balance.setPenaltyPaid(MathUtil.nullToZero(balance.getPenaltyPaid()).add(totalApplied)); + } else { + balance.setFeePaid(MathUtil.nullToZero(balance.getFeePaid()).add(totalApplied)); + } + } + return totalApplied; + } + + private boolean matchesDueType(final WorkingCapitalLoanCharge charge, final DueType dueType, final LocalDate transactionDate) { + final LocalDate dueDate = charge.getDueDate(); + // A charge with no due date is treated as already due; otherwise "due" means on/before the transaction date. + final boolean isDue = dueDate == null || !dueDate.isAfter(transactionDate); + return dueType == DueType.DUE ? isDue : !isDue; + } + + private List resolveAllocationOrder(final WorkingCapitalLoan loan) { + final List rules = loan.getPaymentAllocationRules(); + if (rules != null && !rules.isEmpty()) { + final WorkingCapitalLoanPaymentAllocationRule repaymentRule = findRule(rules, PaymentAllocationTransactionType.REPAYMENT); + final WorkingCapitalLoanPaymentAllocationRule rule = repaymentRule != null ? repaymentRule + : findRule(rules, PaymentAllocationTransactionType.DEFAULT); + if (rule != null && rule.getAllocationTypes() != null && !rule.getAllocationTypes().isEmpty()) { + return rule.getAllocationTypes(); + } + } + // No configured order: keep the legacy principal-only behaviour. + return List.of(WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL); + } + + private WorkingCapitalLoanPaymentAllocationRule findRule(final List rules, + final PaymentAllocationTransactionType transactionType) { + return rules.stream().filter(rule -> transactionType.equals(rule.getTransactionType())).findFirst().orElse(null); + } + + public record AllocationResult(BigDecimal principalPortion, BigDecimal feeChargesPortion, BigDecimal penaltyChargesPortion, + BigDecimal overpaymentPortion) { + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java new file mode 100644 index 00000000000..da35cfb85e8 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.util.List; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +/** + * Reprocesses transaction allocations for a Working Capital loan after a backdated transaction changes the + * chronological order. (Transaction reversal is the other intended trigger but is not wired yet — it arrives with the + * generic undo work in PS-3209.) + * + *

+ * Transactions themselves are never reversed or replayed — only the allocation split (principal/fee/penalty portions) + * of affected transactions is recalculated. From those recomputed portions this service also rebuilds the loan balance + * (paid amounts) and the amortization schedule. + * + *

+ * The delinquency and breach schedules are intentionally not rebuilt here: they are maintained incrementally + * by the regular transaction flow. This is a known, accepted limitation for the current simple-scenario scope — a + * backdated re-allocation that shifts principal between transactions can leave the per-period paid amounts slightly + * stale, but only when a period boundary falls between the affected dates (the totals are unchanged). + * + *

+ * Allocation order only matters when payments compete for charge buckets. A loan without charges allocates every + * repayment-like transaction to principal only, which is order-independent — reprocessing is a no-op in that case. + */ +public interface WorkingCapitalLoanTransactionReprocessingService { + + void reprocessTransactions(WorkingCapitalLoan loan); + + /** + * Reprocesses using the provided pre-loaded transaction list (avoids a redundant DB query when the caller has + * already fetched them). + */ + void reprocessTransactions(WorkingCapitalLoan loan, List allTransactions); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java new file mode 100644 index 00000000000..5d9eeb6c267 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java @@ -0,0 +1,146 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanAmortizationScheduleWriteService.PrincipalPayment; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class WorkingCapitalLoanTransactionReprocessingServiceImpl implements WorkingCapitalLoanTransactionReprocessingService { + + // Replay order matches the standard loan transaction ordering, simplified for WC (no accrual/income posting): + // transaction date, then submitted date, then id. + private static final Comparator TRANSACTION_ORDER = Comparator + .comparing(WorkingCapitalLoanTransaction::getTransactionDate) + .thenComparing(WorkingCapitalLoanTransaction::getSubmittedOnDate, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(WorkingCapitalLoanTransaction::getId); + + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanChargeRepository chargeRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; + private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; + private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; + + @Override + public void reprocessTransactions(final WorkingCapitalLoan loan) { + final List allTransactions = transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loan.getId()); + reprocessTransactions(loan, allTransactions); + } + + @Override + public void reprocessTransactions(final WorkingCapitalLoan loan, final List allTransactions) { + // Allocation order only matters when payments compete for charge buckets. Without charges, every + // repayment-like transaction allocates to principal only — min(amount, outstanding) — which is + // order-independent, so a changed chronological order cannot change any allocation. + final List charges = chargeRepository.findByLoan_IdAndActiveTrueOrderByDueDateAscIdAsc(loan.getId()); + if (charges.isEmpty()) { + log.debug("Skipping transaction reprocessing for WC loan {}: no active charges, allocations are order-independent", + loan.getId()); + return; + } + + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()).orElse(null); + if (balance == null) { + log.debug("Skipping transaction reprocessing for WC loan {}: no balance to recompute", loan.getId()); + return; + } + + // Reset the paid distribution; the principal/fee/penalty totals stay, only how much of each is paid is + // recomputed. + balance.setPrincipalPaid(BigDecimal.ZERO); + balance.setFeePaid(BigDecimal.ZERO); + balance.setPenaltyPaid(BigDecimal.ZERO); + balance.setOverpaymentAmount(BigDecimal.ZERO); + for (final WorkingCapitalLoanCharge charge : charges) { + charge.setAmountPaid(BigDecimal.ZERO); + charge.setPaid(false); + } + + // Re-allocate every non-reversed repayment-like transaction in chronological order. + final List replayable = allTransactions.stream() + .filter(txn -> !txn.isReversed() && isRepaymentLike(txn.getTypeOf())).sorted(TRANSACTION_ORDER).toList(); + + // Pre-load the existing allocations in one query rather than per transaction. Looking them up via the + // repository (instead of txn.getAllocation()) also avoids the lazy inverse side being stale for the + // transaction that just triggered the reprocessing, which would otherwise create a second allocation row and + // violate the one-allocation-per-transaction unique constraint. + final Map allocationsByTxnId = allocationRepository + .findByWcLoanTransaction_IdIn(replayable.stream().map(WorkingCapitalLoanTransaction::getId).toList()).stream() + .collect(Collectors.toMap(allocation -> allocation.getWcLoanTransaction().getId(), Function.identity())); + + final List principalPayments = new ArrayList<>(); + final List updatedAllocations = new ArrayList<>(); + for (final WorkingCapitalLoanTransaction txn : replayable) { + final WorkingCapitalLoanPaymentAllocationProcessor.AllocationResult result = allocationProcessor.allocate(loan, balance, + charges, txn.getTransactionDate(), txn.getTransactionAmount()); + updatedAllocations.add(applyAllocation(txn, allocationsByTxnId.get(txn.getId()), result)); + principalPayments.add(new PrincipalPayment(txn.getTransactionDate(), result.principalPortion())); + } + + allocationRepository.saveAll(updatedAllocations); + chargeRepository.saveAll(charges); + balanceRepository.saveAndFlush(balance); + + // The amortization schedule depends only on the principal paid per day, which can shift when the principal + // portions are re-allocated; rebuild it from the recomputed portions. + amortizationScheduleWriteService.rebuildScheduleFromPrincipalPayments(loan, principalPayments); + } + + private WorkingCapitalLoanTransactionAllocation applyAllocation(final WorkingCapitalLoanTransaction txn, + final WorkingCapitalLoanTransactionAllocation existing, + final WorkingCapitalLoanPaymentAllocationProcessor.AllocationResult result) { + if (existing == null) { + return WorkingCapitalLoanTransactionAllocation.forPortions(txn, result.principalPortion(), result.feeChargesPortion(), + result.penaltyChargesPortion()); + } + existing.setPrincipalPortion(result.principalPortion()); + existing.setFeeChargesPortion(result.feeChargesPortion()); + existing.setPenaltyChargesPortion(result.penaltyChargesPortion()); + return existing; + } + + private boolean isRepaymentLike(final LoanTransactionType type) { + return type == LoanTransactionType.REPAYMENT || type == LoanTransactionType.GOODWILL_CREDIT; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 3e639a2c0d8..8d1d6d5f2c4 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 @@ -60,6 +60,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; @@ -71,6 +72,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; @@ -105,6 +107,9 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanTransactionRelationRepository relationRepository; private final WorkingCapitalLoanPeriodPaymentRateChangeRepository rateChangeRepository; private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; + private final WorkingCapitalLoanTransactionReprocessingService transactionReprocessingService; + private final WorkingCapitalLoanChargeRepository chargeRepository; + private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -662,18 +667,42 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, transactionDate, classification, txnExternalId); this.transactionRepository.saveAndFlush(transaction); - final WorkingCapitalLoanBalance currentBalance = this.balanceRepository.findByWcLoan_Id(loan.getId()) + final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - final BigDecimal outstandingBeforeRepayment = MathUtil.nullToZero(currentBalance.getPrincipalOutstanding()); - final BigDecimal amountAppliedToOutstanding = transactionAmount.min(outstandingBeforeRepayment); + final List charges = this.chargeRepository.findByLoan_IdAndActiveTrueOrderByDueDateAscIdAsc(loanId); - final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation - .forPrincipalAllocation(transaction, amountAppliedToOutstanding); + // Allocate the amount across penalty/fee/principal following the loan's configured payment allocation order + // (principal-only when no order is configured). This updates the charge paid amounts and the loan balance. + final WorkingCapitalLoanPaymentAllocationProcessor.AllocationResult allocationResult = allocationProcessor.allocate(loan, balance, + charges, transactionDate, transactionAmount); + this.chargeRepository.saveAll(charges); + this.balanceRepository.saveAndFlush(balance); + + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation.forPortions(transaction, + allocationResult.principalPortion(), allocationResult.feeChargesPortion(), allocationResult.penaltyChargesPortion()); this.allocationRepository.saveAndFlush(allocation); - amortizationScheduleWriteService.applyRepayment(loan, transactionDate, amountAppliedToOutstanding); - updateBalanceOnRepayment(loan, transactionAmount); - internalWorkingCapitalLoanPaymentService.makePayment(loanId, amountAppliedToOutstanding, transactionDate); + // Only the principal portion affects the amortization and delinquency/breach schedules; fee and penalty + // portions settle charges. + final BigDecimal principalPortion = allocationResult.principalPortion(); + + // A backdated transaction can change how the other transactions allocate across charges, so it triggers + // reprocessing. When the loan has charges, reprocessing rebuilds the amortization schedule from scratch, so + // the incremental apply below would be immediately overwritten and is skipped. For a charge-free loan + // reprocessing is a no-op (principal-only allocation is order-independent), so the incremental apply stands. + final List allTransactions = this.transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loanId); + final boolean backdated = isBackdatedTransaction(allTransactions, transaction); + final boolean reprocessingWillRebuildSchedule = backdated && !charges.isEmpty(); + if (!reprocessingWillRebuildSchedule) { + // The amortization model records the principal on its actual day and recalculates forward. + amortizationScheduleWriteService.applyRepayment(loan, transactionDate, principalPortion); + } + if (backdated) { + transactionReprocessingService.reprocessTransactions(loan, allTransactions); + } + // Delinquency and breach schedules are maintained incrementally here; reprocessing does not rebuild them. + internalWorkingCapitalLoanPaymentService.makePayment(loanId, principalPortion, transactionDate); handleStateChanges(loan, transactionDate); triggerInlineAmortizationIfLoanClosed(loan, transactionDate); @@ -933,24 +962,6 @@ private void updateBalanceForDiscountChange(final WorkingCapitalLoan loan, final this.balanceRepository.saveAndFlush(balance); } - private void updateBalanceOnRepayment(final WorkingCapitalLoan loan, final BigDecimal repaymentAmount) { - final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) - .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - final BigDecimal principalOutstanding = balance.getPrincipalOutstanding() != null ? balance.getPrincipalOutstanding() - : BigDecimal.ZERO; - final BigDecimal currentTotalPaidPrincipal = balance.getPrincipalPaid() != null ? balance.getPrincipalPaid() : BigDecimal.ZERO; - final BigDecimal currentOverpayment = balance.getOverpaymentAmount() != null ? balance.getOverpaymentAmount() : BigDecimal.ZERO; - final BigDecimal amountAppliedToOutstanding = repaymentAmount.min(principalOutstanding); - final BigDecimal overpaymentIncrement = repaymentAmount.subtract(amountAppliedToOutstanding).max(BigDecimal.ZERO); - - balance.setOverpaymentAmount(currentOverpayment.add(overpaymentIncrement)); - - final BigDecimal principalPaidInThisRepayment = amountAppliedToOutstanding.max(BigDecimal.ZERO); - balance.setPrincipalPaid(currentTotalPaidPrincipal.add(principalPaidInThisRepayment)); - - this.balanceRepository.saveAndFlush(balance); - } - private void updateBalanceOnCreditBalanceRefund(final WorkingCapitalLoan loan, final BigDecimal refundAmount) { final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); @@ -959,6 +970,15 @@ private void updateBalanceOnCreditBalanceRefund(final WorkingCapitalLoan loan, f this.balanceRepository.saveAndFlush(balance); } + private boolean isBackdatedTransaction(final List allTransactions, + final WorkingCapitalLoanTransaction newTxn) { + // The same-date ID comparison is defensive only: the just-persisted transaction holds the highest + // ID, so in practice only a strictly later transaction date marks the new one as backdated. + return allTransactions.stream().filter(txn -> !txn.isReversed() && !txn.getId().equals(newTxn.getId())) + .anyMatch(txn -> txn.getTransactionDate().isAfter(newTxn.getTransactionDate()) + || (txn.getTransactionDate().equals(newTxn.getTransactionDate()) && txn.getId().compareTo(newTxn.getId()) > 0)); + } + private void reverseTransaction(final WorkingCapitalLoanTransaction txn) { txn.setReversed(true); txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java new file mode 100644 index 00000000000..49b022a7398 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java @@ -0,0 +1,261 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests.client.feign.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for WC Transaction Reprocessing (generic). + * + * Backdated repayments are applied through the regular incremental flow (balance math is order-independent and the + * amortization model records payments on their actual day). The reprocessing engine only recalculates allocations of + * subsequent transactions when payments compete for charge buckets — without charges it is a no-op, which these tests + * verify by asserting that existing allocations stay untouched after a backdated repayment. + * + *

+ * The charge-based re-allocation path (fees/penalties competing across transactions) is covered at the E2E layer in + * {@code WorkingCapitalTransactionReprocessing.feature} (C85212/C85216/C85218); this integration suite focuses on the + * charge-free, order-independent path. + */ +public class FeignWorkingCapitalLoanTransactionReprocessingTest extends FeignIntegrationTest { + + private FeignWorkingCapitalLoanHelper wcLoanHelper; + private FeignClientHelper clientHelper; + private FeignBusinessDateHelper businessDateHelper; + private WorkingCapitalLoanProductHelper productHelper; + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + + @BeforeAll + void setupHelpers() { + wcLoanHelper = new FeignWorkingCapitalLoanHelper(fineractClient()); + clientHelper = new FeignClientHelper(fineractClient()); + businessDateHelper = new FeignBusinessDateHelper(fineractClient()); + productHelper = new WorkingCapitalLoanProductHelper(); + } + + @AfterAll + void cleanupEntities() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + createdProductIds.clear(); + } + + @Test + void testBackdatedRepayment_balanceReflectsBothPayments() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // First repayment on day 10 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterFirstRepayment = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterFirstRepayment.getBalance(), "Balance should exist after repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterFirstRepayment.getBalance().getPrincipalOutstanding(), + "Outstanding should be 6000 after 3000 repayment on 9000 loan"); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterFirstRepayment.getBalance().getPrincipalPaid(), + "Principal paid should be 3000 after first repayment"); + + // Backdated repayment on day 5 (before existing repayment on day 10) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "05 January 2026")); + + // Both repayments should be reflected — balance math is order-independent + GetWorkingCapitalLoansLoanIdResponse afterBackdated = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterBackdated.getBalance(), "Balance should exist after backdated repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(4000), afterBackdated.getBalance().getPrincipalOutstanding(), + "Outstanding should be 4000 after total 5000 repaid on 9000 loan"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), afterBackdated.getBalance().getPrincipalPaid(), + "Principal paid should be 5000 (2000 + 3000)"); + + // Both repayments fit into principal: the backdated one allocates fully, the earlier one stays untouched + List transactions = wcLoanHelper.getTransactions(loanId); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 5), BigDecimal.valueOf(2000)), BigDecimal.valueOf(2000)); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 10), BigDecimal.valueOf(3000)), BigDecimal.valueOf(3000)); + }); + } + + @Test + void testBackdatedRepayment_excessBecomesOverpayment() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // Partial repayment on day 10 (loan stays ACTIVE with 2000 outstanding) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(7000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterRepayment = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterRepayment.getBalance(), "Balance should exist after repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(2000), afterRepayment.getBalance().getPrincipalOutstanding(), + "Outstanding should be 2000 after 7000 repayment on 9000 loan"); + + // Backdated repayment on day 5 — total repaid (5000 + 7000 = 12000) exceeds principal (9000) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(5000), "05 January 2026")); + + // Totals are order-independent: 9000 principal repaid, 3000 overpayment + GetWorkingCapitalLoansLoanIdResponse afterBackdated = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterBackdated.getBalance(), "Balance should exist after backdated repayment"); + assertEqualBigDecimal(BigDecimal.ZERO, afterBackdated.getBalance().getPrincipalOutstanding(), + "Outstanding should be 0 — principal is fully repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(9000), afterBackdated.getBalance().getPrincipalPaid(), + "Principal paid should be 9000 — capped at total principal"); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterBackdated.getBalance().getOverpaymentAmount(), + "Overpayment should be 3000 (5000 + 7000 - 9000 principal)"); + + // Without charges, allocations are not redistributed: the day-10 repayment keeps its original + // 7000 principal allocation, and the backdated day-5 repayment allocates against the 2000 that + // was outstanding when it was booked (its excess 3000 is overpayment, not part of the allocation). + List transactions = wcLoanHelper.getTransactions(loanId); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 5), BigDecimal.valueOf(5000)), BigDecimal.valueOf(2000)); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 10), BigDecimal.valueOf(7000)), BigDecimal.valueOf(7000)); + }); + } + + @Test + void testMultipleBackdatedRepaymentsAccumulateCorrectly() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // First repayment on day 15 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "15 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterFirst = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterFirst.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterFirst.getBalance().getPrincipalOutstanding(), + "Outstanding should be 6000 after first repayment"); + + // Backdated repayment on day 5 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-20"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), "05 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterSecond = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterSecond.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), afterSecond.getBalance().getPrincipalOutstanding(), + "Outstanding should be 5000 after 4000 total repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(4000), afterSecond.getBalance().getPrincipalPaid(), + "Principal paid should be 4000 (1000 + 3000)"); + + // Another backdated repayment on day 10 (between existing ones) + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterThird = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterThird.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterThird.getBalance().getPrincipalOutstanding(), + "Outstanding should be 3000 after 6000 total repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterThird.getBalance().getPrincipalPaid(), + "Principal paid should be 6000 (1000 + 2000 + 3000)"); + }); + } + + @Test + void testNonBackdatedRepaymentDoesNotTriggerReprocessing() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // Sequential repayments (not backdated — each on or after the business date) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-05"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "05 January 2026")); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "10 January 2026")); + + // Verify balance is the simple sum — no reprocessing side effects + GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(loan.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(4000), loan.getBalance().getPrincipalOutstanding(), + "Outstanding should be 4000 after sequential 2000 + 3000 repayments"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), loan.getBalance().getPrincipalPaid(), + "Principal paid should be 5000 after sequential repayments"); + assertEqualBigDecimal(BigDecimal.ZERO, loan.getBalance().getOverpaymentAmount(), + "No overpayment expected for sequential repayments under principal"); + }); + } + + private Long createAndDisburseLoanOnDate(Long clientIdParam, BigDecimal principal, String date) { + Long productId = createProduct(); + Long loanId = submitAndTrack(clientIdParam, productId, principal, date); + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approve(date, principal, date)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburse(date, principal)); + return loanId; + } + + private Long submitAndTrack(Long clientIdParam, Long productId, BigDecimal principal, String date) { + Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplication(clientIdParam, productId, + principal, BigDecimal.valueOf(18), date, date)); + createdLoanIds.add(loanId); + return loanId; + } + + private Long createProduct() { + String uniqueName = "WCL Reprocess " + Utils.uniqueRandomStringGenerator("", 8); + String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private static GetWorkingCapitalLoanTransactionIdResponse findTransaction(List transactions, + LocalDate transactionDate, BigDecimal amount) { + return transactions.stream().filter(txn -> transactionDate.equals(txn.getTransactionDate())) + .filter(txn -> txn.getTransactionAmount() != null && amount.compareTo(txn.getTransactionAmount()) == 0).findFirst() + .orElseThrow(() -> new AssertionError("Transaction not found on " + transactionDate + " with amount " + amount)); + } + + private static void assertAllocation(GetWorkingCapitalLoanTransactionIdResponse transaction, BigDecimal expectedPrincipalPortion) { + String context = "Transaction on " + transaction.getTransactionDate() + " amount " + transaction.getTransactionAmount(); + assertEqualBigDecimal(expectedPrincipalPortion, transaction.getPrincipalPortion(), context + " — principal portion"); + assertEqualBigDecimal(BigDecimal.ZERO, transaction.getFeeChargesPortion(), context + " — fee charges portion"); + assertEqualBigDecimal(BigDecimal.ZERO, transaction.getPenaltyChargesPortion(), context + " — penalty charges portion"); + } + + private static void assertEqualBigDecimal(BigDecimal expected, BigDecimal actual, String message) { + assertNotNull(actual, message + " — value was null"); + assertEquals(0, expected.compareTo(actual), message + " — expected: " + expected + " but was: " + actual); + } +}