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..9e86597c5ca 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 | + # The refund hands back money the loan never needed, so it is not what settled it: the closure inherits the + # 03 January settlement rather than taking the refund's own date. + And Working capital loan details has the following field values: + | overpaidOnDate | null | + | timeline.closedOnDate | 2026-01-03 | + | timeline.actualMaturityDate | 2026-01-03 | @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..d50e8357c46 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 @@ -435,3 +459,209 @@ 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 dates closedOnDate on the earlier settlement day, not 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; the refund is not a repayment, the loan settled on 05 January + 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-05 | + | timeline.actualMaturityDate | 2026-01-05 | + | 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 | 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..dfd92128bde 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentOverpayment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentOverpayment.feature @@ -230,7 +230,7 @@ Feature: Working Capital Loan Repayment - Overpayment 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.closedOnDate | 2026-01-04 | @TestRailId:C98269 Scenario: Verify overpaidOnDate is set when a CLOSED loan is overpaid by a later repayment - UC7 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 32eeaadc6ef..4cb0ec5146a 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 @@ -357,25 +357,75 @@ 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) { + /** + * 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; final ProjectedAmortizationScheduleModel asOfModel = generate(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(), - totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, - calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate); - asOfModel.copyPrincipalAdjustmentsFrom(this); - for (final ActualPayment payment : actualPayments) { - asOfModel.applyPayment(payment.date(), payment.amount().getAmount()); + totalPaymentVolume.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); + } + } + } + for (final ActualPayment payment : actualPayments) { + if (!payment.date().isAfter(reachedDate)) { + asOfModel.applyPayment(payment.date(), payment.amount().getAmount()); + } } + 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..333186e54b9 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 @@ -156,6 +156,29 @@ 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); + /** * 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..386a91035ad 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 @@ -356,7 +356,7 @@ private BigDecimal queryNetAmortized(final Long loanId) { 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..598c0260750 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; @@ -140,8 +141,10 @@ public void processRepaymentLikeTransaction(final WorkingCapitalLoan loan, final // Breach schedule is maintained incrementally here; reprocessing does not rebuild it. breachScheduleService.applyRepayment(loanId, transactionDate, transactionAmount); + final LoanStatus statusBeforeTransaction = loan.getLoanStatus(); stateMachine.determineAndTransition(loan, transactionDate); recalculateOverpaidOnDate(loan, transaction); + recalculateSettlementDates(loan, statusBeforeTransaction); 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. @@ -212,8 +215,136 @@ 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}, + * correcting the optimistic values the lifecycle state machine stamps when a loan settles. + *

+ * The same defect {@link #recalculateOverpaidOnDate} fixes on {@code overpaidOnDate}, reaching the API the same way + * through {@code WorkingCapitalLoanSummaryMapper.buildTimeline}. The machine stamps whichever transaction triggered + * the transition, which is the day the loan settled only when that transaction is also the chronologically last + * one. A backdated repayment completing an already part-paid loan is not: the money that finished the settlement + * arrived later, so the stored dates would say the loan settled before it was actually paid off. + *

+ * {@code statusBeforeEvent} is what the loan was before the transition ran. It separates an event that settled the + * loan from one that only took an excess off an already-settled loan - undoing an overpaying repayment, refunding a + * credit balance - and the two need corrections in opposite directions. It has to be passed in because by the time + * this runs the transition has overwritten the evidence, so callers capture the status beforehand. + *

+ * Must be called wherever the loan can be settled, alongside + * {@link #recalculateOverpaidOnDate(WorkingCapitalLoan, WorkingCapitalLoanTransaction)}: the repayment path here, a + * discount fee adjustment, and a transaction undo all reach it through {@code determineAndTransition}, and a credit + * balance refund transitions on its own. {@link #settlementDate} reads what both of them write, so a site that + * settles a loan without correcting these dates would book the closing amortization from the raw stamp. + */ + public void recalculateSettlementDates(final WorkingCapitalLoan loan, final LoanStatus statusBeforeEvent) { + final LoanStatus status = loan.getLoanStatus(); + if (status == null) { + return; + } + if (status.isOverpaid()) { + recalculateMaturityOnOverpaid(loan); + } else if (status.isClosedObligationsMet()) { + recalculateClosure(loan, statusBeforeEvent); + } + } + + /** + * An overpaid loan has met its obligations, so it carries a maturity date even though it is not closed. + *

+ * The machine dates it from the triggering transaction, which is the day the obligations were met only when that + * transaction is also the last payment that was needed. A backdated repayment stamps its own early date while the + * payment that finished the settlement came later, so the stamp has to be allowed to move later. + *

+ * It must not move earlier. {@code obligationsMetOn} names the last payment the loan needed, which is the day the + * obligations were met only when a payment is what met them - and it need not be. A discount fee adjustment that + * reduces what is owed to what has already been paid settles the loan on its own date, later than any repayment, + * and is not a repayment-like transaction at all, so the query cannot see it. The stamp is the only record of that + * day, and overwriting it would report a maturity from a day the loan was still running a balance. + */ + private void recalculateMaturityOnOverpaid(final WorkingCapitalLoan loan) { + final LocalDate obligationsMetOn = obligationsMetOn(loan); + if (DateUtils.isBefore(loan.getMaturedOnDate(), obligationsMetOn)) { + loan.setMaturedOnDate(obligationsMetOn); + } + } + + /** + * Both dates answer the same question here: the day the loan's obligations were met. That is the last payment the + * loan actually needed - a surplus payment arriving afterwards moves neither, and the refund or undo that takes the + * surplus away is not what settled the loan either. + */ + private void recalculateClosure(final WorkingCapitalLoan loan, final LoanStatus statusBeforeEvent) { + final LocalDate obligationsMetOn = obligationsMetOn(loan); + if (obligationsMetOn == null) { + return; + } + if (statusBeforeEvent != null && statusBeforeEvent.isOverpaid()) { + // The loan had already met its obligations, so this event did not settle it - it took away an excess, and + // the loan fell back onto a settlement that had happened earlier. Removing the overpaying transaction, by + // undo or by refund, can take away the very payment the dates were derived from, which moves the day + // earlier, so the one-directional rule below would never reach it and both dates are assigned outright. + // + // Floored at the maturity the loan already carries, because that is where a settlement completed by + // something other than a payment is recorded - a discount fee adjustment is not a repayment-like + // transaction, so it never appears in obligationsMetOn and dropping to it would date the closure from a day + // the loan was still running a balance. + final LocalDate settledOn = DateUtils.isBefore(obligationsMetOn, loan.getMaturedOnDate()) ? loan.getMaturedOnDate() + : obligationsMetOn; + loan.setClosedOnDate(settledOn); + loan.setMaturedOnDate(settledOn); + return; + } + // This event is what settled the loan, and it need not have been a repayment: a discount fee adjustment that + // reduces what is owed to what has already been paid, a waiver, a charge adjustment. For those the transition's + // own date is the day the loan settled and remains the best answer, so the last payment only moves the dates + // later, never earlier. + if (DateUtils.isBefore(loan.getClosedOnDate(), obligationsMetOn)) { + loan.setClosedOnDate(obligationsMetOn); + } + if (DateUtils.isBefore(loan.getMaturedOnDate(), obligationsMetOn)) { + loan.setMaturedOnDate(obligationsMetOn); + } + } + + /** The day the loan's obligations were met, or {@code null} when no payment has been needed to meet them. */ + private LocalDate obligationsMetOn(final WorkingCapitalLoan loan) { + return transactionRepository.findLatestActiveTransactionDateWithDuePortion(loan.getId(), + LoanTransactionType.getRepaymentLikeTransactionTypes()); + } + + /** + * 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. Both fields it reads are re-derived immediately before this runs, by + * {@link #recalculateOverpaidOnDate(WorkingCapitalLoan, WorkingCapitalLoanTransaction)} and + * {@link #recalculateSettlementDates(WorkingCapitalLoan, LoanStatus)}, so the income and the closure the API + * reports are 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; + if (loan.isOverpaid()) { + settledOn = loan.getOverpaidOnDate(); + } else if (loan.isClosedObligationsMet()) { + settledOn = loan.getClosedOnDate(); + } else { + settledOn = null; + } + return settledOn == null || !settledOn.isAfter(transactionDate) ? transactionDate : settledOn; + } } 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..2f213ddbc35 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,6 +696,7 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina stateMachine.determineAndTransition(loan, transactionDate); transactionProcessor.recalculateOverpaidOnDate(loan, adjustmentTransaction); + transactionProcessor.recalculateSettlementDates(loan, oldStatus); 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); @@ -960,7 +961,13 @@ 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); - loan.setMaturedOnDate(transactionDate); + // The transition stamps the closure from the refund's own date, and unlike determineAndTransition it + // leaves the maturity untouched, so the fallback below stands in for a loan that has none yet. Both are + // then re-derived: a refund hands back money the loan never needed, so it cannot be what settled it. + if (loan.getMaturedOnDate() == null) { + loan.setMaturedOnDate(transactionDate); + } + transactionProcessor.recalculateSettlementDates(loan, oldStatus); } } @@ -1141,6 +1148,7 @@ public CommandProcessingResult undoTransaction(final WorkingCapitalLoan loan, fi stateMachine.determineAndTransition(loan, DateUtils.getBusinessLocalDate()); transactionProcessor.recalculateOverpaidOnDate(loan, transaction); + transactionProcessor.recalculateSettlementDates(loan, oldStatus); changes.put("status", loan.getLoanStatus()); 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..6edc528b4e2 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanCobCatchUpAmortizationTest.java @@ -0,0 +1,428 @@ +/** + * 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 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)"); + }); + } + + // ----------------------------------------------------------------------- + // 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(); + } + + 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);