diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature new file mode 100644 index 00000000000..e2ee13db867 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature @@ -0,0 +1,295 @@ +@WorkingCapital +@WorkingCapitalLoanChargeAfterMaturityFeature +Feature: Working Capital Loan Charge After Maturity + + @TestRailId:C85607 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC1: closed loan reopens to ACTIVE when charge added after maturity + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_BREACH_NEAR_BREACH | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP_BREACH_NEAR_BREACH | 2026-01-01 | 2026-01-01 | Closed (obligations met) | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 0.0 | + When Admin sets the business date to "01 June 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 "01 June 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 June 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan details has the following field values: + | balance.totalOutstanding | 100.0 | + | balance.principalOutstanding | 0.0 | + | timeline.actualMaturityDate | 2026-06-01 | + | delinquencyStartDate | null | + | breachStartDate | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 12.30 | 12.30 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "01 June 2026" + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85608 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC2: overpaid loan stays OVERPAID when charge is less than overpayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1150.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-01-01 | 2026-01-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 150.0 | + When Admin sets the business date to "01 July 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 "01 July 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "OVERPAID" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 July 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-01-01 | 2026-01-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 50.0 | + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.principalOutstanding | 0.0 | + | delinquencyStartDate | null | + | breachStartDate | null | + And Working Capital loan breach schedule has no data + Then Customer makes credit balance refund on "01 July 2026" with 50.0 transaction amount on Working Capital loan + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85609 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC3: overpaid loan closes when charge equals overpayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-01-01 | 2026-01-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 100.0 | + When Admin sets the business date to "01 August 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 "01 August 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 August 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-01-01 | 2026-01-01 | Closed (obligations met) | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 0.0 | + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | delinquencyStartDate | null | + | breachStartDate | null | + And Working Capital loan breach schedule has no data + + @TestRailId:C85610 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC4: overpaid loan reopens to ACTIVE when charge is greater than overpayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_BREACH_NEAR_BREACH | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1040.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP_BREACH_NEAR_BREACH | 2026-01-01 | 2026-01-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 40.0 | + When Admin sets the business date to "01 September 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 "01 September 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 September 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 60.0 | 40.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan details has the following field values: + | balance.totalOutstanding | 60.0 | + | balance.principalOutstanding | 0.0 | + | timeline.actualMaturityDate | 2026-09-01 | + | delinquencyStartDate | null | + | breachStartDate | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 12.30 | 12.30 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "01 September 2026" + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85611 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC5: active loan past maturity updates maturity and schedules on charge + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_BREACH_NEAR_BREACH | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + When Admin sets the business date to "01 October 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 "01 October 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 October 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan details has the following field values: + | balance.totalOutstanding | 1100.0 | + | balance.principalOutstanding | 1000.0 | + | timeline.actualMaturityDate | 2026-10-01 | + | delinquencyStartDate | present | + | breachStartDate | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 12.30 | 12.30 | null | null | + | 2 | 2026-03-01 | 2026-04-30 | 61 | 12.30 | 12.30 | null | null | + | 3 | 2026-05-01 | 2026-06-30 | 61 | 12.30 | 12.30 | null | null | + | 4 | 2026-07-01 | 2026-08-31 | 62 | 12.30 | 12.30 | null | null | + | 5 | 2026-09-01 | 2026-10-31 | 61 | 12.30 | 12.30 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "01 October 2026" + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85612 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC6: charge on accrual product after maturity does not create a spurious transaction + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "01 November 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 "01 November 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 November 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Repayment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + Then Admin closes the Working Capital loan with a full repayment on "01 November 2026" + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85613 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC7: partial repayment on charge-reopened loan stays ACTIVE and clears fee + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_BREACH_NEAR_BREACH | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "01 December 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 "01 December 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 December 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + When Admin sets the business date to "02 December 2026" + And Customer makes repayment on "02 December 2026" with 40.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 60.0 | 40.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan details has the following field values: + | balance.totalOutstanding | 60.0 | + Then Admin closes the Working Capital loan with a full repayment on "02 December 2026" + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85614 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC8: overpayment consumed by charge survives backdated repayment reprocessing + When Admin sets the business date to "01 August 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 August 2026 | 01 August 2026 | 1000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 August 2026" + And Customer makes repayment on "10 August 2026" with 1150.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-08-01 | 2026-08-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 150.0 | + When Admin sets the business date to "15 August 2026" + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "20 August 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "OVERPAID" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 20 August 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-08-01 | 2026-08-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 50.0 | + When Admin sets the business date to "20 August 2026" + And Customer makes repayment on "05 August 2026" with 50.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discount | totalPaidPrincipal | overpaymentAmount | + | WCLP | 2026-08-01 | 2026-08-01 | Overpaid | 1000.0 | 1000.0 | 100000.0 | 18.0 | null | 1000.0 | 100.0 | + Then Customer makes credit balance refund on "20 August 2026" with 100.0 transaction amount on Working Capital loan + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85615 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC9: charge due date before business date is rejected (Negative) + When Admin creates working capital loan charge without payment mode + And Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Trying to add working capital loan charge by loan id and charge id with amount 100.0 and due date "01-05-2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | DueDate cannot be in the past | + + @TestRailId:C85616 + Scenario: Verify charges added after Maturity date, closed, overpaid loan - UC10: accounting entries after charge-driven reopen and repayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Customer makes repayment on "01 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "01 June 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 "01 June 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 01 June 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + When Admin sets the business date to "02 June 2026" + And Customer makes repayment on "02 June 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "02 June 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 100.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanGoodwillCreditBackdatedUndo.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanGoodwillCreditBackdatedUndo.feature index 284058f8232..78f0b8cfaa1 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanGoodwillCreditBackdatedUndo.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanGoodwillCreditBackdatedUndo.feature @@ -331,7 +331,7 @@ Feature: Working Capital Loan Goodwill Credit Backdated and Undo | 10 January 2026 | Repayment | 9000.0 | 8500.0 | 0.0 | 0.0 | false | @TestRailId:C85544 - Scenario: Verify Working Capital Goodwill Credit backdated/undo - UC13: Goodwill credit settling only a fee on a closed loan keeps the loan closed + Scenario: Verify Working Capital Goodwill Credit backdated/undo - UC13: Goodwill credit settling the remaining due fee closes the loan 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: @@ -349,7 +349,7 @@ Feature: Working Capital Loan Goodwill Credit Backdated and Undo 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 35.0 transaction amount And Customer makes repayment on "05 January 2026" with 9000 transaction amount on Working Capital loan - Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + Then Working Capital loan status will be "ACTIVE" And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Paid | Fee Outstanding | | 35.0 | 0.0 | 35.0 | 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 a2c5f236d49..8920ddd714c 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 @@ -153,6 +153,18 @@ public List projectedPayments() { return projectedPayments; } + /** + * The scheduled maturity is the date of the last real projected payment (a period with {@code paymentNo > 0}, i.e. + * excluding the disbursement row). Returns {@code null} when the schedule has no real payments. + */ + public LocalDate scheduledMaturityDate() { + if (projectedPayments == null) { + return null; + } + return projectedPayments.stream().filter(payment -> payment.paymentNo() > 0).map(ProjectedPayment::date).filter(Objects::nonNull) + .max(Comparator.naturalOrder()).orElse(null); + } + public List originalProjectedPayments() { return originalProjectedPayments; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java index 00df7890040..be00a2cf605 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java @@ -20,6 +20,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.List; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; @@ -43,17 +44,22 @@ public boolean canTransition(final WorkingCapitalLoanEvent event, final WorkingC } /** - * Determines and applies the status transition implied by the loan's current balance after a monetary transaction: + * Determines and applies the status transition implied by the loan's balance after a monetary transaction: * overpaid, repaid in full, or reopened from a matured state. Mirrors * {@code DefaultLoanLifecycleStateMachine.determineAndTransition} in the core loan module. + *

+ * Closure is decided on the amount that is due as of {@code transactionDate}: the outstanding principal + * plus any fee/penalty charge whose due date has arrived. A not-yet-due (in advance) charge does not block closure, + * while a due-but-unpaid fee/penalty keeps the loan open even once the principal is settled. */ - public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDate transactionDate) { + public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDate transactionDate, + final List activeCharges) { if (loan.getBalance() == null) { return; } final LoanStatus currentStatus = loan.getLoanStatus(); final BigDecimal overpaymentAmount = MathUtil.nullToZero(loan.getBalance().getOverpaymentAmount()); - final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding()); + final BigDecimal dueOutstanding = dueOutstanding(loan, transactionDate, activeCharges); if (overpaymentAmount.compareTo(BigDecimal.ZERO) > 0) { if (currentStatus == null || !currentStatus.isOverpaid()) { transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan); @@ -61,19 +67,37 @@ public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDat if (loan.getMaturedOnDate() == null) { loan.setMaturedOnDate(transactionDate); } - } else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) { + } else if (dueOutstanding.compareTo(BigDecimal.ZERO) == 0) { if (currentStatus == null || !currentStatus.isClosedObligationsMet()) { transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan); } if (loan.getMaturedOnDate() == null) { loan.setMaturedOnDate(transactionDate); } - } else if (principalOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null) { + } else if (dueOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null + && canTransition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan)) { transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan); loan.setMaturedOnDate(null); } } + /** + * Outstanding amount that is due as of {@code asOf}: principal (always due for closure) plus every active + * fee/penalty charge whose due date is on or before {@code asOf}. In-advance charges are excluded so they do not + * keep an otherwise settled loan open. + */ + private BigDecimal dueOutstanding(final WorkingCapitalLoan loan, final LocalDate asOf, + final List activeCharges) { + final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding()); + if (activeCharges == null || activeCharges.isEmpty()) { + return principalOutstanding; + } + final BigDecimal dueChargeOutstanding = activeCharges.stream() + .filter(charge -> charge.getDueDate() != null && !charge.getDueDate().isAfter(asOf)) + .map(charge -> MathUtil.nullToZero(charge.getAmountOutstanding())).reduce(BigDecimal.ZERO, BigDecimal::add); + return principalOutstanding.add(dueChargeOutstanding); + } + private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) { LoanStatus from = loan.getLoanStatus(); if (from == null) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java index 2ae0e62b16c..73e1a0a1c47 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java @@ -36,6 +36,8 @@ import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.springframework.stereotype.Service; @Service @@ -108,6 +110,29 @@ public void validateCreateLoanCharge(final String json) { } + /** + * Validates a specified-due-date charge against the target loan: the due date is mandatory, cannot fall in the past + * and the loan must be active, closed (obligations met) or overpaid so a charge can be raised after the loan + * matured. Non specified-due-date charges carry no due date and are unaffected. + */ + public void validateCreateLoanChargeAgainstLoan(final LoanStatus loanStatus, final ChargeTimeType chargeTimeType, + final LocalDate dueDate, final LocalDate businessDate) { + if (chargeTimeType != ChargeTimeType.SPECIFIED_DUE_DATE) { + return; + } + if (dueDate == null) { + throw new PlatformApiDataValidationException("field.is.mandatory", "Field is mandatory", + WorkingCapitalLoanChargeConstants.dueDateParamName); + } + if (dueDate.isBefore(businessDate)) { + throw new PlatformApiDataValidationException("dueDate.cannot.be.in.the.past", "DueDate cannot be in the past", + WorkingCapitalLoanChargeConstants.dueDateParamName); + } + if (!(loanStatus.isActive() || loanStatus.isClosedObligationsMet() || loanStatus.isOverpaid())) { + throw new PlatformApiDataValidationException("loan.should.be.active", "Loan should be in active status", "workingCapitalLoan"); + } + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java index 80efdec9fc1..660026ed8c6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java @@ -21,21 +21,26 @@ import com.google.gson.JsonElement; import java.math.BigDecimal; +import java.math.MathContext; import java.time.LocalDate; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentPostBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentPreBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; @@ -47,9 +52,14 @@ import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; +import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest; 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.WorkingCapitalLoanEvent; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; @@ -85,18 +95,50 @@ public class WorkingCapitalLoanChargeWritePlatformServiceImpl implements Working private final BusinessEventNotifierService businessEventNotifierService; private final WorkingCapitalLoanAccountingProcessor accountingProcessor; private final WorkingCapitalLoanTransactionProcessor transactionProcessor; + private final WorkingCapitalLoanChargePaymentHandler chargePaymentHandler; + private final WorkingCapitalLoanBalanceUpdater balanceUpdater; + private final WorkingCapitalLoanLifecycleStateMachine stateMachine; + private final WorkingCapitalLoanAllocationRequestFactory allocationRequestFactory; + private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; + private final WorkingCapitalLoanDelinquencyRangeScheduleService delinquencyRangeScheduleService; + private final WorkingCapitalLoanBreachScheduleService breachScheduleService; + private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper; + @Transactional @Override public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command) { loanChargeDataValidator.validateCreateLoanCharge(command.json()); WorkingCapitalLoan loan = workingCapitalLoanRepository.findById(loanId) .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + final LoanStatus statusBeforeCharge = loan.getLoanStatus(); + WorkingCapitalLoanCharge loanCharge = assemblyChargeFromCommand(loan, command); loanCharge = loanChargeRepository.saveAndFlush(loanCharge); - addChargeToBalance(loan, loanCharge); + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()) + .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); + addChargeToBalance(balance, loanCharge); + + // A specified-due-date charge added after the loan matured (closed / overpaid / active-past-maturity) creates a + // new outstanding obligation: consume any overpayment against it, then let the resulting balance drive the + // status (reopen / stay overpaid / close), the new maturity date and the delinquency / breach schedules. + final LocalDate chargeDueDate = loanCharge.getDueDate(); + if (requiresChargeDrivenLifecycle(loan, statusBeforeCharge, chargeDueDate)) { + if (statusBeforeCharge.isOverpaid()) { + consumeOverpaymentAgainstCharges(loan, balance); + } + applyChargeDrivenLifecycle(loan, balance, statusBeforeCharge, chargeDueDate); + } + + balanceRepository.saveAndFlush(balance); + workingCapitalLoanRepository.saveAndFlush(loan); + + final Map changes = new LinkedHashMap<>(); + if (loan.getLoanStatus() != statusBeforeCharge) { + changes.put("status", loan.getLoanStatus()); + } return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // @@ -105,9 +147,91 @@ public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command .withOfficeId(loan.getOfficeId()) // .withClientId(loan.getClientId()) // .withLoanId(loanId) // + .with(changes) // .build(); } + private boolean requiresChargeDrivenLifecycle(final WorkingCapitalLoan loan, final LoanStatus statusBeforeCharge, + final LocalDate chargeDueDate) { + if (chargeDueDate == null) { + return false; + } + if (statusBeforeCharge.isClosedObligationsMet() || statusBeforeCharge.isOverpaid()) { + return true; + } + if (statusBeforeCharge.isActive()) { + final LocalDate scheduledMaturityDate = resolveScheduledMaturityDate(loan); + return scheduledMaturityDate != null && chargeDueDate.isAfter(scheduledMaturityDate); + } + return false; + } + + private LocalDate resolveScheduledMaturityDate(final WorkingCapitalLoan loan) { + final MathContext mc = MoneyHelper.getMathContext(); + return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan)) + .map(ProjectedAmortizationScheduleModel::scheduledMaturityDate).orElse(null); + } + + private void consumeOverpaymentAgainstCharges(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance) { + final BigDecimal availableOverpayment = MathUtil.nullToZero(balance.getOverpaymentAmount()); + if (!MathUtil.isGreaterThanZero(availableOverpayment)) { + return; + } + final List charges = loanChargeRepository.findByLoanIdAndActiveTrueOrderByDueDateAscIdAsc(loan.getId()); + final LocalDate businessDate = ThreadLocalContextUtil.getBusinessDate(); + final WorkingCapitalLoanAllocationRequest request = allocationRequestFactory.build(loan, balance, charges, businessDate, + availableOverpayment, LoanTransactionType.REPAYMENT); + final WorkingCapitalLoanAllocationPlan plan = allocationProcessor.plan(request); + + final Map chargesById = charges.stream() + .collect(Collectors.toMap(WorkingCapitalLoanCharge::getId, Function.identity())); + for (final WorkingCapitalLoanAllocationPlan.ChargeAllocation chargeAllocation : plan.chargeAllocations()) { + final WorkingCapitalLoanCharge charge = chargesById.get(chargeAllocation.chargeId()); + if (charge != null) { + chargePaymentHandler.applyChargePayment(charge, chargeAllocation.amount()); + } + } + loanChargeRepository.saveAll(charges); + + balance.setOverpaymentAmount(BigDecimal.ZERO); + balanceUpdater.apply(balance, plan); + } + + private void applyChargeDrivenLifecycle(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance, + final LoanStatus statusBeforeCharge, final LocalDate chargeDueDate) { + final BigDecimal overpayment = MathUtil.nullToZero(balance.getOverpaymentAmount()); + if (MathUtil.isGreaterThanZero(overpayment)) { + // The overpayment still exceeds the charges: the loan stays overpaid, nothing becomes outstanding. + return; + } + + if (MathUtil.isGreaterThanZero(balance.getTotalOutstanding())) { + // An outstanding obligation appeared: a closed / overpaid loan reopens; an active loan stays active. + if (statusBeforeCharge.isClosedObligationsMet() || statusBeforeCharge.isOverpaid()) { + stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan); + } + loan.setMaturedOnDate(chargeDueDate); + generateDelinquencyAndBreachPeriods(loan, chargeDueDate); + } else if (statusBeforeCharge.isOverpaid()) { + // The overpayment exactly settled the charge: the loan closes with obligations met. + stateMachine.transition(WorkingCapitalLoanEvent.LOAN_CREDIT_BALANCE_REFUND_IN_FULL, loan); + loan.setMaturedOnDate(chargeDueDate); + } + } + + private void generateDelinquencyAndBreachPeriods(final WorkingCapitalLoan loan, final LocalDate chargeDueDate) { + if (delinquencyRangeScheduleService.hasSchedule(loan.getId())) { + delinquencyRangeScheduleService.generateNextPeriodIfNeeded(loan, chargeDueDate); + } else { + delinquencyRangeScheduleService.generateInitialPeriod(loan); + } + if (breachScheduleService.hasSchedule(loan.getId())) { + breachScheduleService.generateNextPeriodIfNeeded(loan, chargeDueDate); + } else { + breachScheduleService.generateInitialPeriod(loan); + } + } + @Transactional @Override public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final Long wcLoanChargeId, final JsonCommand command) { @@ -236,28 +360,13 @@ private WorkingCapitalLoanCharge assemblyChargeFromCommand(WorkingCapitalLoan lo final ExternalId externalId = externalIdFactory.createFromCommand(command, WorkingCapitalLoanConstants.externalIdParameterName); final Charge chargeDefinition = chargeRepository.findOneWithNotFoundDetection(chargeId); - if (ChargeTimeType.SPECIFIED_DUE_DATE.getValue().equals(chargeDefinition.getChargeTimeType())) { - if (dueDate == null) { - throw new PlatformApiDataValidationException("field.is.mandatory", "Field is mandatory", - WorkingCapitalLoanChargeConstants.dueDateParamName); - } - if (dueDate.isBefore(ThreadLocalContextUtil.getBusinessDate())) { - throw new PlatformApiDataValidationException("dueDate.cannot.be.in.the.past", "DueDate cannot be in the past", - WorkingCapitalLoanChargeConstants.dueDateParamName); - } - if (!loan.getLoanStatus().isActive()) { - throw new PlatformApiDataValidationException("loan.should.be.active", "Loan should be in active status", - "workingCapitalLoan"); - } - } + loanChargeDataValidator.validateCreateLoanChargeAgainstLoan(loan.getLoanStatus(), + ChargeTimeType.fromInt(chargeDefinition.getChargeTimeType()), dueDate, ThreadLocalContextUtil.getBusinessDate()); return WorkingCapitalLoanCharge.build(loan, externalId, chargeDefinition, amount, dueDate, ThreadLocalContextUtil.getBusinessDate()); } - private void addChargeToBalance(WorkingCapitalLoan loan, WorkingCapitalLoanCharge loanCharge) { - final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()) - .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - + private void addChargeToBalance(final WorkingCapitalLoanBalance balance, final WorkingCapitalLoanCharge loanCharge) { if (loanCharge.isPenaltyCharge()) { balance.setPenalty(balance.getPenalty().add(loanCharge.getAmount())); } else { 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 db17bf598ba..47ad649bd3a 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 @@ -99,7 +99,7 @@ public WorkingCapitalLoanTransactionAllocation processRepaymentLikeTransaction(f // Breach schedule is maintained incrementally here; reprocessing does not rebuild it. breachScheduleService.applyRepayment(loanId, transactionDate, transactionAmount); - stateMachine.determineAndTransition(loan, transactionDate); + stateMachine.determineAndTransition(loan, transactionDate, charges); triggerInlineAmortizationIfLoanClosed(loan, transactionDate); return allocation; 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 ba96046ca8e..47625690544 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 @@ -587,7 +587,8 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); updateBalanceForDiscountChange(loan, amount, true); - stateMachine.determineAndTransition(loan, transactionDate); + final List activeCharges = chargeRepository.findByLoanIdAndActiveTrueOrderByDueDateAscIdAsc(loan.getId()); + stateMachine.determineAndTransition(loan, transactionDate, activeCharges); transactionProcessor.triggerInlineAmortizationIfLoanClosed(loan, transactionDate); changes.put("status", loan.getLoanStatus()); @@ -915,7 +916,7 @@ public CommandProcessingResult undoTransaction(final WorkingCapitalLoan loan, fi accountingProcessor.postReversalJournalEntries(loan, transaction); } - stateMachine.determineAndTransition(loan, transaction.getReversedOnDate()); + stateMachine.determineAndTransition(loan, transaction.getReversedOnDate(), charges); changes.put("status", loan.getLoanStatus()); handleNote(loan, command, changes); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java index ce9627435e2..6289f24bf3f 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java @@ -51,6 +51,7 @@ import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRateRequest; import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.client.models.WorkingCapitalLoanChargeData; +import org.apache.fineract.client.models.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.client.models.WorkingCapitalLoanPeriodPaymentRateChangeData; public class FeignWorkingCapitalLoanHelper { @@ -178,6 +179,10 @@ public void undoLoanTransaction(Long loanId, Long transactionId, ExecuteWorkingC transactionId, "undo", request)); } + public List getDelinquencyRangeSchedule(Long loanId) { + return ok(() -> fineractClient.workingCapitalLoanDelinquencyRangeSchedule().retrieveDelinquencyRangeSchedule(loanId)); + } + /** * Best-effort cleanup: attempts undo disbursal, undo approval, and delete in sequence. Each step is independent — * failures are silently ignored so the remaining steps still run. diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAfterMaturityTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAfterMaturityTest.java new file mode 100644 index 00000000000..9160f67cd71 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAfterMaturityTest.java @@ -0,0 +1,502 @@ +/** + * 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.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.assertEqualBigDecimal; +import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.findCharge; +import static org.junit.jupiter.api.Assertions.assertEquals; +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 org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.DelinquencyRangeRequest; +import org.apache.fineract.client.models.GetBalance; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.PostDelinquencyBucketResponse; +import org.apache.fineract.client.models.PostDelinquencyRangeResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest.AccountingRuleEnum; +import org.apache.fineract.client.models.WorkingCapitalLoanChargeData; +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.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.products.DelinquencyRangesHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDelinquencyRangeScheduleHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanbreach.WorkingCapitalBreachHelper; +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; + +/** + * Adding charges after the maturity date on closed, overpaid, or past-maturity Working-Capital loans. + * + *

+ * Adding a charge to such a loan must be allowed and, depending on how the charge compares to any existing overpayment, + * must reopen the loan to ACTIVE, close it (obligations met), or leave it OVERPAID, updating the maturity date and + * generating delinquency/breach periods when a balance becomes outstanding. + * + *

+ * The charge-add is wrapped so a server rejection surfaces as an {@link AssertionError} ("adding a charge must be + * allowed on a closed/overpaid/past-maturity loan"), never as a raw transport exception. + */ +public class FeignWorkingCapitalLoanChargeAfterMaturityTest extends FeignIntegrationTest { + + private static final String STATUS_ACTIVE = "loanStatusType.active"; + private static final String STATUS_CLOSED_OBLIGATIONS_MET = "loanStatusType.closed.obligations.met"; + private static final String STATUS_OVERPAID = "loanStatusType.overpaid"; + + private static final BigDecimal PRINCIPAL = BigDecimal.valueOf(1000); + private static final double CHARGE_AMOUNT = 100; + + // Delinquency configuration so a period can be generated once a charge creates an outstanding balance. + private static final int DELINQUENCY_FREQUENCY_DAYS = 20; + private static final int DELINQUENCY_GRACE_DAYS = 3; + private static final BigDecimal DELINQUENCY_MIN_PAYMENT_PERCENT = new BigDecimal("3"); + + // Breach configuration so a breach period can be generated once a charge creates an outstanding balance. + private static final int BREACH_FREQUENCY = 1; + private static final String BREACH_FREQUENCY_TYPE = "MONTHS"; + private static final String BREACH_AMOUNT_CALCULATION_TYPE = "FLAT"; + private static final BigDecimal BREACH_MIN_PAYMENT_AMOUNT = new BigDecimal("500"); + + private FeignWorkingCapitalLoanHelper wcLoanHelper; + private FeignClientHelper clientHelper; + private FeignBusinessDateHelper businessDateHelper; + private WorkingCapitalLoanProductHelper productHelper; + private WorkingCapitalBreachHelper breachHelper; + + // GL accounts (only needed for the accrual-with-deferred-revenue product used by the accounting test). + 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); + productHelper = new WorkingCapitalLoanProductHelper(); + breachHelper = new WorkingCapitalBreachHelper(); + + final FeignAccountHelper accountHelper = new FeignAccountHelper(feignClient); + fundSourceAccount = accountHelper.createLiabilityAccount("wcChgMatFundSrc"); + loanPortfolioAccount = accountHelper.createAssetAccount("wcChgMatLoanPort"); + transfersSuspenseAccount = accountHelper.createAssetAccount("wcChgMatXferSusp"); + incomeFromDiscountFeeAccount = accountHelper.createIncomeAccount("wcChgMatIncDisc"); + feesReceivableAccount = accountHelper.createAssetAccount("wcChgMatFeesRcv"); + penaltiesReceivableAccount = accountHelper.createAssetAccount("wcChgMatPenRcv"); + incomeFromFeeAccount = accountHelper.createIncomeAccount("wcChgMatIncFee"); + incomeFromPenaltyAccount = accountHelper.createIncomeAccount("wcChgMatIncPen"); + incomeFromRecoveryAccount = accountHelper.createIncomeAccount("wcChgMatIncRec"); + writeOffAccount = accountHelper.createExpenseAccount("wcChgMatWrtOff"); + overpaymentAccount = accountHelper.createLiabilityAccount("wcChgMatOverpay"); + deferredIncomeAccount = accountHelper.createLiabilityAccount("wcChgMatDefInc"); + } + + @AfterAll + void cleanupEntities() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + createdProductIds.clear(); + } + + @Test + @DisplayName("charge on CLOSED loan reopens to ACTIVE with fee outstanding, new maturity, 1 delinquency + 1 breach period") + void chargeOnClosedLoan_reopensToActive() { + businessDateHelper.runAt("2026-01-01", () -> { + final Long clientId = clientHelper.createClient("01 January 2026"); + final Long loanId = createDisburseAndCloseLoan(clientId, "01 January 2026"); + + assertStatus(loanId, STATUS_CLOSED_OBLIGATIONS_MET, "loan should be closed before adding the charge"); + final int delinquencyBefore = wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(); + final int breachBefore = wcLoanHelper.getBreachSchedule(loanId).size(); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-06-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 June 2026", "closed loan"); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_ACTIVE, loan.getStatus().getCode(), "closed loan must reopen to ACTIVE once a charge creates outstanding"); + assertEquals(LocalDate.of(2026, 6, 1), loan.getTimeline().getActualMaturityDate(), + "new maturity date must equal the charge due date"); + + final GetBalance balance = balanceOf(loan); + assertEqualBigDecimal(BigDecimal.valueOf(CHARGE_AMOUNT), balance.getFeeOutstanding(), "fee outstanding must equal the charge"); + assertEqualBigDecimal(BigDecimal.valueOf(CHARGE_AMOUNT), balance.getTotalOutstanding(), + "total outstanding must equal the charge amount"); + + assertEquals(delinquencyBefore + 1, wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(), + "exactly one new delinquency period must be created from the charge due date"); + assertEquals(breachBefore + 1, wcLoanHelper.getBreachSchedule(loanId).size(), + "exactly one new breach period must be created from the charge due date"); + }); + } + + @Test + @DisplayName("charge on OVERPAID loan (overpaid 150 > charge 100) stays OVERPAID; overpayment 50, no fee outstanding") + void chargeOnOverpaidLoan_overpaidGreaterThanCharge_staysOverpaid() { + businessDateHelper.runAt("2026-02-01", () -> { + final Long clientId = clientHelper.createClient("01 February 2026"); + final Long loanId = createDisburseAndOverpayLoan(clientId, "01 February 2026", BigDecimal.valueOf(1150)); + + assertStatus(loanId, STATUS_OVERPAID, "loan should be overpaid before adding the charge"); + final int delinquencyBefore = wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(); + final int breachBefore = wcLoanHelper.getBreachSchedule(loanId).size(); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-07-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 July 2026", "overpaid > charge"); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_OVERPAID, loan.getStatus().getCode(), "loan must stay OVERPAID when overpayment exceeds the charge"); + + final GetBalance balance = balanceOf(loan); + assertEqualBigDecimal(BigDecimal.valueOf(50), balance.getOverpaymentAmount(), + "overpayment must be reduced by the charge (150 - 100)"); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getFeeOutstanding(), + "the charge is fully covered by overpayment: no fee outstanding"); + + assertEquals(delinquencyBefore, wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(), + "no new delinquency period when nothing becomes outstanding"); + assertEquals(breachBefore, wcLoanHelper.getBreachSchedule(loanId).size(), + "no new breach period when nothing becomes outstanding"); + }); + } + + @Test + @DisplayName("charge on OVERPAID loan (overpaid 100 == charge 100) closes it; overpayment 0, totalOutstanding 0, maturity set") + void chargeOnOverpaidLoan_overpaidEqualsCharge_closes() { + businessDateHelper.runAt("2026-03-01", () -> { + final Long clientId = clientHelper.createClient("01 March 2026"); + final Long loanId = createDisburseAndOverpayLoan(clientId, "01 March 2026", BigDecimal.valueOf(1100)); + + assertStatus(loanId, STATUS_OVERPAID, "loan should be overpaid before adding the charge"); + final int delinquencyBefore = wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(); + final int breachBefore = wcLoanHelper.getBreachSchedule(loanId).size(); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 August 2026", "overpaid == charge"); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_CLOSED_OBLIGATIONS_MET, loan.getStatus().getCode(), + "loan must close (obligations met) when overpayment exactly matches the charge"); + assertEquals(LocalDate.of(2026, 8, 1), loan.getTimeline().getActualMaturityDate(), + "maturity date must be set to the charge due date on close"); + + final GetBalance balance = balanceOf(loan); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getOverpaymentAmount(), "overpayment must be fully consumed (100 - 100)"); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getTotalOutstanding(), "nothing outstanding once the charge is covered exactly"); + + assertEquals(delinquencyBefore, wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(), + "no new delinquency period when the loan closes with zero outstanding"); + assertEquals(breachBefore, wcLoanHelper.getBreachSchedule(loanId).size(), + "no new breach period when the loan closes with zero outstanding"); + }); + } + + @Test + @DisplayName("charge on OVERPAID loan (overpaid 40 < charge 100) reopens to ACTIVE; overpayment 0, fee 60, maturity set") + void chargeOnOverpaidLoan_overpaidLessThanCharge_reopensToActive() { + businessDateHelper.runAt("2026-04-01", () -> { + final Long clientId = clientHelper.createClient("01 April 2026"); + final Long loanId = createDisburseAndOverpayLoan(clientId, "01 April 2026", BigDecimal.valueOf(1040)); + + assertStatus(loanId, STATUS_OVERPAID, "loan should be overpaid before adding the charge"); + final int delinquencyBefore = wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(); + final int breachBefore = wcLoanHelper.getBreachSchedule(loanId).size(); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-09-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 September 2026", "overpaid < charge"); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_ACTIVE, loan.getStatus().getCode(), "loan must reopen to ACTIVE when the charge exceeds the overpayment"); + assertEquals(LocalDate.of(2026, 9, 1), loan.getTimeline().getActualMaturityDate(), + "new maturity date must equal the charge due date"); + + final GetBalance balance = balanceOf(loan); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getOverpaymentAmount(), "overpayment must be fully consumed by the charge"); + assertEqualBigDecimal(BigDecimal.valueOf(60), balance.getFeeOutstanding(), + "residual fee outstanding must be charge minus overpayment (100 - 40)"); + + assertEquals(delinquencyBefore + 1, wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(), + "exactly one new delinquency period must be created from the charge due date"); + assertEquals(breachBefore + 1, wcLoanHelper.getBreachSchedule(loanId).size(), + "exactly one new breach period must be created from the charge due date"); + }); + } + + @Test + @DisplayName("charge after maturity on still-ACTIVE loan updates maturity to charge due date and creates delinquency/breach") + void chargeAfterMaturity_onActiveLoan_updatesMaturityAndSchedules() { + businessDateHelper.runAt("2026-05-01", () -> { + final Long clientId = clientHelper.createClient("01 May 2026"); + final Long productId = createDefaultProduct(); + final Long loanId = submitApproveDisburse(clientId, productId, "01 May 2026"); + + assertStatus(loanId, STATUS_ACTIVE, "loan should be active (no repayment) before adding the charge"); + final int delinquencyBefore = wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(); + final int breachBefore = wcLoanHelper.getBreachSchedule(loanId).size(); + + // Advance well past the original maturity and add a charge dated at the (past-maturity) business date. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-10-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 October 2026", "active past maturity"); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_ACTIVE, loan.getStatus().getCode(), "past-maturity active loan must stay ACTIVE after the charge"); + assertEquals(LocalDate.of(2026, 10, 1), loan.getTimeline().getActualMaturityDate(), + "maturity date must be updated to the charge due date (N+1 term)"); + + assertEquals(delinquencyBefore + 1, wcLoanHelper.getDelinquencyRangeSchedule(loanId).size(), + "a delinquency period must be created covering the charge due date"); + assertEquals(breachBefore + 1, wcLoanHelper.getBreachSchedule(loanId).size(), + "a breach period must be created covering the charge due date"); + }); + } + + @Test + @DisplayName("charge-add on accrual product reopens the loan and posts no spurious transaction/journal entry at add-time") + void chargeAdd_onAccrualProduct_postsNoSpuriousJournalEntryAtAddTime() { + businessDateHelper.runAt("2026-06-01", () -> { + final Long clientId = clientHelper.createClient("01 June 2026"); + final Long productId = createAccrualWithDeferredRevenueProduct(); + final Long loanId = submitApproveDisburse(clientId, productId, "01 June 2026"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(PRINCIPAL, "01 June 2026")); + + assertStatus(loanId, STATUS_CLOSED_OBLIGATIONS_MET, "accrual-product loan should be closed before adding the charge"); + final int transactionsBefore = wcLoanHelper.getTransactions(loanId).size(); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-11-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 November 2026", "accounting on charge add"); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_ACTIVE, loan.getStatus().getCode(), "the accrual-product loan must reopen to ACTIVE on charge add"); + + final List transactionsAfter = wcLoanHelper.getTransactions(loanId); + assertEquals(transactionsBefore, transactionsAfter.size(), + "adding a charge must not post any spurious transaction/journal entry at add-time (accrual is deferred to COB)"); + }); + } + + @Test + @DisplayName("partial fee repayment on a charge-reopened loan stays ACTIVE with fee still outstanding (not re-closed)") + void chargeReopenedLoan_thenPartialFeeRepayment_staysActive() { + businessDateHelper.runAt("2026-07-01", () -> { + final Long clientId = clientHelper.createClient("01 July 2026"); + final Long loanId = createDisburseAndCloseLoan(clientId, "01 July 2026"); + + assertStatus(loanId, STATUS_CLOSED_OBLIGATIONS_MET, "loan should be closed before the charge reopens it"); + + // The charge reopens the CLOSED loan to ACTIVE with a 100 fee outstanding and 0 principal outstanding. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-12-01"); + addChargeExpectingAllowed(loanId, false, CHARGE_AMOUNT, "01 December 2026", "reopen closed loan"); + + final GetWorkingCapitalLoansLoanIdResponse reopened = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_ACTIVE, reopened.getStatus().getCode(), "the charge must reopen the closed loan to ACTIVE"); + final GetBalance reopenedBalance = balanceOf(reopened); + assertEqualBigDecimal(BigDecimal.valueOf(CHARGE_AMOUNT), reopenedBalance.getFeeOutstanding(), + "fee outstanding must equal the charge (100) right after the reopen"); + assertEqualBigDecimal(BigDecimal.ZERO, reopenedBalance.getPrincipalOutstanding(), + "principal outstanding must be zero after the reopen (only the fee is due)"); + + // A partial repayment of 40 against the fee leaves 60 of the fee outstanding; with principal already 0 the + // loan must stay ACTIVE rather than re-close. + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(40), "01 December 2026")); + + final GetWorkingCapitalLoansLoanIdResponse afterPartial = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_ACTIVE, afterPartial.getStatus().getCode(), + "loan must stay ACTIVE while 60 of the fee remains outstanding after a partial fee repayment (not re-close)"); + final GetBalance afterBalance = balanceOf(afterPartial); + assertEqualBigDecimal(BigDecimal.valueOf(60), afterBalance.getFeeOutstanding(), + "fee outstanding must be 60 (100 - 40) after the partial fee repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(60), afterBalance.getTotalOutstanding(), + "total outstanding must be 60 after the partial fee repayment"); + }); + } + + @Test + @DisplayName("an overpayment consumed by a charge survives reprocessing triggered by a backdated repayment") + void chargeConsumedOverpayment_thenReprocessingTriggered_consumptionSurvives() { + businessDateHelper.runAt("2026-08-01", () -> { + final Long clientId = clientHelper.createClient("01 August 2026"); + final Long productId = createDefaultProduct(); + final Long loanId = submitApproveDisburse(clientId, productId, "01 August 2026"); + + // Overpay the loan by 150 via a repayment on day 10 (later than disbursement so it can be backdated + // before). + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1150), "10 August 2026")); + assertStatus(loanId, STATUS_OVERPAID, "loan should be overpaid by 150 before adding the charge"); + + // The charge of 100 consumes 100 of the 150 overpayment: loan stays OVERPAID with 50 left, fee settled. + // The charge is due on 20 August (after both the repayment date and the reprocessing business date) so that + // a + // later replay of the persisted repayments cannot itself settle this not-yet-due charge: only the + // overpayment + // consumption ever paid it, which is the state at risk when reprocessing runs. + final Long feeLoanChargeId = addChargeReturningId(loanId, CHARGE_AMOUNT, "20 August 2026", "charge consumes overpayment"); + + final GetWorkingCapitalLoansLoanIdResponse afterCharge = wcLoanHelper.getLoanDetails(loanId); + assertEquals(STATUS_OVERPAID, afterCharge.getStatus().getCode(), + "loan must stay OVERPAID after the charge consumes part of the overpayment (150 > 100)"); + assertEqualBigDecimal(BigDecimal.valueOf(50), balanceOf(afterCharge).getOverpaymentAmount(), + "overpayment must drop to 50 (150 - 100) once the charge consumes 100"); + assertEqualBigDecimal(BigDecimal.ZERO, balanceOf(afterCharge).getFeeOutstanding(), + "the charge must be fully settled by the overpayment right after it is added (fee outstanding 0)"); + assertTrue(findCharge(wcLoanHelper.getCharges(loanId), feeLoanChargeId).getPaid(), + "the charge must be flagged paid after being consumed by the overpayment"); + + // A backdated repayment (dated day 5, before the day-10 repayment) triggers reprocessTransactions. + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-08-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(50), "05 August 2026")); + + // The charge consumption must be durable across the reprocessing event: the charge stays settled. + final WorkingCapitalLoanChargeData chargeAfter = findCharge(wcLoanHelper.getCharges(loanId), feeLoanChargeId); + assertEqualBigDecimal(BigDecimal.ZERO, chargeAfter.getAmountOutstanding(), + "the charge must remain fully paid (0 outstanding) after reprocessing, not revert to unpaid"); + assertTrue(chargeAfter.getPaid(), "the charge must remain flagged paid after reprocessing, not revert to unpaid"); + + final GetBalance afterReprocess = balanceOf(wcLoanHelper.getLoanDetails(loanId)); + assertEqualBigDecimal(BigDecimal.ZERO, afterReprocess.getFeeOutstanding(), + "fee outstanding must stay 0 after reprocessing (the consumed charge must not be restored as due)"); + // Total repaid 1200 against 1000 principal + 100 fee => overpayment 100 once the consumption survives. + assertEqualBigDecimal(BigDecimal.valueOf(100), afterReprocess.getOverpaymentAmount(), + "overpayment must be 100 (1200 repaid - 1000 principal - 100 consumed fee), not restored to 200"); + }); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private void addChargeExpectingAllowed(Long loanId, boolean penalty, double amount, String dueDate, String scenarioContext) { + final Long chargeDefId = wcLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(penalty, amount)); + try { + wcLoanHelper.addCharge(loanId, WorkingCapitalLoanRequestBuilders.addCharge(chargeDefId, amount, dueDate)); + } catch (final CallFailedRuntimeException e) { + throw new AssertionError(scenarioContext + " — adding a charge on a closed/overpaid/past-maturity WC loan must be allowed, " + + "but the server rejected it: " + e.getMessage(), e); + } + } + + private Long addChargeReturningId(Long loanId, double amount, String dueDate, String scenarioContext) { + final Long chargeDefId = wcLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, amount)); + try { + return wcLoanHelper.addCharge(loanId, WorkingCapitalLoanRequestBuilders.addCharge(chargeDefId, amount, dueDate)); + } catch (final CallFailedRuntimeException e) { + throw new AssertionError(scenarioContext + " — adding a charge on a closed/overpaid/past-maturity WC loan must be allowed, " + + "but the server rejected it: " + e.getMessage(), e); + } + } + + private Long createDisburseAndCloseLoan(Long clientId, String date) { + final Long productId = createDefaultProduct(); + final Long loanId = submitApproveDisburse(clientId, productId, date); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(PRINCIPAL, date)); + return loanId; + } + + private Long createDisburseAndOverpayLoan(Long clientId, String date, BigDecimal repaymentAmount) { + final Long productId = createDefaultProduct(); + final Long loanId = submitApproveDisburse(clientId, productId, date); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(repaymentAmount, date)); + return loanId; + } + + private Long submitApproveDisburse(Long clientId, Long productId, String date) { + final Long loanId = wcLoanHelper.submitApplication( + WorkingCapitalLoanRequestBuilders.submitApplication(clientId, productId, PRINCIPAL, BigDecimal.valueOf(18), date, date)); + createdLoanIds.add(loanId); + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approve(date, PRINCIPAL, date)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburse(date, PRINCIPAL)); + return loanId; + } + + private Long createDefaultProduct() { + final String uniqueName = "WCL ChgAfterMat " + Utils.uniqueRandomStringGenerator("", 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + // A delinquency bucket and a breach are configured so that, once the charge creates an outstanding balance, the + // schedule services can generate the delinquency + breach periods the tests assert. + final PostDelinquencyRangeResponse range = DelinquencyRangesHelper.createRange(new DelinquencyRangeRequest() + .classification(Utils.randomStringGenerator("DLQ_R_", 10)).minimumAgeDays(1).maximumAgeDays(60).locale("en")); + final PostDelinquencyBucketResponse bucket = WorkingCapitalLoanDelinquencyRangeScheduleHelper + .createWorkingCapitalLoanDelinquencyBucket(List.of(range.getResourceId()), DELINQUENCY_FREQUENCY_DAYS, 0, + DELINQUENCY_MIN_PAYMENT_PERCENT, 1); + final Long breachId = breachHelper.create(breachHelper.createBreachRequest(Utils.randomStringGenerator("WC_BREACH_", 8), + BREACH_FREQUENCY, BREACH_FREQUENCY_TYPE, BREACH_AMOUNT_CALCULATION_TYPE, BREACH_MIN_PAYMENT_AMOUNT)); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withDelinquencyBucketId(bucket.getResourceId()) + .withDelinquencyGraceDays(DELINQUENCY_GRACE_DAYS).withBreachId(breachId).build()).getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createAccrualWithDeferredRevenueProduct() { + final String uniqueName = "WCL ChgAfterMatAcc " + Utils.uniqueRandomStringGenerator("", 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).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; + } + + private void assertStatus(Long loanId, String expectedCode, String message) { + assertEquals(expectedCode, wcLoanHelper.getLoanDetails(loanId).getStatus().getCode(), message); + } + + private GetBalance balanceOf(GetWorkingCapitalLoansLoanIdResponse loan) { + assertNotNull(loan.getBalance(), "balance should exist"); + return loan.getBalance(); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java index e0bde686f44..b44d08da6e2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java @@ -238,7 +238,10 @@ void testUndoOfChargeAdjustmentThatClosedTheLoanReactivatesIt() { Long client = clientHelper.createClient("01 May 2026"); loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(40), "01 May 2026", createProductWithChargeAdjustmentOverride()); - feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 40, "01 May 2026"); + // Fee is in advance (due after every business date exercised below) so it does not count as a due + // obligation: once the charge adjustment settles the principal the loan legitimately closes, which is the + // precondition this test needs before undoing the adjustment to prove the loan reactivates. + feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 40, "01 June 2026"); GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.getLoanDetails(loanIdHolder[0]); assertNotNull(loanDetails.getStatus());