diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index 5a67253bca8..400a6764295 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -2032,6 +2032,21 @@ public void addDiscountFeeAdjustmentWCLoanWithTransactionDate(final String adjus executeDiscountFeeAdjustmentById(getCreatedLoanId(), request); } + @Then("Adding Discount fee adjustment with {string} amount on transaction date {string} on Working Capital loan account for last discount results an error with the following data:") + public void addingDiscountFeeAdjustmentWCLoanResultsAnError(final String adjustmentAmount, final String transactionDate, + final DataTable table) { + final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_DISCOUNT_FEE_RESPONSE); + Assertions.assertNotNull(lastDiscountResponse); + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDiscountResponse.getResourceId()) + .transactionAmount(new BigDecimal(adjustmentAmount)).transactionDate(transactionDate); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionById(getCreatedLoanId(), "discountFeeAdjustment", request)); + verifyErrorResponse(exception, table); + } + @And("Admin loads discount fee transaction from Working Capital loan for adjustment") public void loadDiscountFeeTransactionFromLoanForAdjustment() { final GetWorkingCapitalLoanTransactionsResponse body = ok( @@ -3256,6 +3271,12 @@ public void closeWorkingCapitalLoanWithFullRepayment(final String transactionDat validateRepaymentResponse(response, totalOutstanding.doubleValue(), transactionDate, loanId); } + @Then("Admin closes the Working Capital loan with all obligations met with a full repayment on {string}") + public void closeObligationsMetWorkingCapitalLoanWithFullRepayment(final String transactionDate) { + closeWorkingCapitalLoanWithFullRepayment(transactionDate); + loanWCStatus("CLOSED_OBLIGATIONS_MET"); + } + @Then("Customer fails to make repayment on {string} with {double} EUR transaction amount outcomes with error message") public void repaymentWCLoanFailure(final String transactionDate, final double transactionAmount) { final Long loanId = getCreatedLoanId(); @@ -3268,12 +3289,6 @@ public void repaymentWCLoanFailure(final String transactionDate, final double tr assertThat(exception.getDeveloperMessage()).contains(errorMessage); } - @Then("Admin closes the Working Capital loan with all obligations met with a full repayment on {string}") - public void closeObligationsMetWorkingCapitalLoanWithFullRepayment(final String transactionDate) { - closeWorkingCapitalLoanWithFullRepayment(transactionDate); - loanWCStatus("CLOSED_OBLIGATIONS_MET"); - } - @Then("Customer makes credit balance refund on {string} with {double} transaction amount on Working Capital loan") public void makeWorkingCapitalLoanCreditBalanceRefund(final String transactionDate, final double transactionAmount) { final Long loanId = getCreatedLoanId(); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature index 20548757807..8b7594bc3e8 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature @@ -229,7 +229,7 @@ Feature: Working Capital Loan Charge-off And Admin sets the business date to "20 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating adding "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "20 January 2026" due date and 10.0 transaction amount results an error with the following data: - | httpCode | message | + | httpCode | message | | 403 | error.msg.wc.loan.is.charged.off | @TestRailId:C93934 @@ -243,7 +243,7 @@ Feature: Working Capital Loan Charge-off And Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount Then Working Capital loan status will be "ACTIVE" Then Initiating an undo of the charge-off on the Working Capital loan results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | error.msg.wc.loan.is.not.charged.off | @TestRailId:C93935 diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOffAccounting.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOffAccounting.feature index df559d10c71..e316eaa5434 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOffAccounting.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOffAccounting.feature @@ -20,7 +20,7 @@ Feature: Working Capital Charge-Off Accounting Entries | Type | Account code | Account name | Debit | Credit | | EXPENSE | 744007 | Credit Loss/Bad Debt | 9000.0 | | | ASSET | 112601 | Loans Receivable | | 9000.0 | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93942 Scenario: Verify Working Capital charge-off accounting - UC2: fraud expense @@ -46,7 +46,7 @@ Feature: Working Capital Charge-Off Accounting Entries | ASSET | 112601 | Loans Receivable | | 9000.0 | | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 9000.0 | | ASSET | 112601 | Loans Receivable | 9000.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93943 Scenario: Verify Working Capital charge-off accounting - UC3: fees and penalties @@ -87,7 +87,7 @@ Feature: Working Capital Charge-Off Accounting Entries | ASSET | 112601 | Loans Receivable | 9000.0 | | | ASSET | 112603 | Interest/Fee Receivable | 50.0 | | | ASSET | 112603 | Interest/Fee Receivable | 30.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93944 Scenario: Verify Working Capital charge-off accounting - UC4: undo charge-off reverses journal entries @@ -114,7 +114,7 @@ Feature: Working Capital Charge-Off Accounting Entries | ASSET | 112601 | Loans Receivable | | 9000.0 | | EXPENSE | 744007 | Credit Loss/Bad Debt | | 9000.0 | | ASSET | 112601 | Loans Receivable | 9000.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93945 Scenario: Verify Working Capital charge-off accounting - UC5: repayment after charge-off posts to recovery income @@ -142,7 +142,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 744008 | Recoveries | | 500.0 | | LIABILITY | 145023 | Suspense/Clearing account | | 500.0 | | INCOME | 744008 | Recoveries | 500.0 | | - Then Admin closes the Working Capital loan with a full repayment on "11 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "11 January 2026" @TestRailId:C93946 Scenario: Verify Working Capital charge-off accounting - UC6: amortization after charge-off credits charge-off expense @@ -161,13 +161,20 @@ Feature: Working Capital Charge-Off Accounting Entries And Admin sets the business date to "03 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId 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 | | - | EXPENSE | 744007 | Credit Loss/Bad Debt | | 9.61 | + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 | And Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "02 January 2026" which has the following Journal entries: | Type | Account code | Account name | Debit | Credit | | LIABILITY | 145023 | Suspense/Clearing account | 50.0 | | | INCOME | 744008 | Recoveries | | 50.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | # --- Undo transaction --- When Customer undo "1"th "Repayment" transaction made on "02 January 2026" on Working Capital loan Then Working Capital Loan Transactions tab has a reversed "REPAYMENT" transaction with date "02 January 2026" which has the following Journal entries: @@ -176,7 +183,14 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 744008 | Recoveries | | 50.0 | | LIABILITY | 145023 | Suspense/Clearing account | | 50.0 | | INCOME | 744008 | Recoveries | 50.0 | | - Then Admin closes the Working Capital loan with a full repayment on "03 January 2026" + 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 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | true | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 January 2026" @TestRailId:C93947 Scenario: Verify Working Capital charge-off accounting - UC7: goodwill credit after charge-off posts to recoveries @@ -212,7 +226,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 404008 | Fee Charge Off | | 50.0 | | INCOME | 744008 | Recoveries | 270.0 | | | INCOME | 744008 | Recoveries | 50.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93948 Scenario: Verify Working Capital charge-off accounting - UC8: payout refund after charge-off credits charge-off expense @@ -238,7 +252,7 @@ Feature: Working Capital Charge-Off Accounting Entries | EXPENSE | 744007 | Credit Loss/Bad Debt | | 500.0 | | LIABILITY | 145023 | Suspense/Clearing account | | 500.0 | | EXPENSE | 744007 | Credit Loss/Bad Debt | 500.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93949 Scenario: Verify Working Capital charge-off accounting - UC9: fee charge adjustment after charge-off @@ -268,7 +282,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 404008 | Fee Charge Off | | 50.0 | | INCOME | 404007 | Fee Income | | 50.0 | | INCOME | 404008 | Fee Charge Off | 50.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93950 Scenario: Verify Working Capital charge-off accounting - UC10: penalty charge adjustment after charge-off @@ -298,7 +312,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 404008 | Fee Charge Off | | 30.0 | | INCOME | 404007 | Fee Income | | 30.0 | | INCOME | 404008 | Fee Charge Off | 30.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93951 Scenario: Verify Working Capital charge-off accounting - UC11: payout refund after fraud charge-off credits fraud expense @@ -326,7 +340,7 @@ Feature: Working Capital Charge-Off Accounting Entries | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 400.0 | | LIABILITY | 145023 | Suspense/Clearing account | | 400.0 | | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 400.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93952 Scenario: Verify Working Capital charge-off accounting - UC12: fee charge adjustment spilling to principal after charge-off @@ -365,7 +379,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 404007 | Fee Income | | 100.0 | | INCOME | 404008 | Fee Charge Off | 50.0 | | | INCOME | 404008 | Fee Charge Off | 50.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93953 Scenario: Verify Working Capital charge-off accounting - UC13: repayment after charge-off with fees, penalties and overpayment @@ -405,7 +419,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 744008 | Recoveries | 50.0 | | | INCOME | 744008 | Recoveries | 30.0 | | | LIABILITY | 245000 | Other Credit Liability | 920.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93954 Scenario: Verify Working Capital charge-off accounting - UC14: amortization after fraud charge-off credits fraud expense @@ -430,13 +444,20 @@ Feature: Working Capital Charge-Off Accounting Entries And Admin sets the business date to "03 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId 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 | | - | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 9.61 | - Then Admin closes the Working Capital loan with a full repayment on "03 January 2026" + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 1000.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 January 2026" @TestRailId:C93955 - Scenario: Verify Working Capital charge-off accounting - UC15: amortization adjustment after charge-off debits charge-off expense + Scenario: Verify Working Capital charge-off accounting - UC15: discount fee adjustment on or after the charge-off date is rejected Given 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 | @@ -455,15 +476,19 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 404000 | Interest Income | | 9.61 | # --- Charge-off --- And Admin charges off the Working Capital loan on "08 January 2026" -# --- Discount fee adjustment --- - And Admin adds Discount fee adjustment with "500" amount on transaction date "08 January 2026" on Working Capital loan account for last discount - And Admin sets the business date to "09 January 2026" - And Admin runs inline COB job for Working Capital Loan by loanId - Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT" transaction with date "08 January 2026" which has the following Journal entries: - | Type | Account code | Account name | Debit | Credit | - | EXPENSE | 744007 | Credit Loss/Bad Debt | 4.47 | | - | LIABILITY | 240005 | Deferred Interest Revenue | | 4.47 | - Then Admin closes the Working Capital loan with a full repayment on "09 January 2026" + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 08 January 2026 | Charge-off | 9950.0 | 9950.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Discount Fee Amortization | 990.39 | | | | false | +# --- Discount fee adjustment dated on the charge-off date is rejected: charge-off is a terminal write-off, so only a + Then Adding Discount fee adjustment with "500" amount on transaction date "08 January 2026" on Working Capital loan account for last discount results an error with the following data: + | httpCode | message | + | 403 | error.msg.wc.loan.is.charged.off | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "08 January 2026" @TestRailId:C93956 Scenario: Verify Working Capital charge-off accounting - UC16: CBR after charge-off keeps regular overpayment accounting @@ -535,7 +560,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 744008 | Recoveries | 50.0 | | | INCOME | 744008 | Recoveries | 30.0 | | | LIABILITY | 245000 | Other Credit Liability | 1420.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93958 Scenario: Verify Working Capital charge-off accounting - UC18: fee charge adjustment with overpayment after charge-off @@ -614,7 +639,7 @@ Feature: Working Capital Charge-Off Accounting Entries | INCOME | 404008 | Fee Charge Off | 15.0 | | | INCOME | 404008 | Fee Charge Off | 25.0 | | | LIABILITY | 245000 | Other Credit Liability | 100.0 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93960 Scenario: Verify Working Capital charge-off accounting - UC20: partial repayment before charge-off writes off remaining principal only @@ -634,7 +659,7 @@ Feature: Working Capital Charge-Off Accounting Entries | Type | Account code | Account name | Debit | Credit | | EXPENSE | 744007 | Credit Loss/Bad Debt | 6000.0 | | | ASSET | 112601 | Loans Receivable | | 6000.0 | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93961 Scenario: Verify Working Capital charge-off accounting - UC21: future charge-off date is rejected (Negative) @@ -647,7 +672,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Charging off the Working Capital loan on "11 January 2026" results an error with the following data: | httpCode | errorMessage | | 400 | future.date | - And Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C93962 Scenario: Verify Working Capital charge-off accounting - UC22: second charge-off attempt is rejected (Negative) @@ -676,7 +701,7 @@ Feature: Working Capital Charge-Off Accounting Entries And Admin charges off the Working Capital loan on "10 January 2026" Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "10 January 2026" which has the following Journal entries: | Type | Account code | Account name | Debit | Credit | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C94033 Scenario: Verify Working Capital charge-off accounting - UC24: backdated repayment before charge-off keeps regular JE and restates charge-off @@ -723,7 +748,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94034 Scenario: Verify Working Capital charge-off accounting - UC25: backdated full repayment before charge-off reverses charge-off and lifts flag @@ -837,7 +862,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94036 Scenario: Verify Working Capital charge-off accounting - UC27: same-day repayment after charge-off posts to recoveries @@ -874,7 +899,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C94037 Scenario: Verify Working Capital charge-off accounting - UC28: undo earlier repayment after charge-off leaves charge-off amount unchanged @@ -963,7 +988,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94038 Scenario: Verify Working Capital charge-off accounting - UC29: backdated repayment before charge-off restates charge-off and keeps later recovery JE @@ -1033,7 +1058,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94039 Scenario: Verify Working Capital charge-off accounting - UC30: backdated repayment before charge-off with fee restates charge-off fee portion @@ -1092,7 +1117,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94040 Scenario: Verify Working Capital charge-off accounting - UC31: backdated goodwill credit before charge-off keeps regular JE and restates charge-off @@ -1139,7 +1164,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94041 Scenario: Verify Working Capital charge-off accounting - UC32: backdated payout refund before charge-off keeps regular JE and restates charge-off @@ -1186,7 +1211,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94042 Scenario: Verify Working Capital charge-off accounting - UC33: backdated full repayment before charge-off restates later recovery to overpayment @@ -1335,7 +1360,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94044 Scenario: Verify Working Capital charge-off accounting - UC35: two successive backdated repayments before charge-off restate charge-off twice @@ -1409,7 +1434,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94045 Scenario: Verify Working Capital charge-off accounting - UC36: backdated repayment before charge-off restates later full recovery into recovery plus overpayment @@ -1583,7 +1608,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94048 Scenario: Verify Working Capital charge-off accounting - UC39: undo charge-off is still allowed after a backdated repayment before charge-off @@ -1653,7 +1678,7 @@ Feature: Working Capital Charge-Off Accounting Entries | ASSET | 112601 | Loans Receivable | | 6000.0 | | EXPENSE | 744007 | Credit Loss/Bad Debt | | 6000.0 | | ASSET | 112601 | Loans Receivable | 6000.0 | | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94049 Scenario: Verify Working Capital charge-off accounting - UC40: undo goodwill credit before charge-off restates charge-off up @@ -1722,7 +1747,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94050 Scenario: Verify Working Capital charge-off accounting - UC41: undo payout refund before charge-off restates charge-off up @@ -1791,7 +1816,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94051 Scenario: Verify Working Capital charge-off accounting - UC42: backdated repayment before charge-off covers penalty only and leaves fee on charge-off @@ -1861,7 +1886,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94052 Scenario: Verify Working Capital charge-off accounting - UC43: fee charge adjustment before charge-off keeps receivable JE and after charge-off posts to charge-off income @@ -1930,7 +1955,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94053 Scenario: Verify Working Capital charge-off accounting - UC44: penalty charge adjustment before charge-off keeps receivable JE and after charge-off posts to charge-off income @@ -1999,7 +2024,7 @@ Feature: Working Capital Charge-Off Accounting Entries Then Working capital loan account has the correct data: | chargedOff | | true | - Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C94054 Scenario: Verify Working Capital charge-off accounting - UC45: backdated repayment before charge-off lifts flag and restates post-charge-off fee charge adjustment to regular JE @@ -2082,6 +2107,8 @@ Feature: Working Capital Charge-Off Accounting Entries | chargedOff | | false | Then Working Capital loan status will be "OVERPAID" + And Customer makes credit balance refund on "20 January 2026" with 50.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @TestRailId:C94055 Scenario: Verify Working Capital charge-off accounting - UC46: backdated discount fee adjustment before charge-off restates charge-off expense @@ -2101,24 +2128,314 @@ Feature: Working Capital Charge-Off Accounting Entries | Type | Account code | Account name | Debit | Credit | | LIABILITY | 240005 | Deferred Interest Revenue | 9.61 | | | INCOME | 404000 | Interest Income | | 9.61 | -# --- Charge-off --- +# --- Charge-off: the full unreleased discount (1000 - 9.61 = 990.39) moves to charge-off expense in one shot --- And Admin charges off the Working Capital loan on "08 January 2026" -# --- Backdated discount fee adjustment before charge-off --- + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 990.39 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 990.39 | And Admin adds Discount fee adjustment with "500" amount on transaction date "07 January 2026" on Working Capital loan account for last discount - And Admin sets the business date to "09 January 2026" - And Admin runs inline COB job for Working Capital Loan by loanId - Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT" transaction with date "08 January 2026" which has the following Journal entries: - | Type | Account code | Account name | Debit | Credit | - | EXPENSE | 744007 | Credit Loss/Bad Debt | 4.47 | | - | LIABILITY | 240005 | Deferred Interest Revenue | | 4.47 | - Then Admin closes the Working Capital loan with a full repayment on "09 January 2026" + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 990.39 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 990.39 | + | LIABILITY | 240005 | Deferred Interest Revenue | | 990.39 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 990.39 | | + | LIABILITY | 240005 | Deferred Interest Revenue | 490.39 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 490.39 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 07 January 2026 | Discount Fee Adjustment | 500.0 | 500.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Charge-off | 9450.0 | 9450.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Discount Fee Amortization | 490.39 | | | | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "08 January 2026" @TestRailId:C94065 - Scenario: Working Capital loan raises Fraud Changed business event when the fraud flag is set + Scenario: Working Capital loan raises Fraud Changed business event when the fraud flag is set - UC47 Given 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 | 9000 | 100000 | 18 | 0 | When Admin sets the fraud flag of the Working Capital loan to true Then a Working Capital Loan Fraud Changed business event is raised - And Admin closes the Working Capital loan with a full repayment on "01 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" + + @TestRailId:C99203 + Scenario: Verify Working Capital charge-off accounting: undo charge-off with discount fee reverses journal entries - UС48 + Given 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 | 9000 | 100000 | 18 | 1000 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Charge-off --- # + And Admin charges off the Working Capital loan on "10 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | +# --- Undo charge-off --- + When Admin sets the business date to "11 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin undoes the charge-off on the Working Capital loan + Then Working capital loan account has the correct data: + | chargedOff | + | false | + And Working Capital Loan Transactions tab has a reversed "CHARGE_OFF" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 10000.0 | + | ASSET | 112601 | Loans Receivable | 10000.0 | | + Then Working Capital Loan Transactions tab has a reversed "DISCOUNT_FEE_AMORTIZATION" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 | + | LIABILITY | 240005 | Deferred Interest Revenue | | 1000.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 1000.0 | | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | true | + | 10 January 2026 | Discount Fee Amortization | 1000.0 | | | | true | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "11 January 2026" + + @TestRailId:C98204 + Scenario: Verify Working Capital charge-off accounting: undo charge-off with discount fee reverses journal entries with fraud expense - UС49 + Given 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 | 9000 | 100000 | 18 | 1000 | +# --- Fraud flag --- # + When Admin sets the fraud flag of the Working Capital loan to true + And Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Charge-off --- # + And Admin charges off the Working Capital loan on "10 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 1000.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | +# --- Undo charge-off --- # + When Admin undoes the charge-off on the Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "CHARGE_OFF" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 10000.0 | + | ASSET | 112601 | Loans Receivable | 10000.0 | | + Then Working Capital Loan Transactions tab has a reversed "DISCOUNT_FEE_AMORTIZATION" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 1000.0 | + | LIABILITY | 240005 | Deferred Interest Revenue | | 1000.0 | + | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 1000.0 | | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | true | + | 10 January 2026 | Discount Fee Amortization | 1000.0 | | | | true | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + + @TestRailId:C98205 + Scenario: Verify Working Capital charge-off accounting: charge-off after discount fee adjustment - UC50 + Given 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 | 9000 | 100000 | 18 | 0 | + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement +# --- Discount fee adjustment --- # + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds Discount fee adjustment with "250" amount on transaction date "05 January 2026" on Working Capital loan account for last discount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 250.0 | 250.0 | 0.0 | 0.0 | false | + And Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Charge-off --- # + And Admin charges off the Working Capital loan on "08 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 9750.0 | | + | ASSET | 112601 | Loans Receivable | | 9750.0 | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 750.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 750.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 250.0 | 250.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Charge-off | 9750.0 | 9750.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Discount Fee Amortization | 750.0 | | | | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "08 January 2026" + + @TestRailId:C98206 + Scenario: Verify Working Capital charge-off accounting: charge-off after discount fee adjustment with following discount fee adj undo - UC51 + Given 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 | 9000 | 100000 | 18 | 0 | + And Admin adds Discount fee with "1000" amount on Working Capital loan account for last disbursement +# --- Discount fee adjustment --- # + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds Discount fee adjustment with "250" amount on transaction date "05 January 2026" on Working Capital loan account for last discount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 250.0 | 250.0 | 0.0 | 0.0 | false | + And Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Charge-off --- # + And Admin charges off the Working Capital loan on "08 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 9750.0 | | + | ASSET | 112601 | Loans Receivable | | 9750.0 | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 750.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 750.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 250.0 | 250.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Charge-off | 9750.0 | 9750.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Discount Fee Amortization | 750.0 | | | | false | +# --- undo last discount fee adjustment transaction --- # +# The loan is charged off, so undoing the adjustment reprocesses immediately: the charge-off transaction and its +# linked final discount-fee amortization are both replayed in place against the restored (fully undone) discount +# pool, rather than being left stranded at the smaller, adjusted amounts until some later transaction happens to +# catch them up. + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | + | 1000.0 | 10000.0 | + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 9750.0 | | + | ASSET | 112601 | Loans Receivable | | 9750.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 9750.0 | + | ASSET | 112601 | Loans Receivable | 9750.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "08 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 750.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 750.0 | + | LIABILITY | 240005 | Deferred Interest Revenue | | 750.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 750.0 | | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 250.0 | 250.0 | 0.0 | 0.0 | true | + | 08 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + And Admin sets the business date to "09 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "09 January 2026" + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 250.0 | 250.0 | 0.0 | 0.0 | true | + | 08 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 08 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 09 January 2026 | Repayment | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + + # TODO: generate testrail id + Scenario: Verify Working Capital charge-off accounting - UC52: backdated full repayment before charge-off with an active discount pool lifts charge-off and reverses the final discount fee amortization + Given 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 | 9000 | 100000 | 18 | 1000 | + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Charge-off --- # + And Admin charges off the Working Capital loan on "15 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + And Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Backdated full repayment before charge-off lifts the flag and must also reverse the final amortization --- # + And Customer makes repayment on "10 January 2026" with 10000.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + And Working Capital Loan Transactions tab has a reversed "CHARGE_OFF" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 10000.0 | + | ASSET | 112601 | Loans Receivable | 10000.0 | | + And Working Capital Loan Transactions tab has a reversed "DISCOUNT_FEE_AMORTIZATION" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 | + | LIABILITY | 240005 | Deferred Interest Revenue | | 1000.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 1000.0 | | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | | 1000.0 | + | LIABILITY | 240005 | Deferred Interest Revenue | 1000.0 | | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 15 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | true | + | 15 January 2026 | Discount Fee Amortization | 1000.0 | | | | true | + Then Working capital loan account has the correct data: + | chargedOff | + | false | + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java index d6873abafa6..7794afa7eff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java @@ -470,6 +470,47 @@ public void postJournalEntriesForDiscountFeeAmortization(final WorkingCapitalLoa } } + @Override + public void restateJournalEntriesForDiscountFeeAmortization(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn, + final boolean isChargedOff) { + final List effectiveEntries = effectiveJournalEntries(txn); + if (!discountFeeAmortizationSplitDiffersFromLedger(loan, txn, effectiveEntries, isChargedOff)) { + // The ledger already reflects the recomputed amount; re-posting would only add cancelling noise. + return; + } + reverseExistingEntries(loan, txn, true); + postJournalEntriesForDiscountFeeAmortization(loan, txn, isChargedOff); + } + + /** + * {@link #splitDiffersFromLedger}'s counterpart for the fixed debit/credit pair a discount-fee-amortization posts. + */ + private boolean discountFeeAmortizationSplitDiffersFromLedger(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn, + final List effectiveEntries, final boolean isChargedOff) { + if (effectiveEntries.isEmpty()) { + return true; + } + final Long productId = loan.getLoanProduct().getId(); + final BigDecimal amount = txn.getTransactionAmount(); + + final Map planned = new HashMap<>(); + if (MathUtil.isGreaterThanZero(amount)) { + final GLAccount deferredIncomeAccount = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, + CashAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), null); + final CashAccountsForLoan creditAccountType = resolveChargeOffExpenseAccount(loan, isChargedOff); + final GLAccount creditAccount = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, creditAccountType.getValue(), + null); + planned.merge(new PostingKey(deferredIncomeAccount.getId(), true), amount, BigDecimal::add); + planned.merge(new PostingKey(creditAccount.getId(), false), amount, BigDecimal::add); + } + + final Map posted = postedAmountsByPosition(effectiveEntries); + if (!planned.keySet().equals(posted.keySet())) { + return true; + } + return planned.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(posted.get(entry.getKey())) != 0); + } + @Override public void postJournalEntriesForDiscountFeeAmortizationAdjustment(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn, final boolean isChargedOff) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java index 4f5e5e11084..4f06ce66c69 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java @@ -41,6 +41,10 @@ public WorkingCapitalLoan execute(final WorkingCapitalLoan input) { log.debug("Skipping discount fee amortization for WC loan {} - no loan product details", input.getId()); return input; } + if (input.isChargedOff()) { + log.debug("Skipping discount fee amortization for WC loan {} - loan is charged off", input.getId()); + return input; + } // Run when there is still a discount to amortize, OR when income was previously recognized and now needs to be // reconciled down (e.g. a full discount adjustment reduced the discount to zero). Otherwise there is nothing to // do. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java index 8ccdf3b6f8f..309beba9bdc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java @@ -40,6 +40,14 @@ void restateJournalEntries(WorkingCapitalLoan loan, WorkingCapitalLoanTransactio void postJournalEntriesForDiscountFeeAmortization(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, boolean isChargedOff); + /** + * {@link #restateJournalEntries}'s counterpart for a discount-fee-amortization transaction: replaces its stale + * journal entries with a fresh set posted from its recomputed {@code transactionAmount} (a no-op if the ledger + * already reflects that amount). Used to replay the charge-off's final lump-sum amortization in place when a + * backdated discount-fee adjustment reprocess changes what it should have been. + */ + void restateJournalEntriesForDiscountFeeAmortization(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, boolean isChargedOff); + void postJournalEntriesForDiscountFeeAmortizationAdjustment(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, boolean isChargedOff); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java index 4b5b8009f44..f56881ed978 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java @@ -39,6 +39,13 @@ Optional findByToTransactionAndFromTransa List findAllByToTransactionAndFromTransactionReversedAndFromTransactionTransactionType( WorkingCapitalLoanTransaction relatedDisbursementTransaction, boolean reversed, LoanTransactionType transactionType); + /** + * Unfiltered by reversed state, unlike the sibling lookup above: used where a previously-reversed link must still + * be found again (e.g. reviving a final discount-fee amortization the discount pool no longer zeroes out). + */ + List findAllByToTransactionAndFromTransactionTransactionType( + WorkingCapitalLoanTransaction toTransaction, LoanTransactionType transactionType); + List findAllByToChargeAndFromTransactionReversedAndFromTransactionTransactionType( WorkingCapitalLoanCharge toCharge, boolean reversed, LoanTransactionType transactionType); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java index 85ebada71f1..a12c29ed697 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java @@ -76,6 +76,7 @@ public class WorkingCapitalLoanChargeOffWriteServiceImpl implements WorkingCapit private final ExternalIdFactory externalIdFactory; private final WorkingCapitalLoanAccountingProcessor accountingProcessor; private final BusinessEventNotifierService businessEventNotifierService; + private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; @Transactional @Override @@ -130,6 +131,8 @@ public CommandProcessingResult chargeOff(final Long loanId, final JsonCommand co this.accountingProcessor.postJournalEntries(loan, chargeOffTransaction, allocation, loan.isChargedOff()); } + this.discountFeeAmortizationService.processFinalDiscountFeeAmortizationOnChargeOff(loan, chargeOffTransaction); + final Map changes = new LinkedHashMap<>(); changes.put(WorkingCapitalLoanConstants.transactionDateParamName, transactionDate); if (chargeOffReasonId != null) { @@ -161,6 +164,8 @@ public CommandProcessingResult undoChargeOff(final Long loanId, final JsonComman .orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.wc.loan.charge.off.transaction.not.found", "No active charge-off transaction found for loan " + loanId, loanId)); + this.discountFeeAmortizationService.undoDiscountFeeAmortizationOnChargeOff(loan, chargeOffTransaction); + final ExternalId reversalExternalId = this.externalIdFactory .create(command.stringValueOfParameterNamedAllowingNull(WorkingCapitalLoanConstants.reversalExternalIdParamName)); chargeOffTransaction.setReversed(true); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java index a2708863afe..3d0b4da3e7c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationService.java @@ -20,11 +20,26 @@ import java.time.LocalDate; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; public interface WorkingCapitalLoanDiscountFeeAmortizationService { void processDiscountFeeAmortization(WorkingCapitalLoan loan, LocalDate transactionDate); + /** + * Recognizes the entire unreleased discount-fee deferred income balance as of charge-off in one shot, crediting the + * charge-off expense account instead of discount-fee income, and links the resulting transaction to + * {@code chargeOffTransaction} so it can be found and reversed on undo. No-op if there is nothing left to + * recognize. + */ + void processFinalDiscountFeeAmortizationOnChargeOff(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction chargeOffTransaction); + + /** + * Reverses the discount-fee amortization transaction (and its journal entries) created by + * {@link #processFinalDiscountFeeAmortizationOnChargeOff} for {@code chargeOffTransaction}, if any. + */ + void undoDiscountFeeAmortizationOnChargeOff(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction chargeOffTransaction); + /** * Recomputes {@code realizedIncomeFromDiscountFee} on the loan balance from the database aggregate of non-reversed * amortization transactions. Callers must flush any pending amortization transaction posts or reversals before 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 b69ba19fe81..33bd5490dad 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 @@ -23,10 +23,12 @@ import java.time.LocalDate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAmortizationAdjustmentTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAmortizationTransactionBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanTransactionReversedBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; @@ -37,6 +39,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionFinder; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelation; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,6 +50,7 @@ public class WorkingCapitalLoanDiscountFeeAmortizationServiceImpl implements WorkingCapitalLoanDiscountFeeAmortizationService { private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanTransactionRelationRepository transactionRelationRepository; private final WorkingCapitalLoanAccountingProcessor accountingProcessor; private final ExternalIdFactory externalIdFactory; private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper; @@ -118,6 +122,68 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final log.debug("Posted discount fee amortization of {} for WC loan [{}]", amortizationAmount, loan.getId()); } + @Override + @Transactional + public void processFinalDiscountFeeAmortizationOnChargeOff(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction chargeOffTransaction) { + // The amortization transaction (and the balance it feeds) is tracked regardless of accounting rule, matching + // the periodic amortization path; only the journal entry posting below is conditional on the accounting rule. + final BigDecimal unrealizedAmount = loan.getBalance() != null ? loan.getBalance().getUnrealizedIncomeFromDiscountFee() + : BigDecimal.ZERO; + if (!MathUtil.isGreaterThanZero(unrealizedAmount)) { + log.debug("Skipping final discount fee amortization for WC loan [{}] - nothing left to recognize", loan.getId()); + return; + } + + final WorkingCapitalLoanTransaction amortizationTxn = WorkingCapitalLoanTransaction.discountFeeAmortization(loan, unrealizedAmount, + chargeOffTransaction.getTransactionDate(), externalIdFactory.create()); + linkToChargeOffTransaction(amortizationTxn, chargeOffTransaction); + transactionRepository.saveAndFlush(amortizationTxn); + businessEventNotifierService.notifyPostBusinessEvent( + new WorkingCapitalLoanDiscountFeeAmortizationTransactionBusinessEvent(amortizationTxn, loan.getId())); + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, true); + } + + recalculateRealizedIncome(loan); + + log.debug("Posted final discount fee amortization of {} for WC loan [{}] on charge-off", unrealizedAmount, loan.getId()); + } + + @Override + @Transactional + public void undoDiscountFeeAmortizationOnChargeOff(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction chargeOffTransaction) { + final var linkedAmortizations = transactionRelationRepository + .findAllByToTransactionAndFromTransactionReversedAndFromTransactionTransactionType(chargeOffTransaction, false, + LoanTransactionType.DISCOUNT_FEE_AMORTIZATION); + if (linkedAmortizations.isEmpty()) { + return; + } + + for (final WorkingCapitalLoanTransactionRelation relation : linkedAmortizations) { + final WorkingCapitalLoanTransaction amortizationTxn = relation.getFromTransaction(); + amortizationTxn.setReversed(true); + amortizationTxn.setReversedOnDate(DateUtils.getBusinessLocalDate()); + transactionRepository.saveAndFlush(amortizationTxn); + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + accountingProcessor.postReversalJournalEntries(loan, amortizationTxn); + } + businessEventNotifierService + .notifyPostBusinessEvent(new WorkingCapitalLoanTransactionReversedBusinessEvent(amortizationTxn, loan.getId())); + } + + recalculateRealizedIncome(loan); + + log.debug("Reversed final discount fee amortization for WC loan [{}] on undo charge-off", loan.getId()); + } + + private void linkToChargeOffTransaction(final WorkingCapitalLoanTransaction amortizationTransaction, + final WorkingCapitalLoanTransaction chargeOffTransaction) { + amortizationTransaction.getLoanTransactionRelations().add(new WorkingCapitalLoanTransactionRelation(amortizationTransaction, + chargeOffTransaction, LoanTransactionRelationTypeEnum.RELATED)); + } + @Override @Transactional public void recalculateRealizedIncome(final WorkingCapitalLoan loan) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java index 4a89ed79ad3..3b5e1844e40 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java @@ -42,6 +42,8 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionComparator; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelation; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargePaidByRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; @@ -71,6 +73,8 @@ public class WorkingCapitalLoanTransactionReprocessingServiceImpl implements Wor private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; private final WorkingCapitalLoanAccountingProcessor accountingProcessor; private final BusinessEventNotifierService businessEventNotifierService; + private final WorkingCapitalLoanTransactionRelationRepository transactionRelationRepository; + private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; @Override public void reprocessTransactions(final WorkingCapitalLoan loan) { @@ -360,6 +364,11 @@ private boolean replayChargeOff(final WorkingCapitalLoan loan, final WorkingCapi final BigDecimal overpaymentPortion = MathUtil.nullToZero(balance.getOverpaymentAmount()); if (!MathUtil.isGreaterThanZero(chargeOffAmount)) { + // Mirrors the explicit undo-charge-off path: lifting the charge-off must also reverse its linked final + // discount-fee amortization, or that transaction's journal entries keep crediting the charge-off expense + // account on a loan that is no longer charged off. + discountFeeAmortizationService.undoDiscountFeeAmortizationOnChargeOff(loan, chargeOffTransaction); + chargeOffTransaction.setReversed(true); chargeOffTransaction.setReversedOnDate(DateUtils.getBusinessLocalDate()); transactionRepository.saveAndFlush(chargeOffTransaction); @@ -391,9 +400,79 @@ private boolean replayChargeOff(final WorkingCapitalLoan loan, final WorkingCapi if (accountingEnabled && (amountChanged || allocationChanged)) { accountingProcessor.restateJournalEntries(loan, chargeOffTransaction, storedAllocation, true); } + + restateFinalDiscountFeeAmortization(loan, balance, chargeOffTransaction, accountingEnabled); + return true; } + /** + * Replays the charge-off's final lump-sum discount-fee amortization (see + * {@code WorkingCapitalLoanDiscountFeeAmortizationServiceImpl#processFinalDiscountFeeAmortizationOnChargeOff}), in + * place, against the current discount pool. A no-op when nothing is linked (no discount was ever charged off) or + * the recomputed amount has not moved. + *

+ * The link is looked up regardless of the amortization's own reversed state: a prior replay can have zeroed the + * pool and reversed it (see below), and a later backdated change (e.g. undoing the discount-fee adjustment that + * zeroed it) can bring the pool back up, needing the same transaction revived rather than left behind for good. + *

+ */ + private void restateFinalDiscountFeeAmortization(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance, + final WorkingCapitalLoanTransaction chargeOffTransaction, final boolean accountingEnabled) { + final List linkedAmortizations = transactionRelationRepository + .findAllByToTransactionAndFromTransactionTransactionType(chargeOffTransaction, + LoanTransactionType.DISCOUNT_FEE_AMORTIZATION); + if (linkedAmortizations.isEmpty()) { + return; + } + + final BigDecimal netDiscountPool = MathUtil.subtract(MathUtil.nullToZero(balance.getTotalDiscountFee()), + MathUtil.nullToZero(balance.getTotalDiscountFeeAdjustment())); + final BigDecimal netAmortized = transactionRepository.sumNetAmortization(loan.getId(), + LoanTransactionType.DISCOUNT_FEE_AMORTIZATION, LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT); + + for (final WorkingCapitalLoanTransactionRelation relation : linkedAmortizations) { + final WorkingCapitalLoanTransaction amortizationTxn = relation.getFromTransaction(); + final boolean wasReversed = amortizationTxn.isReversed(); + // sumNetAmortization only counts non-reversed transactions, so a currently-reversed amortization + // contributes nothing to netAmortized already - only a live one's own (stale) amount needs excluding from + // what is "already realized elsewhere" to recompute the target as if the final push were happening now. + final BigDecimal ownLiveContribution = wasReversed ? BigDecimal.ZERO + : MathUtil.nullToZero(amortizationTxn.getTransactionAmount()); + final BigDecimal realizedElsewhere = MathUtil.subtract(netAmortized, ownLiveContribution); + final BigDecimal newAmount = MathUtil.subtract(netDiscountPool, realizedElsewhere).max(BigDecimal.ZERO); + + if (!MathUtil.isGreaterThanZero(newAmount)) { + if (!wasReversed) { + // The discount pool no longer leaves anything to push to expense: the final transaction becomes + // moot. + amortizationTxn.setReversed(true); + amortizationTxn.setReversedOnDate(DateUtils.getBusinessLocalDate()); + transactionRepository.saveAndFlush(amortizationTxn); + if (accountingEnabled) { + accountingProcessor.postReversalJournalEntries(loan, amortizationTxn); + } + } + continue; + } + + final boolean amountChanged = MathUtil.nullToZero(amortizationTxn.getTransactionAmount()).compareTo(newAmount) != 0; + if (wasReversed) { + // The pool moved back above zero: revive the transaction this same charge-off already created rather + // than leaving it behind reversed forever. + amortizationTxn.setReversed(false); + amortizationTxn.setReversedOnDate(null); + } + amortizationTxn.updateAmount(newAmount); + transactionRepository.save(amortizationTxn); + if (accountingEnabled && (amountChanged || wasReversed)) { + accountingProcessor.restateJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, true); + } + } + + discountFeeAmortizationService.recalculateRealizedIncome(loan); + } + private boolean chargeOffAllocationMatches(final WorkingCapitalLoanTransactionAllocation allocation, final BigDecimal principalPortion, final BigDecimal feePortion, final BigDecimal penaltyPortion, final BigDecimal overpaymentPortion) { return allocationPortionsEqual(allocation.getPrincipalPortion(), principalPortion) 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 156fce46652..9f8ff926a6c 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 @@ -38,6 +38,7 @@ 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.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -597,6 +598,16 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina .localDateValueOfParameterNamed(WorkingCapitalLoanConstants.transactionDateParamName); final LocalDate transactionDate = requestedTransactionDate != null ? requestedTransactionDate : relatedDiscountTransaction.getTransactionDate(); + // Charge-off is a terminal write-off: a discount-fee adjustment dated on or after the charge-off date is + // rejected. Only a backdated adjustment (before the charge-off date) is allowed, since it corrects something + // that happened before the loan was written off -- the charge-off's final amortization is restated below to + // account for it. + if (loan.isChargedOff() && loan.getChargedOffOnDate() != null && !transactionDate.isBefore(loan.getChargedOffOnDate())) { + throw new GeneralPlatformDomainRuleException("error.msg.wc.loan.is.charged.off", + "Discount fee adjustment on Working Capital Loan " + loanId + + " is not allowed on or after the charge-off date. The loan is charged off.", + loanId); + } validator.validateDiscountAdjustmentTransaction(loan, command.json(), amount, relatedDiscountTransaction, remainingDiscountAmount, transactionDate); final Long classificationId = command.longValueOfParameterNamed(WorkingCapitalLoanConstants.classificationIdParamName); @@ -632,6 +643,13 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); + // Backdated adjustment on an already charged-off loan (validated above to predate the charge-off): reprocess + // to replay the charge-off's final lump-sum amortization against the reduced discount pool, the same way a + // backdated repayment reprocess replays the charge-off transaction itself. + if (loan.isChargedOff()) { + transactionReprocessingService.reprocessTransactions(loan); + } + final LoanStatus oldStatus = loan.getLoanStatus(); stateMachine.determineAndTransition(loan, transactionDate); @@ -693,6 +711,13 @@ private CommandProcessingResult undoDiscountFeeAdjustment(final WorkingCapitalLo // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); + // Mirrors makeDiscountFeeAdjustment: undoing a backdated adjustment on an already charged-off loan must also + // reprocess, so the charge-off's final lump-sum amortization is replayed back up against the restored + // discount pool instead of being left stranded at the smaller, adjusted amount. + if (loan.isChargedOff()) { + transactionReprocessingService.reprocessTransactions(loan); + } + // Restoring the outstanding can reopen a loan the adjustment had closed, so re-run the status transition. final LocalDate reversedOnDate = adjustmentTransaction.getReversedOnDate(); final LoanStatus oldStatus = loan.getLoanStatus(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java index b3ae6e5cc0f..314d4e868b2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java @@ -19,36 +19,34 @@ package org.apache.fineract.integrationtests; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; import java.time.LocalDate; -import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; -import java.util.concurrent.atomic.AtomicLong; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; 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.client.models.PostWorkingCapitalLoansRequest; +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.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.BusinessDateHelper; import org.apache.fineract.integrationtests.common.ClientHelper; import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; import org.apache.fineract.integrationtests.common.accounting.Account; -import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; -import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; -import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; import org.junit.jupiter.api.BeforeAll; @@ -60,11 +58,22 @@ * Charge-off is a pure accounting tag: it writes off the outstanding receivables against the charge-off expense (no * interest leg -- WC has no interest concept), keeps the loan ACTIVE, and is reversible via undo. */ -public class WorkingCapitalLoanChargeOffAccountingTest { - - private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("dd MMMM yyyy"); - - private final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); +public class WorkingCapitalLoanChargeOffAccountingTest extends FeignIntegrationTest { + + private static final DateTimeFormatter API_DATE = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + private static final String DISCOUNT_FEE_AMORTIZATION_CODE = "loanTransactionType.discountFeeAmortization"; + + // Fixed simulated calendar used by every test in this class (never a computed "now"): loans are disbursed on + // DAY_1, charged off on DAY_2, and later days are used for whatever the individual test needs next. + private static final LocalDate DAY_1 = LocalDate.of(2026, 1, 1); + private static final LocalDate DAY_2 = LocalDate.of(2026, 1, 2); + private static final LocalDate DAY_3 = LocalDate.of(2026, 1, 3); + private static final LocalDate DAY_4 = LocalDate.of(2026, 1, 4); + private static final LocalDate DAY_5 = LocalDate.of(2026, 1, 5); + private static final LocalDate DAY_10 = LocalDate.of(2026, 1, 10); + + private final FeignWorkingCapitalLoanHelper loanHelper = new FeignWorkingCapitalLoanHelper(fineractClient()); + private final FeignBusinessDateHelper businessDateHelper = new FeignBusinessDateHelper(fineractClient()); private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); private final List createdLoanIds = new ArrayList<>(); private final List createdProductIds = new ArrayList<>(); @@ -113,24 +122,22 @@ public static void setupAccounts() { @Test public void testChargeOffWritesOffPrincipalAndKeepsLoanActive() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - final AtomicLong chargeOffTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> chargeOffTxnId.set(loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, "charge-off note")))); + final Long[] chargeOffTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_2.toString(), () -> chargeOffTxnIdHolder[0] = loanHelper.chargeOff(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), "charge-off note"))); // The loan stays ACTIVE and is flagged as charged off (pure accounting tag, no portfolio impact). - final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.getLoanDetails(loanIdHolder[0]); assertNotNull(loanData.getStatus()); assertEquals("loanStatusType.active", loanData.getStatus().getCode()); assertEquals(Boolean.TRUE, loanData.getChargedOff()); // Only principal was outstanding (no fees/penalties): Dr Charge-off expense 5000, Cr Loan portfolio 5000. - final List entries = getJournalEntriesForWCTransaction(chargeOffTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(chargeOffTxnIdHolder[0]); assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); assertJournalEntry(entries, "DEBIT", chargeOffExpenseAccount, 5000.0); assertJournalEntry(entries, "CREDIT", loanPortfolioAccount, 5000.0); @@ -139,29 +146,26 @@ public void testChargeOffWritesOffPrincipalAndKeepsLoanActive() { @Test public void testUndoChargeOffReversesJournalEntriesAndClearsTag() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - final AtomicLong chargeOffTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> chargeOffTxnId.set(loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)))); + final Long[] chargeOffTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_2.toString(), () -> chargeOffTxnIdHolder[0] = loanHelper.chargeOff(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); - final LocalDate undoDate = chargeOffDate.plusDays(1); - BusinessDateHelper.runAt(undoDate.format(BUSINESS_DATE), () -> loanHelper.undoChargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildUndoChargeOffRequest("undo note"))); + businessDateHelper.runAt(DAY_3.toString(), + () -> loanHelper.undoChargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.undoChargeOff("undo note"))); // The charge-off tag is cleared and the loan stays ACTIVE. - final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.getLoanDetails(loanIdHolder[0]); assertNotNull(loanData.getStatus()); assertEquals("loanStatusType.active", loanData.getStatus().getCode()); - assertFalse(Boolean.TRUE.equals(loanData.getChargedOff())); + assertNotEquals(Boolean.TRUE, loanData.getChargedOff()); // The original 2 entries are reversed: querying the charge-off transaction now returns the originals plus their // reversals (Dr Loan portfolio 5000, Cr Charge-off expense 5000). - final List entries = getJournalEntriesForWCTransaction(chargeOffTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(chargeOffTxnIdHolder[0]); assertEquals(4, entries.size(), "Expected 4 journal entries (2 original + 2 reversal)"); assertJournalEntry(entries, "DEBIT", loanPortfolioAccount, 5000.0); assertJournalEntry(entries, "CREDIT", chargeOffExpenseAccount, 5000.0); @@ -170,17 +174,14 @@ public void testUndoChargeOffReversesJournalEntriesAndClearsTag() { @Test public void testSecondChargeOffFails() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); - - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> { - loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)); - final CallFailedRuntimeException error = loanHelper.runChargeOffByLoanIdExpectingFailure(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); + + businessDateHelper.runAt(DAY_2.toString(), () -> { + loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null)); + final CallFailedRuntimeException error = loanHelper.chargeOffExpectingFailure(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null)); assertTrue(error.getMessage() != null && error.getMessage().contains("already.charged.off"), "Expected already-charged-off validation error, got: " + error.getMessage()); }); @@ -188,9 +189,8 @@ public void testSecondChargeOffFails() { @Test public void testChargeOffWithNoAccountingCreatesNoJournalEntries() { - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), () -> { final String uniqueName = "WCL CoNoAcct " + UUID.randomUUID().toString().substring(0, 8); final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); final Long productId = productHelper @@ -198,39 +198,34 @@ public void testChargeOffWithNoAccountingCreatesNoJournalEntries() { new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) .getResourceId(); createdProductIds.add(productId); - loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1); }); - final LocalDate chargeOffDate = currentDate.plusDays(1); - final AtomicLong chargeOffTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> chargeOffTxnId.set(loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)))); + final Long[] chargeOffTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_2.toString(), () -> chargeOffTxnIdHolder[0] = loanHelper.chargeOff(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); - final List entries = getJournalEntriesForWCTransaction(chargeOffTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(chargeOffTxnIdHolder[0]); assertTrue(entries.isEmpty(), "Expected no journal entries for NONE accounting rule"); } @Test public void testRepaymentAfterChargeOffPostsToRecoveryIncome() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null))); + businessDateHelper.runAt(DAY_2.toString(), + () -> loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); // Repayments stay allowed after charge-off (the balance can be cured); their credits are routed to recovery // income instead of the loan portfolio / receivables. - final LocalDate repaymentDate = chargeOffDate.plusDays(1); - final AtomicLong repaymentTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(repaymentDate.format(BUSINESS_DATE), - () -> repaymentTxnId.set(loanHelper.makeRepaymentByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder - .buildRepaymentRequest(repaymentDate, BigDecimal.valueOf(1000), null, null, null, null)))); + final Long[] repaymentTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_3.toString(), () -> repaymentTxnIdHolder[0] = loanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), DAY_3.format(API_DATE)))); - final List entries = getJournalEntriesForWCTransaction(repaymentTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(repaymentTxnIdHolder[0]); assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); assertJournalEntry(entries, "DEBIT", fundSourceAccount, 1000.0); assertJournalEntry(entries, "CREDIT", incomeFromRecoveryAccount, 1000.0); @@ -239,25 +234,21 @@ public void testRepaymentAfterChargeOffPostsToRecoveryIncome() { @Test public void testGoodwillCreditAfterChargeOffPostsToRecoveryIncome() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null))); + businessDateHelper.runAt(DAY_2.toString(), + () -> loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); // Goodwill credit stays allowed after charge-off (parity with term and progressive loans). The debit side is - // the - // goodwill expense as usual; the credit side recognizes recovery income instead of the written-off portfolio. - final LocalDate goodwillDate = chargeOffDate.plusDays(1); - final AtomicLong goodwillTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(goodwillDate.format(BUSINESS_DATE), - () -> goodwillTxnId.set(loanHelper.makeGoodwillCreditByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder - .buildRepaymentRequest(goodwillDate, BigDecimal.valueOf(1000), null, null, null, null)))); - - final List entries = getJournalEntriesForWCTransaction(goodwillTxnId.get()); + // the goodwill expense as usual; the credit side recognizes recovery income instead of the written-off + // portfolio. + final Long[] goodwillTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_3.toString(), () -> goodwillTxnIdHolder[0] = loanHelper.makeGoodwillCredit(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.goodwillCredit(BigDecimal.valueOf(1000), DAY_3.format(API_DATE)))); + + final List entries = getJournalEntriesForWCTransaction(goodwillTxnIdHolder[0]); assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); assertJournalEntry(entries, "DEBIT", goodwillCreditAccount, 1000.0); assertJournalEntry(entries, "CREDIT", incomeFromRecoveryAccount, 1000.0); @@ -266,24 +257,20 @@ public void testGoodwillCreditAfterChargeOffPostsToRecoveryIncome() { @Test public void testPayoutRefundAfterChargeOffReversesChargeOffExpense() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null))); + businessDateHelper.runAt(DAY_2.toString(), + () -> loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); // Payout refund stays allowed after charge-off; its principal credit reverses the charge-off expense that the // charge-off recognized, instead of reducing the already written-off portfolio. - final LocalDate refundDate = chargeOffDate.plusDays(1); - final AtomicLong refundTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(refundDate.format(BUSINESS_DATE), - () -> refundTxnId.set(loanHelper.makePayoutRefundByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder - .buildRepaymentRequest(refundDate, BigDecimal.valueOf(1000), null, null, null, null)))); + final Long[] refundTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_3.toString(), () -> refundTxnIdHolder[0] = loanHelper.payoutRefund(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.payoutRefund(BigDecimal.valueOf(1000), DAY_3.format(API_DATE)))); - final List entries = getJournalEntriesForWCTransaction(refundTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(refundTxnIdHolder[0]); assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); assertJournalEntry(entries, "DEBIT", fundSourceAccount, 1000.0); assertJournalEntry(entries, "CREDIT", chargeOffExpenseAccount, 1000.0); @@ -292,32 +279,26 @@ public void testPayoutRefundAfterChargeOffReversesChargeOffExpense() { @Test public void testUndoOfRepaymentAfterChargeOffReversesRecoveryIncomeEntries() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null))); + businessDateHelper.runAt(DAY_2.toString(), + () -> loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); - final LocalDate repaymentDate = chargeOffDate.plusDays(1); - final AtomicLong repaymentTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(repaymentDate.format(BUSINESS_DATE), - () -> repaymentTxnId.set(loanHelper.makeRepaymentByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder - .buildRepaymentRequest(repaymentDate, BigDecimal.valueOf(1000), null, null, null, null)))); + final Long[] repaymentTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_3.toString(), () -> repaymentTxnIdHolder[0] = loanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), DAY_3.format(API_DATE)))); // Undo the repayment that was posted after charge-off: the recovery-income entries it booked must be // reversed exactly like any other transaction's entries, not left dangling on the ledger. - final LocalDate undoDate = repaymentDate.plusDays(1); - BusinessDateHelper.runAt(undoDate.format(BUSINESS_DATE), - () -> loanHelper.undoTransactionByLoanId(loanId.get(), repaymentTxnId.get())); + businessDateHelper.runAt(DAY_4.toString(), () -> loanHelper.undoTransaction(loanIdHolder[0], repaymentTxnIdHolder[0])); // The loan is still charged off - only the repayment was undone, not the charge-off itself. - final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.getLoanDetails(loanIdHolder[0]); assertEquals(Boolean.TRUE, loanData.getChargedOff()); - final List entries = getJournalEntriesForWCTransaction(repaymentTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(repaymentTxnIdHolder[0]); assertEquals(4, entries.size(), "Expected 4 journal entries (2 original + 2 reversal)"); // Originals: Dr Fund source 1000, Cr Income from recovery 1000. Reversal mirrors flip both sides. assertJournalEntry(entries, "DEBIT", fundSourceAccount, 1000.0); @@ -329,32 +310,27 @@ public void testUndoOfRepaymentAfterChargeOffReversesRecoveryIncomeEntries() { @Test public void testChargeAdjustmentAfterChargeOffPostsToChargeOffFeeIncome() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - final AtomicLong feeChargeId = new AtomicLong(0L); - final FeignWorkingCapitalLoanHelper feignLoanHelper = new FeignWorkingCapitalLoanHelper( - FineractFeignClientHelper.getFineractFeignClient()); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { - loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + final Long[] loanIdHolder = new Long[1]; + final Long[] feeChargeIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), () -> { + loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1); // The fee charge must be added before charge-off: new charges are rejected once the loan is charged off. - final Long chargeId = feignLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, 300)); - feeChargeId.set(feignLoanHelper.addCharge(loanId.get(), - WorkingCapitalLoanRequestBuilders.addCharge(chargeId, 300, currentDate.format(BUSINESS_DATE)))); + final Long chargeId = loanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, 300)); + feeChargeIdHolder[0] = loanHelper.addCharge(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.addCharge(chargeId, 300, DAY_1.format(API_DATE))); }); - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null))); + businessDateHelper.runAt(DAY_2.toString(), + () -> loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); // A charge adjustment on a charged-off loan still debits the fee's own income account, but the credit // recognizes charge-off fee income instead of the (already written-off) fees receivable. - final LocalDate adjustmentDate = chargeOffDate.plusDays(1); - final AtomicLong adjustmentTxnId = new AtomicLong(0L); - BusinessDateHelper.runAt(adjustmentDate.format(BUSINESS_DATE), () -> adjustmentTxnId.set(feignLoanHelper.adjustCharge(loanId.get(), - feeChargeId.get(), WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(100))))); + final Long[] adjustmentTxnIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_3.toString(), () -> adjustmentTxnIdHolder[0] = loanHelper.adjustCharge(loanIdHolder[0], + feeChargeIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(100)))); - final List entries = getJournalEntriesForWCTransaction(adjustmentTxnId.get()); + final List entries = getJournalEntriesForWCTransaction(adjustmentTxnIdHolder[0]); assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); assertJournalEntry(entries, "DEBIT", incomeFromFeeAccount, 100.0); assertJournalEntry(entries, "CREDIT", incomeFromChargeOffFeesAccount, 100.0); @@ -363,23 +339,17 @@ public void testChargeAdjustmentAfterChargeOffPostsToChargeOffFeeIncome() { @Test public void testAddChargeAfterChargeOffIsRejected() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); - final LocalDate chargeOffDate = currentDate.plusDays(1); - BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> { - loanHelper.chargeOffByLoanId(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)); + businessDateHelper.runAt(DAY_2.toString(), () -> { + loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null)); // New charges cannot be added once the loan is charged off. - final FeignWorkingCapitalLoanHelper feignLoanHelper = new FeignWorkingCapitalLoanHelper( - FineractFeignClientHelper.getFineractFeignClient()); - final Long chargeId = feignLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, 100)); - final CallFailedRuntimeException error = assertThrows(CallFailedRuntimeException.class, - () -> feignLoanHelper.addCharge(loanId.get(), - WorkingCapitalLoanRequestBuilders.addCharge(chargeId, 100, chargeOffDate.format(BUSINESS_DATE)))); + final Long chargeId = loanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, 100)); + final CallFailedRuntimeException error = assertThrows(CallFailedRuntimeException.class, () -> loanHelper + .addCharge(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.addCharge(chargeId, 100, DAY_2.format(API_DATE)))); assertTrue(error.getMessage() != null && error.getMessage().contains("charged.off"), "Expected charged-off rejection for addCharge, got: " + error.getMessage()); }); @@ -388,13 +358,12 @@ public void testAddChargeAfterChargeOffIsRejected() { @Test public void testChargeOffWithFutureDateFails() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { - loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + businessDateHelper.runAt(DAY_1.toString(), () -> { + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1); - final CallFailedRuntimeException error = loanHelper.runChargeOffByLoanIdExpectingFailure(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(currentDate.plusDays(1), null)); + // Business date stays at DAY_1, so a charge-off dated DAY_2 is a future date. + final CallFailedRuntimeException error = loanHelper.chargeOffExpectingFailure(loanId, + WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null)); assertTrue(error.getMessage() != null && error.getMessage().contains("future.date"), "Expected future-date validation error, got: " + error.getMessage()); }); @@ -403,28 +372,138 @@ public void testChargeOffWithFutureDateFails() { @Test public void testChargeOffDatedBeforeLastTransactionFails() { final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); - final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); - final AtomicLong loanId = new AtomicLong(0L); - BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), - () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); - - final LocalDate repaymentDate = currentDate.plusDays(2); - BusinessDateHelper.runAt(repaymentDate.format(BUSINESS_DATE), () -> { - loanHelper.makeRepaymentByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildRepaymentRequest(repaymentDate, - BigDecimal.valueOf(1000), null, null, null, null)); - - // The charge-off can be backdated, but never behind the last (non-reversed) transaction. - final CallFailedRuntimeException error = loanHelper.runChargeOffByLoanIdExpectingFailure(loanId.get(), - WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(currentDate.plusDays(1), null)); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), DAY_1)); + + businessDateHelper.runAt(DAY_3.toString(), () -> { + loanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), DAY_3.format(API_DATE))); + + // The charge-off can be backdated, but never behind the last (non-reversed) transaction: DAY_2 predates + // the DAY_3 repayment above. + final CallFailedRuntimeException error = loanHelper.chargeOffExpectingFailure(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null)); assertTrue(error.getMessage() != null && error.getMessage().contains("last.transaction"), "Expected before-last-transaction validation error, got: " + error.getMessage()); }); } + @Test + public void testChargeOffAmortizesRemainingUnreleasedDiscountFeeToChargeOffExpense() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProductWithDiscountOverride(); + final BigDecimal principal = BigDecimal.valueOf(5000); + final BigDecimal discount = BigDecimal.valueOf(500); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoanWithDiscount(productId, principal, discount, DAY_1)); + + businessDateHelper.runAt(DAY_2.toString(), () -> { + loanHelper.executeInlineWCCOB(loanIdHolder[0]); + assertTrue(filterByType(loanHelper.getTransactions(loanIdHolder[0]), DISCOUNT_FEE_AMORTIZATION_CODE).isEmpty(), + "No periodic amortization should have posted before any repayment"); + + loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null)); + }); + + // The entire unreleased discount fee is recognized in one shot, dated on the charge-off date. + final List amortizations = filterByType(loanHelper.getTransactions(loanIdHolder[0]), + DISCOUNT_FEE_AMORTIZATION_CODE); + assertEquals(1, amortizations.size(), "Expected exactly 1 discount fee amortization transaction"); + final GetWorkingCapitalLoanTransactionIdResponse amortTxn = amortizations.getFirst(); + assertEquals(DAY_2, amortTxn.getTransactionDate()); + assertEquals(0, discount.compareTo(amortTxn.getTransactionAmount()), + "Expected the full discount to be recognized at charge-off, was: " + amortTxn.getTransactionAmount()); + + // ...credited to charge-off expense, not discount-fee income. + final List entries = getJournalEntriesForWCTransaction(amortTxn.getId()); + assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); + assertJournalEntry(entries, "DEBIT", deferredIncomeAccount, discount.doubleValue()); + assertJournalEntry(entries, "CREDIT", chargeOffExpenseAccount, discount.doubleValue()); + + // No further amortization happens once charged off, even if COB keeps running: this run (business date DAY_3) + // processes DAY_2, the charge-off date itself, and must not create a second amortization transaction. + businessDateHelper.runAt(DAY_10.toString(), () -> loanHelper.executeInlineWCCOB(loanIdHolder[0])); + assertEquals(1, filterByType(loanHelper.getTransactions(loanIdHolder[0]), DISCOUNT_FEE_AMORTIZATION_CODE).size(), + "COB after charge-off must not create additional discount fee amortization transactions"); + } + + @Test + public void testUndoChargeOffReversesDiscountFeeAmortizationAndResumesPeriodicAmortization() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProductWithDiscountOverride(); + final BigDecimal principal = BigDecimal.valueOf(5000); + final BigDecimal discount = BigDecimal.valueOf(500); + final Long[] loanIdHolder = new Long[1]; + businessDateHelper.runAt(DAY_1.toString(), + () -> loanIdHolder[0] = createApprovedAndDisbursedLoanWithDiscount(productId, principal, discount, DAY_1)); + + businessDateHelper.runAt(DAY_2.toString(), + () -> loanHelper.chargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.chargeOff(DAY_2.format(API_DATE), null))); + + final List amortizations = filterByType(loanHelper.getTransactions(loanIdHolder[0]), + DISCOUNT_FEE_AMORTIZATION_CODE); + assertEquals(1, amortizations.size(), "Expected exactly 1 discount fee amortization transaction"); + final Long amortTxnId = amortizations.getFirst().getId(); + + businessDateHelper.runAt(DAY_3.toString(), + () -> loanHelper.undoChargeOff(loanIdHolder[0], WorkingCapitalLoanRequestBuilders.undoChargeOff(null))); + + // The final amortization transaction created at charge-off is now reversed... + final GetWorkingCapitalLoanTransactionIdResponse reversedAmortTxn = loanHelper.getTransaction(loanIdHolder[0], amortTxnId); + assertEquals(Boolean.TRUE, reversedAmortTxn.getReversed(), + "Expected the discount fee amortization transaction to be reversed after undo charge-off"); + + // ...and its journal entries are reversed too (2 original + 2 reversal). + final List entries = getJournalEntriesForWCTransaction(amortTxnId); + assertEquals(4, entries.size(), "Expected 4 journal entries (2 original + 2 reversal)"); + assertJournalEntry(entries, "DEBIT", deferredIncomeAccount, discount.doubleValue()); + assertJournalEntry(entries, "CREDIT", chargeOffExpenseAccount, discount.doubleValue()); + assertJournalEntry(entries, "CREDIT", deferredIncomeAccount, discount.doubleValue()); + assertJournalEntry(entries, "DEBIT", chargeOffExpenseAccount, discount.doubleValue()); + + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.getLoanDetails(loanIdHolder[0]); + assertNotEquals(Boolean.TRUE, loanData.getChargedOff()); + + businessDateHelper.runAt(DAY_4.toString(), () -> loanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), DAY_4.format(API_DATE)))); + businessDateHelper.runAt(DAY_5.toString(), () -> loanHelper.executeInlineWCCOB(loanIdHolder[0])); + + final List resumedAmortizations = filterByType( + loanHelper.getTransactions(loanIdHolder[0]), DISCOUNT_FEE_AMORTIZATION_CODE); + assertEquals(1, resumedAmortizations.size(), + "Expected exactly 1 non-reversed amortization transaction after undo + repayment + COB"); + final GetWorkingCapitalLoanTransactionIdResponse resumedAmortTxn = resumedAmortizations.getFirst(); + assertNotNull(resumedAmortTxn.getTransactionAmount()); + assertTrue(resumedAmortTxn.getTransactionAmount().compareTo(BigDecimal.ZERO) > 0, "Resumed amortization should be positive"); + + final List resumedEntries = getJournalEntriesForWCTransaction(resumedAmortTxn.getId()); + assertJournalEntry(resumedEntries, "CREDIT", incomeFromDiscountFeeAccount, resumedAmortTxn.getTransactionAmount().doubleValue()); + } + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- + private Long createApprovedAndDisbursedLoanWithDiscount(final Long productId, final BigDecimal principal, final BigDecimal discount, + final LocalDate approvedOnDate) { + final String dateStr = approvedOnDate.format(API_DATE); + final Long loanId = submitAndTrack( + WorkingCapitalLoanRequestBuilders + .submitApplication(createdClientId, productId, principal, + WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT, dateStr, dateStr) + .discount(discount)); + loanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approveWithDiscount(dateStr, principal, dateStr, discount)); + loanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburseWithDiscount(dateStr, principal, discount)); + return loanId; + } + + private List filterByType( + final List transactions, final String typeCode) { + return transactions.stream() + .filter(txn -> txn.getType() != null && typeCode.equals(txn.getType().getCode()) && !Boolean.TRUE.equals(txn.getReversed())) + .toList(); + } + private Long createAccrualWithDeferredRevenueAmortizationProduct() { final String uniqueName = "WCL CoAcct " + UUID.randomUUID().toString().substring(0, 8); final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); @@ -450,12 +529,41 @@ private Long createAccrualWithDeferredRevenueAmortizationProduct() { return productId; } + /** + * Same GL setup as {@link #createAccrualWithDeferredRevenueAmortizationProduct()}, but also allows the discount + * amount to be overridden per loan (required to submit/approve/disburse with a discount at all). + */ + private Long createAccrualWithDeferredRevenueAmortizationProductWithDiscountOverride() { + final String uniqueName = "WCL CoDiscAcct " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 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()) + .withChargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue()) + .withIncomeFromChargeOffFeesAccountId(incomeFromChargeOffFeesAccount.getAccountID().longValue()) + .withIncomeFromChargeOffPenaltyAccountId(incomeFromChargeOffPenaltyAccount.getAccountID().longValue()) + .withGoodwillCreditAccountId(goodwillCreditAccount.getAccountID().longValue()).build()).getResourceId(); + createdProductIds.add(productId); + return productId; + } + private Long createApprovedAndDisbursedLoan(final Long productId, final BigDecimal principal, final LocalDate approvedOnDate) { - final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder().withClientId(createdClientId) - .withProductId(productId).withPrincipal(principal) - .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT).buildSubmitRequest()); - loanHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveRequest(approvedOnDate, principal, null)); - loanHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildDisburseRequest(approvedOnDate, principal)); + final String dateStr = approvedOnDate.format(API_DATE); + final Long loanId = submitAndTrack(WorkingCapitalLoanRequestBuilders.submitApplication(createdClientId, productId, principal, + WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT, dateStr, dateStr)); + loanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approve(dateStr, principal, dateStr)); + loanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburse(dateStr, principal)); return loanId; } @@ -487,7 +595,7 @@ private void assertJournalEntry(final List entries, } private Long submitAndTrack(final PostWorkingCapitalLoansRequest submitJson) { - final Long loanId = loanHelper.submit(submitJson); + final Long loanId = loanHelper.submitApplication(submitJson); createdLoanIds.add(loanId); return loanId; } 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 19e2af13543..306318354a8 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 @@ -132,12 +132,43 @@ public Long undoTransaction(Long loanId, Long transactionId, ExecuteWorkingCapit return response.getResourceId(); } + public void undoTransaction(Long loanId, Long transactionId) { + undoTransaction(loanId, transactionId, new ExecuteWorkingCapitalLoanTransactionCommandRequest()); + } + public Long makeGoodwillCredit(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() .executeWorkingCapitalLoanTransactionById(loanId, "goodwillCredit", request)); return response.getResourceId(); } + public Long payoutRefund(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { + PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionById(loanId, "payoutRefund", request)); + return response.getResourceId(); + } + + public Long chargeOff(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { + PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionById(loanId, "chargeOff", request)); + return response.getResourceId(); + } + + public Long undoChargeOff(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { + PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionById(loanId, "undoChargeOff", request)); + return response.getResourceId(); + } + + public CallFailedRuntimeException chargeOffExpectingFailure(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { + return fail(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionById(loanId, "chargeOff", + request)); + } + + public GetWorkingCapitalLoanTransactionIdResponse getTransaction(Long loanId, Long transactionId) { + return ok(() -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionById(loanId, transactionId)); + } + public Long creditBalanceRefund(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() .executeWorkingCapitalLoanTransactionById(loanId, "creditBalanceRefund", request)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java index f92a9b30605..444fdc49ff9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java @@ -132,6 +132,27 @@ public static PostWorkingCapitalLoanTransactionsRequest creditBalanceRefund(BigD return repayment(amount, transactionDate); } + public static PostWorkingCapitalLoanTransactionsRequest payoutRefund(BigDecimal amount, String transactionDate) { + return repayment(amount, transactionDate); + } + + public static PostWorkingCapitalLoanTransactionsRequest chargeOff(String transactionDate, String note) { + final PostWorkingCapitalLoanTransactionsRequest request = new PostWorkingCapitalLoanTransactionsRequest() + .transactionDate(transactionDate).locale(LOCALE).dateFormat(DATE_FORMAT); + if (note != null) { + request.note(note); + } + return request; + } + + public static PostWorkingCapitalLoanTransactionsRequest undoChargeOff(String note) { + final PostWorkingCapitalLoanTransactionsRequest request = new PostWorkingCapitalLoanTransactionsRequest().locale(LOCALE); + if (note != null) { + request.note(note); + } + return request; + } + public static ExecuteWorkingCapitalLoanTransactionCommandRequest reversal() { return new ExecuteWorkingCapitalLoanTransactionCommandRequest(); }