diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 398c81d2c64..d36fbf31758 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -1332,6 +1332,15 @@ public CommandWrapperBuilder undoWriteOffWorkingCapitalLoanTransaction(final Lon return this; } + public CommandWrapperBuilder recoveryPaymentWorkingCapitalLoanTransaction(final Long loanId) { + this.actionName = ACTION_RECOVERYPAYMENT; + this.entityName = ENTITY_WORKINGCAPITALLOAN; + this.entityId = loanId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/transactions?command=recoveryPayment"; + return this; + } + public CommandWrapperBuilder loanInterestPaymentWaiverTransaction(final Long loanId) { this.actionName = ACTION_INTERESTPAYMENTWAIVER; this.entityName = ENTITY_LOAN; diff --git a/fineract-doc/src/docs/en/chapters/features/index.adoc b/fineract-doc/src/docs/en/chapters/features/index.adoc index f2473c7ab24..ea865631fe6 100644 --- a/fineract-doc/src/docs/en/chapters/features/index.adoc +++ b/fineract-doc/src/docs/en/chapters/features/index.adoc @@ -27,6 +27,7 @@ include::working-capital-charge-off.adoc[leveloffset=+1] include::working-capital-credit-balance-refund.adoc[leveloffset=+1] include::working-capital-goodwill-credit.adoc[leveloffset=+1] include::working-capital-write-off.adoc[leveloffset=+1] +include::working-capital-recovery-payment.adoc[leveloffset=+1] include::working-capital-delinquency-management.adoc[leveloffset=+1] include::working-capital-eir-calculation.adoc[leveloffset=+1] include::working-capital-planned-projected-balances-eir.adoc[leveloffset=+1] diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-recovery-payment.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-recovery-payment.adoc new file mode 100644 index 00000000000..a3126bdb42f --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-recovery-payment.adoc @@ -0,0 +1,291 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + += Working Capital Loan — Recovery Payment + +== Purpose + +A Recovery Payment records money collected on a Working Capital Loan *after it was written off*. The loss was already recognized at write-off time, so the money coming back is recognized as *income*, not as a repayment of a receivable that no longer exists. + +It ensures: + +* Collections on written-off accounts can be *recorded and accounted for* +* The recovered cash is recognized as *recovery income*, leaving the written-off loss intact +* Recoveries cannot exceed the loss that was actually booked +* A recovery captured in error can be *reversed* + +== Functionality + +* A Recovery Payment is the *only* monetary transaction admitted while the loan sits in `CLOSED_WRITTEN_OFF` (see <<_interaction_with_the_post_write_off_lock>>). +* It is recorded as a `RECOVERY_REPAYMENT` transaction for the amount collected. +* It results in: +** Recognition of the whole amount as recovery income +** An increase of the loan's running `totalRecovered`, which lowers how much is still recoverable +** Triggering of accounting entries for accrual with deferred revenue amortization accounting products +* It deliberately does *not*: +** Change the loan status — the loan stays `CLOSED_WRITTEN_OFF` +** Move the outstanding balance, which the write-off left at zero and which stays at zero +** Allocate across principal, fees or penalty — the transaction carries *no allocation* +** Touch the amortization, delinquency or breach schedules + +[NOTE] +==== +The status is left untouched on purpose. A Working Capital Loan in `CLOSED_WRITTEN_OFF` has a zero outstanding, so running the balance-driven status evaluation would try to transition it as *repaid in full*, which is not a legal transition out of `CLOSED_WRITTEN_OFF`. The recovery path therefore never invokes the loan lifecycle state machine. +==== + +== Recovery Payment Handling + +* The collected amount is added to `totalRecovered` on the loan balance. That column is the only balance-side effect of the transaction. +* `totalWrittenOff` — the gross amount the write-off moved out of the outstanding — is *not* reduced by recoveries. It remains the accounting record of the loss that was booked. +* What is still collectable is derived: `writtenOffOutstanding = totalWrittenOff - totalRecovered`, floored at zero. This value caps the next recovery payment. + +== Undo Recovery Payment + +A recovery captured in error can be reversed through the generic transaction-undo command: + +* Reverses the `RECOVERY_REPAYMENT` transaction (kept in the ledger, flagged reversed, with an optional `reversalExternalId`). +* Subtracts the amount from `totalRecovered`, so the money becomes recoverable again. +* Posts the mirror journal entries (see <<_undo_recovery_payment_2>>). +* Leaves the loan in `CLOSED_WRITTEN_OFF`. + +.Recovery payment and reversal flow +[plantuml,format=svg] +.... +@startuml +actor User +participant "Transactions API" as API +participant "Recovery Payment\nWrite Service" as SVC +participant "Data Validator" as VAL +database "Loan balance" as BAL +participant "Accounting\nProcessor" as ACC + +User -> API : POST ...?command=recoveryPayment +API -> SVC : recoveryPayment(loanId, command) +SVC -> VAL : validateRecoveryPayment +alt loan not CLOSED_WRITTEN_OFF + VAL --> User : 400 error.msg.wc.loan.is.not.written.off +else amount > writtenOffOutstanding + VAL --> User : 400 cannot.be.greater.than.remaining.written.off.amount +else valid + SVC -> SVC : create RECOVERY_REPAYMENT txn (no allocation) + SVC -> BAL : totalRecovered += amount + SVC -> ACC : Dr Fund Source / Cr Income from Recovery + note over SVC : status stays CLOSED_WRITTEN_OFF + SVC --> User : 200 +end + +User -> API : POST .../transactions/{txnId}?command=undo +API -> SVC : undoRecoveryPayment +SVC -> SVC : reverse txn +SVC -> BAL : totalRecovered -= amount +SVC -> ACC : mirror entries +SVC --> User : 200 +@enduml +.... + +== Validation Rules + +A recovery payment can be applied only if: + +* Loan status is *Closed (written-off)*. + +A recovery payment can be reversed only if: + +* Loan status is still *Closed (written-off)*, and the transaction is not already reversed. + +=== Recovery Payment Date + +`transactionDate` is *required*. It may be backdated, with the same two bounds Write-Off and Charge-Off apply: + +* It must not be in the future. +* It must not be earlier than the last user transaction date. + +Because the write-off is itself a user transaction, the lower bound also prevents a recovery from being dated before the write-off that made it possible — no separate check is needed. + +=== Recovery Payment Amount + +`transactionAmount` is *required* and must be positive. It is capped by `writtenOffOutstanding` — what is *still* recoverable — and not by the gross amount written off. + +[IMPORTANT] +==== +This is a deliberate divergence from term and progressive loans, which cap each recovery against the gross written-off figure. Because that figure is never reduced by the recoveries collected against it, successive recoveries can each pass validation on their own and together collect more than the loss that was booked. Working Capital compares against the remaining amount instead, so the recoveries can never add up past the loss. +==== + +[cols="1,3,2"] +|=== +|*Operation* |*Rule* |*Error code* + +|Recovery payment |The loan must be written off. |`error.msg.wc.loan.is.not.written.off` +|Recovery payment |The transaction date is required. |`validation.msg.WORKINGCAPITALLOAN.transactionDate.cannot.be.blank` +|Recovery payment |The transaction date must not be in the future. |`cannot.be.a.future.date` +|Recovery payment |The transaction date must not be earlier than the last user transaction date. |`cannot.be.before.last.transaction.date` +|Recovery payment |The amount is required and must be positive. |`validation.msg.WORKINGCAPITALLOAN.transactionAmount.cannot.be.blank` +|Recovery payment |The amount must not exceed what is still recoverable. |`cannot.be.greater.than.remaining.written.off.amount` +|Undo recovery payment |The transaction must not be already reversed. |`transaction.already.undone` +|Undo recovery payment |The loan must still be written off. |`error.msg.wc.loan.is.not.written.off` +|=== + +A repayment classification (`classificationId`) is *not* accepted: a recovery carries no allocation, so there is nothing to classify, and silently ignoring the parameter would hide the mistake. + +=== Interaction with the post-write-off lock + +A written-off Working Capital Loan is otherwise locked — no transaction can be posted and none can be undone. The recovery payment is the single exception, and it is a *typed* one: the hole is opened for the `RECOVERY_REPAYMENT` transaction type coming from `CLOSED_WRITTEN_OFF`, not by relaxing the status gates that guard repayment, goodwill credit, payout refund, charge adjustment or the generic transaction undo. Those gates are unchanged and still reject a written-off loan. + +=== Undoing the write-off while a recovery stands + +Undo Write-Off is *rejected* while any recovery payment is outstanding. + +Undoing the write-off restores the full outstanding balance. The recovered cash, however, stays booked as recovery income and would then also be replayed against that restored balance — the same money both recognized as income and reducing the receivable. The recoveries must therefore be reversed first, which is exactly what the undo recovery payment is for. + +[cols="1,3,2"] +|=== +|*Operation* |*Rule* |*Error code* + +|Undo write-off |No recovery payment may be outstanding (`totalRecovered` must be zero). |`cannot.undo.write.off.with.recovery.payments` +|=== + +== Transaction Template + +The transaction template pre-fills the amount with what is *still* recoverable, so the value it offers is always one the API accepts: + +[source] +---- +GET /v1/working-capital-loans/{loanId}/template?templateType=recoveryPayment +---- + +`expectedAmount` returns `writtenOffOutstanding`. On a loan written off for 100 with 30 already recovered, the template offers 70 — not the gross 100, which the API would reject. + +== API + +[source] +---- +POST /v1/working-capital-loans/{loanId}/transactions?command=recoveryPayment +POST /v1/working-capital-loans/{loanId}/transactions/{transactionId}?command=undo +---- + +.Recovery payment request +[source,json] +---- +{ + "transactionDate": "20 January 2026", + "transactionAmount": 40, + "externalId": "WC-RP-001", + "dateFormat": "dd MMMM yyyy", + "locale": "en", + "note": "Partial collection after write-off", + "paymentDetails": { + "paymentTypeId": 1, + "accountNumber": "ACC-001" + } +} +---- + +.Undo recovery payment request +[source,json] +---- +{ + "reversalExternalId": "WC-RP-REV-001", + "note": "Captured in error", + "locale": "en" +} +---- + +== Read Model + +The Working Capital Loan balance exposes the written-off and recovered figures, so a client can reconcile the zeroed outstanding and explain a rejected amount: + +[cols="1,3"] +|=== +|*Field* |*Description* + +|`totalWrittenOff` +|Gross amount the write-off moved out of the outstanding. Not reduced by recoveries. + +|`principalWrittenOff`, `feeWrittenOff`, `penaltyWrittenOff` +|The same figure split by portion. `principalOutstanding` and its siblings already net these off, so without them the exposed balance cannot be reconciled: a written-off loan reports a gross principal and a zero outstanding with nothing in between to explain the difference. + +|`totalRecovered` +|Running total collected after the write-off and recognized as recovery income. + +|`writtenOffOutstanding` +|Still recoverable (`totalWrittenOff - totalRecovered`). Caps the next recovery payment and is what the transaction template offers. +|=== + +== Accounting Treatment + +A Recovery Payment triggers Journal Entries (JE) when the Working Capital Loan product uses accrual with deferred revenue amortization accounting. The portfolio and receivable accounts were already relieved by the write-off, so there is nothing to credit back: the whole amount is recognized as income against the fund source, with no split by portion. + +=== Recovery Payment + +[cols="1,2,1,2"] +|=== +|*Type* |*WCP mapping name* |*GL type* |*Allocation* + +|Dr |Fund Source |Asset/Liability |Excess amount +|Cr |Income from Recovery |Income |Excess amount +|=== + +=== Undo Recovery Payment + +Undo posts an offsetting mirror for each entry above (the ledger stays append-only), which nets the recovery to zero: + +[cols="1,2,1,2"] +|=== +|*Type* |*WCP mapping name* |*GL type* |*Allocation* + +|Dr |Income from Recovery |Income |Excess amount +|Cr |Fund Source |Asset/Liability |Excess amount +|=== + +[NOTE] +==== +Do not confuse this with a *repayment posted after a charge-off*. That transaction is a real repayment: it moves the balance and is credited to Income from Recovery *by portion*, because a charge-off is a non-monetary tag that leaves the receivable on the books. A recovery payment on a written-off loan moves no balance and books a single line. +==== + +== Business Events + +* `WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent` — emitted after a recovery payment. +* `WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent` — emitted after a recovery payment is reversed. + +Neither operation emits a balance-changed or status-changed event: the outstanding balance stays at zero and the loan stays in `CLOSED_WRITTEN_OFF` throughout. + +== Permissions + +[cols="1,1,1,1"] +|=== +|*Permission* |*Grouping* |*Entity* |*Action* + +|`RECOVERYPAYMENT_WORKINGCAPITALLOAN` |`transaction_loan` |`WORKINGCAPITALLOAN` |`RECOVERYPAYMENT` +|=== + +The reversal adds no permission of its own: it goes through the generic transaction-undo command, exactly as +the reversal of a repayment, goodwill credit, payout refund or charge adjustment does. That command carries +action `UNDO` on entity `ENTITY_WORKINGCAPITALLOANTRANSACTION`. + +[NOTE] +==== +The permission code checked for a reversal is *not written anywhere in the source*: it is derived at runtime as +`actionName + "_" + entityName`, which for this command yields `UNDO_ENTITY_WORKINGCAPITALLOANTRANSACTION`. +Searching the codebase for that string finds nothing — the entity name and the action are declared separately +and concatenated when the command wrapper is built. + +No `m_permission` row is seeded for it, so today it can only be exercised by a super user (`ALL_FUNCTIONS`); +no other role can be granted it. This is pre-existing behaviour shared by every Working Capital transaction +reversal, not something the recovery payment introduces. +==== diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-write-off.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-write-off.adoc index 137acc69ee3..f36f9b5b2ef 100644 --- a/fineract-doc/src/docs/en/chapters/features/working-capital-write-off.adoc +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-write-off.adoc @@ -90,14 +90,24 @@ same input. |Write-off |The write-off date must not be in the future. |`cannot.be.a.future.date` |Write-off |The write-off date must not be earlier than the last user transaction date. |`cannot.be.before.last.transaction.date` |Undo write-off |The loan must be written off. |`error.msg.wc.loan.is.not.written.off` +|Undo write-off |No recovery payment may be outstanding on the loan. |`cannot.undo.write.off.with.recovery.payments` |=== +Undoing the write-off restores the full outstanding balance, so any money already collected as recovery income +would then also be replayed against that restored balance — the same cash both recognized as income and +reducing the receivable. Recoveries must be reversed first; see <<_working_capital_loan_recovery_payment>>. + === Post-write-off lock -A written-off loan is locked: no new transaction can be posted on it and no existing transaction can be undone -while it stays in `CLOSED_WRITTEN_OFF`. This is stricter than term and progressive loans, which reopen the loan -on adjustment. The only way out is the undo write-off, which reopens the loan to `ACTIVE` and restores the -balance; from there the account behaves normally again. +A written-off loan is locked: apart from the Recovery Payment described below, no new transaction can be posted +on it and no existing transaction can be undone while it stays in `CLOSED_WRITTEN_OFF`. This is stricter than +term and progressive loans, which reopen the loan on adjustment. The only way out is the undo write-off, which +reopens the loan to `ACTIVE` and restores the balance; from there the account behaves normally again. + +The single exception is the *Recovery Payment*, which records money collected after the write-off and is +admitted precisely because the loan is written off. It is a typed exception — opened for the +`RECOVERY_REPAYMENT` transaction type only — so none of the status gates listed below are relaxed. See +<<_working_capital_loan_recovery_payment>>. The lock is not a check of its own — it falls out of the status gates each operation already applies, none of which admit `CLOSED_WRITTEN_OFF`: @@ -176,6 +186,10 @@ The Working Capital Loan resource exposes the written-off state: |`writeOffReason` |The `WriteOffReasons` code value selected at write-off time, when one was provided. + +|`balance.totalWrittenOff` +|Gross amount moved out of the outstanding by the write-off, split per portion in `principalWrittenOff`, +`feeWrittenOff` and `penaltyWrittenOff`. Cleared by an undo. |=== == Accounting Treatment diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java index 54d61dac35a..399693d7593 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java @@ -43,6 +43,7 @@ public enum TransactionType { BUY_DOWN_FEE_AMORTIZATION("buyDownFeeAmortization"), // INTEREST_REFUND("interestRefund"), // WRITE_OFF("writeOff"), // + RECOVERY_REPAYMENT("recoveryRepayment"), // DISCOUNT_FEE("discountFee"), // DISCOUNT_FEE_ADJUSTMENT("discountFeeAdjustment"), // DISCOUNT_FEE_AMORTIZATION("discountFeeAmortization"), // 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..2b1a671399c 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 @@ -1292,6 +1292,47 @@ public void undoWriteOffWorkingCapitalLoanExternalId() { ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionById(loanId, "undoWriteOff", request)); } + @When("Admin makes a recovery payment of {string} on the Working Capital loan on {string}") + public void recoveryPaymentWorkingCapitalLoan(final String transactionAmount, final String transactionDate) { + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest().transactionDate(transactionDate) + .transactionAmount(new BigDecimal(transactionAmount)); + ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionById(getCreatedLoanId(), + "recoveryPayment", request)); + } + + @Then("Initiating a recovery payment of {string} on the Working Capital loan on {string} results an error with the following data:") + public void recoveryPaymentWorkingCapitalError(final String transactionAmount, final String transactionDate, final DataTable table) { + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest().transactionDate(transactionDate) + .transactionAmount(new BigDecimal(transactionAmount)); + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionById(getCreatedLoanId(), "recoveryPayment", request)); + if (table != null) { + verifyErrorResponse(exception, table); + } + } + + @When("Admin undoes the last recovery payment on the Working Capital loan") + public void undoLastRecoveryPaymentWorkingCapitalLoan() { + final Long loanId = getCreatedLoanId(); + final ExecuteWorkingCapitalLoanTransactionCommandRequest request = new ExecuteWorkingCapitalLoanTransactionCommandRequest(); + ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId(loanId, + latestActiveRecoveryPaymentTransaction().getId(), "undo", request)); + } + + private GetWorkingCapitalLoanTransactionIdResponse latestActiveRecoveryPaymentTransaction() { + final GetWorkingCapitalLoanTransactionsResponse body = retrieveLoanTransactions(getCreatedLoanId()); + if (body.getContent() == null || body.getContent().isEmpty()) { + throw new IllegalStateException("No Working Capital Loan transactions found"); + } + return body.getContent().stream() + .filter(t -> t.getType() != null && "loanTransactionType.recoveryRepayment".equals(t.getType().getCode())) + .filter(t -> !Boolean.TRUE.equals(t.getReversed())) + .max(Comparator.comparing(GetWorkingCapitalLoanTransactionIdResponse::getId)) + .orElseThrow(() -> new IllegalStateException("Active recovery payment transaction not found on loan")); + } + @Then("Initiating write-off undo of the Working Capital loan results an error with the following data:") public void initiateWriteOffUndoWorkingCapitalError(final DataTable table) { final Long loanId = getCreatedLoanId(); @@ -3720,6 +3761,9 @@ private void assertBalanceFieldEquals(final String field, final String expected) case "totalPaidPrincipal" -> balance.getPrincipalPaid(); case "realizedIncome" -> balance.getRealizedIncomeFromDiscountFee(); case "unrealizedIncome" -> balance.getUnrealizedIncomeFromDiscountFee(); + case "totalWrittenOff" -> balance.getTotalWrittenOff(); + case "totalRecovered" -> balance.getTotalRecovered(); + case "writtenOffOutstanding" -> balance.getWrittenOffOutstanding(); default -> throw new IllegalArgumentException("Unknown balance field: " + field); }; assertNotNull(actual, "Balance field " + field + " should not be null"); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRecoveryPayment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRecoveryPayment.feature new file mode 100644 index 00000000000..e139d366ab7 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRecoveryPayment.feature @@ -0,0 +1,152 @@ +@WorkingCapital +@WorkingCapitalLoanRecoveryPaymentFeature +Feature: Working Capital Loan Recovery Payment + + @TestRailId:C94030 + Scenario: Verify Working Capital Recovery Payment: full cycle write-off, recovery, undo recovery, undo write-off - UC1 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 100 | 1000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + 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" +# --- write off the loan --- # + When Admin sets the business date to "15 January 2026" + And Admin writes off the Working Capital loan on "15 January 2026" + Then Working Capital loan status will be "CLOSED_WRITTEN_OFF" + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 0.0 | + | totalWrittenOff | 100.0 | + | totalRecovered | 0.0 | + | writtenOffOutstanding | 100.0 | +# --- collect a partial recovery --- # + When Admin sets the business date to "20 January 2026" + And Admin makes a recovery payment of "40" on the Working Capital loan on "20 January 2026" + Then Working Capital Loan Transactions tab has a "RECOVERY_REPAYMENT" transaction with date "20 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 40.0 | | + | INCOME | 744008 | Recoveries | | 40.0 | +# --- the recovery is income, not a repayment: the loan stays closed and the balance stays zeroed --- # + Then Working Capital loan status will be "CLOSED_WRITTEN_OFF" + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 0.0 | + | totalPaidPrincipal | 0.0 | + | totalWrittenOff | 100.0 | + | totalRecovered | 40.0 | + | writtenOffOutstanding | 60.0 | +# --- the recovery carries no allocation, so its portion columns come back null, not zero --- # + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Close (as written-off) | 100.0 | 100.0 | 0.0 | 0.0 | false | + | 20 January 2026 | Repayment (after write-off) | 40.0 | | | | false | +# --- reverse the recovery: the mirror entries are appended and the money becomes recoverable again --- # + When Admin undoes the last recovery payment on the Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "RECOVERY_REPAYMENT" transaction with date "20 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 40.0 | | + | INCOME | 744008 | Recoveries | | 40.0 | + | INCOME | 744008 | Recoveries | 40.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 40.0 | + And Working Capital loan status will be "CLOSED_WRITTEN_OFF" + And Working Capital loan balance payload contains the following fields: + | field | value | + | totalRecovered | 0.0 | + | writtenOffOutstanding | 100.0 | +# --- with nothing recovered, the write-off can be undone --- # + When Admin undoes the write-off on the Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And Working Capital loan balance principalOutstanding is "100.0" + Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + + @TestRailId:C94031 + Scenario: Verify Working Capital Recovery Payment: successive recoveries cannot collect more than was written off - UC2 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 100 | 1000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + When Admin sets the business date to "15 January 2026" + And Admin writes off the Working Capital loan on "15 January 2026" + And Admin makes a recovery payment of "60" on the Working Capital loan on "15 January 2026" +# --- 100 was written off and 60 recovered, so only 40 is left: a 50 recovery is rejected --- # + Then Initiating a recovery payment of "50" on the Working Capital loan on "15 January 2026" results an error with the following data: + | HTTP response code | Error message | + | 400 | cannot.be.greater.than.remaining.written.off.amount | + And Working Capital loan balance payload contains the following fields: + | field | value | + | totalRecovered | 60.0 | + | writtenOffOutstanding | 40.0 | +# --- exactly the remainder is accepted, and then nothing more --- # + When Admin makes a recovery payment of "40" on the Working Capital loan on "15 January 2026" + Then Working Capital loan balance payload contains the following fields: + | field | value | + | totalRecovered | 100.0 | + | writtenOffOutstanding | 0.0 | + Then Initiating a recovery payment of "1" on the Working Capital loan on "15 January 2026" results an error with the following data: + | HTTP response code | Error message | + | 400 | cannot.be.greater.than.remaining.written.off.amount | + + @TestRailId:C94032 + Scenario: Verify Working Capital Recovery Payment: the write-off cannot be undone while a recovery stands - UC3 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 100 | 1000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + When Admin sets the business date to "15 January 2026" + And Admin writes off the Working Capital loan on "15 January 2026" + And Admin makes a recovery payment of "40" on the Working Capital loan on "15 January 2026" +# --- undoing the write-off would restore the full outstanding while the recovered cash stays booked as income --- # + Then Initiating write-off undo of the Working Capital loan results an error with the following data: + | HTTP response code | Error message | + | 400 | cannot.undo.write.off.with.recovery.payments | + And Working Capital loan status will be "CLOSED_WRITTEN_OFF" +# --- reversing the recovery clears the way --- # + When Admin undoes the last recovery payment on the Working Capital loan + And Admin undoes the write-off on the Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And Working Capital loan balance principalOutstanding is "100.0" + Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" + + @TestRailId:C94033 + Scenario: Verify Working Capital Recovery Payment: a recovery is rejected on a loan that is not written off - UC4 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 100 | 1000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + 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 a recovery payment of "40" on the Working Capital loan on "01 January 2026" results an error with the following data: + | HTTP response code | Error message | + | 400 | error.msg.wc.loan.is.not.written.off | + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + @TestRailId:C94034 + Scenario: Verify Working Capital Recovery Payment: the transaction template offers the remaining recoverable amount - UC5 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 100 | 1000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + When Admin sets the business date to "15 January 2026" + And Admin writes off the Working Capital loan on "15 January 2026" +# --- before any recovery the template offers the whole amount written off --- # + When Admin requests the Working Capital loan transaction template for command "recoveryPayment" + Then The Working Capital loan transaction template expectedAmount is "100.0" +# --- after a partial recovery it offers the remainder, not the gross figure --- # + When Admin makes a recovery payment of "30" on the Working Capital loan on "15 January 2026" + And Admin requests the Working Capital loan transaction template for command "recoveryPayment" + Then The Working Capital loan transaction template expectedAmount is "70.0" 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..914f70ba165 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 @@ -130,6 +130,7 @@ private List plannedPostings(final WorkingCapitalLoan loan, final case LoanTransactionType.ACCRUAL -> chargeAccrualPostings(feesPortion, penaltiesPortion); case LoanTransactionType.CHARGE_OFF -> chargeOffPostings(loan, principalPortion, feesPortion, penaltiesPortion); case LoanTransactionType.WRITEOFF -> writeOffPostings(loan, principalPortion, feesPortion, penaltiesPortion, isChargedOff); + case LoanTransactionType.RECOVERY_REPAYMENT -> recoveryPaymentPostings(txn); default -> throw new NotImplementedException( "Post Journal Entries is not implemented yet for " + txn.getTypeOf().getCode() + " for Working Capital Loan"); }; @@ -214,6 +215,16 @@ private List chargeOffPostings(final WorkingCapitalLoan loan, fin * negative and recognize the loss twice. *

*/ + /** + * Money collected after the loan was written off. The portfolio and receivables were already relieved by the + * write-off, so there is nothing to credit back: the whole amount is recognized as recovery income against the fund + * source. No split by principal, fee or penalty - the transaction carries no allocation. + */ + private List recoveryPaymentPostings(final WorkingCapitalLoanTransaction txn) { + return List.of(LedgerPosting.debit(CashAccountsForLoan.FUND_SOURCE, txn.getTransactionAmount()), + LedgerPosting.creditWithoutPaymentDetail(CashAccountsForLoan.INCOME_FROM_RECOVERY, txn.getTransactionAmount())); + } + private List writeOffPostings(final WorkingCapitalLoan loan, final BigDecimal principalPortion, final BigDecimal feesPortion, final BigDecimal penaltiesPortion, final boolean isChargedOff) { if (isChargedOff) { diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index bfc19218e6a..d7112add0f4 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -126,13 +126,15 @@ public void givenAllConfigurationWhenValidatedThenValidationSuccessful() throws "WorkingCapitalLoanUndoDisbursalBusinessEvent", "WorkingCapitalLoanStatusChangedBusinessEvent", "WorkingCapitalLoanBalanceChangedBusinessEvent", "WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent", "WorkingCapitalLoanWrittenOffBusinessEvent", "WorkingCapitalLoanUndoWrittenOffBusinessEvent", - "WorkingCapitalLoanPeriodPaymentRateChangedBusinessEvent", "WorkingCapitalLoanDelinquencyScheduleChangedBusinessEvent", - "WorkingCapitalLoanDelinquencyDisableBusinessEvent", "WorkingCapitalLoanDelinquencyEnableBusinessEvent", - "WorkingCapitalLoanBreachScheduleChangedBusinessEvent", "WorkingCapitalLoanBreachDisableBusinessEvent", - "WorkingCapitalLoanBreachEnableBusinessEvent", "WorkingCapitalLoanChargeOffBusinessEvent", - "WorkingCapitalLoanFraudChangedBusinessEvent", "WorkingCapitalLoanPayoutRefundTransactionBusinessEvent", - "WorkingCapitalLoanGoodwillCreditTransactionBusinessEvent", "WorkingCapitalLoanTransactionReversedBusinessEvent", - "WorkingCapitalLoanChargeOffTransactionBusinessEvent", "WorkingCapitalLoanDiscountFeeAmortizationTransactionBusinessEvent", + "WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent", + "WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent", "WorkingCapitalLoanPeriodPaymentRateChangedBusinessEvent", + "WorkingCapitalLoanDelinquencyScheduleChangedBusinessEvent", "WorkingCapitalLoanDelinquencyDisableBusinessEvent", + "WorkingCapitalLoanDelinquencyEnableBusinessEvent", "WorkingCapitalLoanBreachScheduleChangedBusinessEvent", + "WorkingCapitalLoanBreachDisableBusinessEvent", "WorkingCapitalLoanBreachEnableBusinessEvent", + "WorkingCapitalLoanChargeOffBusinessEvent", "WorkingCapitalLoanFraudChangedBusinessEvent", + "WorkingCapitalLoanPayoutRefundTransactionBusinessEvent", "WorkingCapitalLoanGoodwillCreditTransactionBusinessEvent", + "WorkingCapitalLoanTransactionReversedBusinessEvent", "WorkingCapitalLoanChargeOffTransactionBusinessEvent", + "WorkingCapitalLoanDiscountFeeAmortizationTransactionBusinessEvent", "WorkingCapitalLoanDiscountFeeAmortizationAdjustmentTransactionBusinessEvent", "WorkingCapitalLoanAddChargeBusinessEvent", "WorkingCapitalLoanJournalEntryCreatedBusinessEvent", "WorkingCapitalLoanBreachPastDueChangeBusinessEvent", "WorkingCapitalLoanUndoChargeOffBusinessEvent", "WorkingCapitalLoanBreachChangeBusinessEvent", diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent.java new file mode 100644 index 00000000000..e4c8b86ebfc --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +public class WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent extends WorkingCapitalLoanTransactionBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent"; + + public WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent(final WorkingCapitalLoanTransaction value) { + super(value); + } + + public WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent(final WorkingCapitalLoanTransaction value, + final Long aggregateRootId) { + super(value, aggregateRootId); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent.java new file mode 100644 index 00000000000..7b846c2e36d --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +public class WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent extends WorkingCapitalLoanTransactionBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent"; + + public WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent(final WorkingCapitalLoanTransaction value) { + super(value); + } + + public WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent(final WorkingCapitalLoanTransaction value, + final Long aggregateRootId) { + super(value, aggregateRootId); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java index 32285f1bff3..b3eaf5d19dc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java @@ -61,6 +61,7 @@ private WorkingCapitalLoanConstants() { public static final String UNDO_CHARGE_OFF_LOAN_COMMAND = "undoChargeOff"; public static final String WRITE_OFF_LOAN_COMMAND = "writeOff"; public static final String UNDO_WRITE_OFF_LOAN_COMMAND = "undoWriteOff"; + public static final String RECOVERY_PAYMENT_LOAN_COMMAND = "recoveryPayment"; // Approval / Rejection / Undo-approval parameters public static final String RESOURCE_NAME = WCL_RESOURCE_NAME; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index ce5daaa5a85..be405fb4d0c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -409,6 +409,18 @@ private GetBalance() {} public BigDecimal penaltyPaid; @Schema(example = "10000.00") public BigDecimal penaltyOutstanding; + @Schema(example = "10000.00", description = "Principal moved out of the outstanding balance by a write-off") + public BigDecimal principalWrittenOff; + @Schema(example = "0.00", description = "Fees moved out of the outstanding balance by a write-off") + public BigDecimal feeWrittenOff; + @Schema(example = "0.00", description = "Penalties moved out of the outstanding balance by a write-off") + public BigDecimal penaltyWrittenOff; + @Schema(example = "10000.00", description = "Gross amount written off; not reduced by recoveries") + public BigDecimal totalWrittenOff; + @Schema(example = "2000.00", description = "Collected after the write-off and recognized as recovery income") + public BigDecimal totalRecovered; + @Schema(example = "8000.00", description = "Still recoverable (totalWrittenOff - totalRecovered); caps the next recovery payment") + public BigDecimal writtenOffOutstanding; @Schema(example = "10000.00") public BigDecimal realizedIncomeFromDiscountFee; @Schema(example = "10000.00") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java index 858f395c759..6980f9392ac 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java @@ -188,7 +188,7 @@ private WorkingCapitalLoanCommandTemplateData handleLoanTransactionTemplate(fina @Path("{loanId}/transactions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "executeWorkingCapitalLoanTransactionById", summary = "Execute Working Capital Loan transaction", description = "Supported command query parameter: repayment, creditBalanceRefund, discountFee, discountFeeAdjustment") + @Operation(operationId = "executeWorkingCapitalLoanTransactionById", summary = "Execute Working Capital Loan transaction", description = "Supported command query parameter: repayment, creditBalanceRefund, payoutRefund, goodwillCredit, discountFee, discountFeeAdjustment, chargeOff, undoChargeOff, writeOff, undoWriteOff, recoveryPayment") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsResponse.class))) }) @@ -203,7 +203,7 @@ public CommandProcessingResult executeLoanTransactionById( @Path("external-id/{loanExternalId}/transactions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "executeWorkingCapitalLoanTransactionByExternalId", summary = "Execute Working Capital Loan transaction by external id", description = "Supported command query parameter: repayment, creditBalanceRefund, discountFee, discountFeeAdjustment") + @Operation(operationId = "executeWorkingCapitalLoanTransactionByExternalId", summary = "Execute Working Capital Loan transaction by external id", description = "Supported command query parameter: repayment, creditBalanceRefund, payoutRefund, goodwillCredit, discountFee, discountFeeAdjustment, chargeOff, undoChargeOff, writeOff, undoWriteOff, recoveryPayment") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsResponse.class))) }) @@ -240,6 +240,8 @@ private CommandProcessingResult executeTransaction(final Long loanId, final Stri commandRequest = builder.writeOffWorkingCapitalLoanTransaction(resolvedLoanId).build(); } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.UNDO_WRITE_OFF_LOAN_COMMAND)) { commandRequest = builder.undoWriteOffWorkingCapitalLoanTransaction(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.RECOVERY_PAYMENT_LOAN_COMMAND)) { + commandRequest = builder.recoveryPaymentWorkingCapitalLoanTransaction(resolvedLoanId).build(); } else { throw new UnrecognizedQueryParamException("command", commandParam); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java index 6e28e174b60..c271f66075c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java @@ -202,7 +202,7 @@ private PostWorkingCapitalLoanTransactionsRequest() {} public String transactionDate; @Schema(example = "42", description = "Disbursement transaction id for discountFee; discount fee transaction id for discountFeeAdjustment") public Long relatedResourceId; - @Schema(example = "100.0", description = "Transaction amount") + @Schema(example = "100.0", description = "Transaction amount. For command=recoveryPayment it may not exceed the loan's writtenOffOutstanding") public BigDecimal transactionAmount; @Schema(example = "12", description = "Optional code value id for transaction classification") public Long classificationId; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBalanceData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBalanceData.java index b17318a6f84..02798c70385 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBalanceData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBalanceData.java @@ -47,6 +47,19 @@ public class WorkingCapitalLoanBalanceData implements Serializable { private BigDecimal penalty; private BigDecimal penaltyPaid; private BigDecimal penaltyOutstanding; + /** + * Portions moved out of the outstanding balance by a write-off. Without them the exposed figures cannot be + * reconciled: {@code principalOutstanding} already nets these off, so a written-off loan reports a gross principal + * and a zero outstanding with nothing in between to explain the difference. + */ + private BigDecimal principalWrittenOff; + private BigDecimal feeWrittenOff; + private BigDecimal penaltyWrittenOff; + private BigDecimal totalWrittenOff; + /** Collected on the loan after it was written off. Recovery income; it does not reduce {@code totalWrittenOff}. */ + private BigDecimal totalRecovered; + /** What is still recoverable: {@code totalWrittenOff - totalRecovered}. Caps the next recovery payment. */ + private BigDecimal writtenOffOutstanding; private BigDecimal realizedIncomeFromDiscountFee; private BigDecimal unrealizedIncomeFromDiscountFee; private BigDecimal overpaymentAmount; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java index 8cc39a1e400..f92855bcbe2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java @@ -92,6 +92,15 @@ public class WorkingCapitalLoanBalance extends AbstractAuditableWithUTCDateTimeC @Setter private BigDecimal penaltyWrittenOff = BigDecimal.ZERO; + /** + * Money collected on the loan after it was written off. It is recovery income, not a repayment: it never lowers the + * outstanding balance (already zero) and never touches the paid columns. Its only balance-side role is to cap how + * much more can still be recovered - see {@link #getWrittenOffOutstanding()}. + */ + @Column(name = "total_recovered", scale = 6, precision = 19, nullable = false) + @Setter + private BigDecimal totalRecovered = BigDecimal.ZERO; + @Column(name = "realized_income_from_discount_fee", scale = 6, precision = 19, nullable = false) @Setter private BigDecimal realizedIncomeFromDiscountFee = BigDecimal.ZERO; @@ -156,6 +165,23 @@ public BigDecimal getTotalOutstanding() { return MathUtil.add(getPrincipalOutstanding()).add(getFeeOutstanding()).add(getPenaltyOutstanding()); } + /** + * Everything the write-off moved out of the outstanding balance. This is the gross amount that was written off; it + * is not reduced by recoveries. + */ + public BigDecimal getTotalWrittenOff() { + return MathUtil.add(getPrincipalWrittenOff(), getFeeWrittenOff(), getPenaltyWrittenOff()); + } + + /** + * How much of the written-off amount is still recoverable: the gross written off less what has already been + * collected. A recovery payment may not exceed this, so successive recoveries cannot add up past what was written + * off. + */ + public BigDecimal getWrittenOffOutstanding() { + return MathUtil.subtract(getTotalWrittenOff(), getTotalRecovered()).max(BigDecimal.ZERO); + } + public BigDecimal getTotalExpectedRepayment() { return MathUtil.add(getPrincipal()).add(getPrincipalAdjustment()).add(getPenalty()).add(getFee()); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java index bb2d14b1db7..98eea5966d6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java @@ -252,6 +252,18 @@ public static WorkingCapitalLoanTransaction writeOff(final WorkingCapitalLoan lo return txn; } + /** + * A recovery payment collects money on a loan that was already written off. It carries no allocation: the balance + * was zeroed by the write-off and stays that way, so the amount is recognized as recovery income rather than + * applied against principal, fees or penalties. The loan keeps its {@code CLOSED_WRITTEN_OFF} status. + */ + public static WorkingCapitalLoanTransaction recoveryPayment(final WorkingCapitalLoan loan, final BigDecimal amount, + final PaymentDetail paymentDetail, final LocalDate transactionDate, final ExternalId externalId) { + final WorkingCapitalLoanTransaction txn = new WorkingCapitalLoanTransaction(); + txn.initialize(loan, LoanTransactionType.RECOVERY_REPAYMENT, transactionDate, amount, paymentDetail, null, externalId); + return txn; + } + private void initialize(final WorkingCapitalLoan loan, final LoanTransactionType transactionType, final LocalDate transactionDate, final BigDecimal amount, final PaymentDetail paymentDetail, final CodeValue classification, final ExternalId externalId) { this.wcLoan = loan; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanRecoveryPaymentCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanRecoveryPaymentCommandHandler.java new file mode 100644 index 00000000000..9364a29638a --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanRecoveryPaymentCommandHandler.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.domain.CommandWrapperConstants; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanRecoveryPaymentWriteService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = CommandWrapperConstants.ENTITY_WORKINGCAPITALLOAN, action = CommandWrapperConstants.ACTION_RECOVERYPAYMENT) +public class WorkingCapitalLoanRecoveryPaymentCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanRecoveryPaymentWriteService recoveryPaymentWriteService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.recoveryPaymentWriteService.recoveryPayment(command.getLoanId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index 1c12f01b8a8..930d92b032b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; 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.portfolio.client.exception.ClientNotActiveException; import org.apache.fineract.portfolio.loanaccount.domain.ExpectedDisbursementDateValidator; import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; @@ -51,6 +52,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.NearBreachActionType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodPaymentRateHistoryHelper; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; @@ -122,6 +124,10 @@ public class WorkingCapitalLoanDataValidator { WorkingCapitalLoanConstants.noteParamName, WorkingCapitalLoanConstants.externalIdParameterName)); private static final Set UNDO_WRITE_OFF_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.reversalExternalIdParamName, WorkingCapitalLoanConstants.noteParamName)); + private static final Set RECOVERY_PAYMENT_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.transactionDateParamName, + WorkingCapitalLoanConstants.transactionAmountParamName, WorkingCapitalLoanConstants.noteParamName, + WorkingCapitalLoanConstants.paymentDetailsParamName, WorkingCapitalLoanConstants.externalIdParameterName)); private static final Set CHARGE_OFF_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.transactionDateParamName, WorkingCapitalLoanConstants.chargeOffReasonIdParamName, @@ -448,14 +454,7 @@ public void validateDisbursement(final String json, final WorkingCapitalLoan loa this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DISBURSAL_SUPPORTED_PARAMETERS); final JsonElement element = this.fromApiJsonHelper.parse(json); - if (element != null && element.isJsonObject()) { - final JsonObject root = element.getAsJsonObject(); - if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) - && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { - final String paymentDetailsJson = root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName).toString(); - this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, paymentDetailsJson, PAYMENT_DETAILS_SUPPORTED_PARAMETERS); - } - } + validatePaymentDetailsParameters(typeOfMap, element); final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) @@ -562,6 +561,19 @@ public void validateDisbursement(final String json, final WorkingCapitalLoan loa * Validates payment details inside paymentDetails object: paymentTypeId integerGreaterThanZero when present; * accountNumber, checkNumber, routingCode, receiptNumber, bankNumber notExceedingLengthOf(50) when present. */ + /** Rejects unknown keys inside the nested {@code paymentDetails} object, which the top-level check cannot see. */ + private void validatePaymentDetailsParameters(final Type typeOfMap, final JsonElement element) { + if (element == null || !element.isJsonObject()) { + return; + } + final JsonObject root = element.getAsJsonObject(); + if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) + && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { + final String paymentDetailsJson = root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName).toString(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, paymentDetailsJson, PAYMENT_DETAILS_SUPPORTED_PARAMETERS); + } + } + private void validatePaymentDetails(final DataValidatorBuilder baseDataValidator, final JsonElement element) { final JsonElement paymentDetailsElement = resolvePaymentDetailsElement(element); final Integer paymentTypeId = this.fromApiJsonHelper @@ -664,14 +676,7 @@ public void validateRepayment(final String json, final WorkingCapitalLoan loan, this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, REPAYMENT_SUPPORTED_PARAMETERS); final JsonElement element = this.fromApiJsonHelper.parse(json); - if (element != null && element.isJsonObject()) { - final JsonObject root = element.getAsJsonObject(); - if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) - && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { - final String paymentDetailsJson = root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName).toString(); - this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, paymentDetailsJson, PAYMENT_DETAILS_SUPPORTED_PARAMETERS); - } - } + validatePaymentDetailsParameters(typeOfMap, element); final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) @@ -807,6 +812,15 @@ public void validateUndoWriteOff(final JsonCommand command, final WorkingCapital baseDataValidator.reset().parameter("loanStatus").failWithCode("error.msg.wc.loan.is.not.written.off"); } + // Undoing the write-off restores the full outstanding balance. Any money already collected as recovery income + // would then also be replayed against that restored balance, so the same cash would both be recognized as + // income and reduce the receivable. The recoveries have to be reversed first. + final WorkingCapitalLoanBalance balance = loan.getBalance(); + if (balance != null && MathUtil.isGreaterThanZero(balance.getTotalRecovered())) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("cannot.undo.write.off.with.recovery.payments"); + } + if (hasBody) { final JsonElement element = this.fromApiJsonHelper.parse(json); validateTransactionExternalId(baseDataValidator, element, WorkingCapitalLoanConstants.reversalExternalIdParamName); @@ -818,23 +832,120 @@ public void validateUndoWriteOff(final JsonCommand command, final WorkingCapital throwExceptionIfValidationWarningsExist(dataValidationErrors); } - public void validateCreditBalanceRefund(final String json, final WorkingCapitalLoan loan) { + /** + * A recovery payment collects money on a loan that was already written off, so it is the one monetary transaction + * allowed while the loan sits in {@code CLOSED_WRITTEN_OFF}. The amount is capped by what is still recoverable + * rather than by the gross amount written off, so successive recoveries cannot add up past the loss that was + * booked. + */ + public void validateRecoveryPayment(final JsonCommand command, final WorkingCapitalLoan loan) { + final String json = command.json(); if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); } final Type typeOfMap = new TypeToken>() {}.getType(); - this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CREDIT_BALANCE_REFUND_SUPPORTED_PARAMETERS); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, RECOVERY_PAYMENT_SUPPORTED_PARAMETERS); final JsonElement element = this.fromApiJsonHelper.parse(json); - if (element != null && element.isJsonObject()) { - final JsonObject root = element.getAsJsonObject(); - if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) - && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { - final String paymentDetailsJson = root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName).toString(); - this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, paymentDetailsJson, PAYMENT_DETAILS_SUPPORTED_PARAMETERS); + validatePaymentDetailsParameters(typeOfMap, element); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + + if (loan.getLoanStatus() == null || !loan.getLoanStatus().isClosedWrittenOff()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("error.msg.wc.loan.is.not.written.off"); + } + + final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed(WorkingCapitalLoanConstants.transactionDateParamName, + element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName).value(transactionDate).notNull(); + if (transactionDate != null) { + if (DateUtils.isDateInTheFuture(transactionDate)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName).value(transactionDate) + .failWithCode("cannot.be.a.future.date"); + } + // The write-off is itself a user transaction, so this also keeps a recovery from predating the write-off + // that made it possible. + final LocalDate lastUserTransactionDate = this.transactionFinder.getLastUserTransactionDate(loan).orElse(null); + if (lastUserTransactionDate != null && DateUtils.isBefore(transactionDate, lastUserTransactionDate)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName).value(transactionDate) + .failWithCode("cannot.be.before.last.transaction.date"); + } + } + + final BigDecimal transactionAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionAmountParamName).value(transactionAmount).notNull() + .positiveAmount(); + + final WorkingCapitalLoanBalance balance = loan.getBalance(); + if (transactionAmount != null && balance != null) { + final BigDecimal recoverable = balance.getWrittenOffOutstanding(); + if (transactionAmount.compareTo(recoverable) > 0) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionAmountParamName).value(transactionAmount) + .failWithCode("cannot.be.greater.than.remaining.written.off.amount", recoverable); } } + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + + validateTransactionExternalId(baseDataValidator, element, WorkingCapitalLoanConstants.externalIdParameterName); + validatePaymentDetails(baseDataValidator, element); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + /** + * Reversing a recovery payment is allowed only while the loan is still written off: the loan must be back in the + * state the recovery was collected in, so that undoing it simply gives back the recoverable amount. + */ + public void validateUndoRecoveryPayment(final JsonCommand command, final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction transaction) { + final String json = command.getJsonCommand(); + final boolean hasBody = StringUtils.isNotBlank(json); + if (hasBody) { + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UNDO_TRANSACTION_SUPPORTED_PARAMETERS); + } + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + + if (transaction.isReversed()) { + baseDataValidator.reset().parameter("transaction").failWithCode("transaction.already.undone", transaction.getId()); + } + + if (loan.getLoanStatus() == null || !loan.getLoanStatus().isClosedWrittenOff()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("error.msg.wc.loan.is.not.written.off"); + } + + if (hasBody) { + final JsonElement element = this.fromApiJsonHelper.parse(json); + validateTransactionExternalId(baseDataValidator, element, WorkingCapitalLoanConstants.reversalExternalIdParamName); + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateCreditBalanceRefund(final String json, final WorkingCapitalLoan loan) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CREDIT_BALANCE_REFUND_SUPPORTED_PARAMETERS); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + validatePaymentDetailsParameters(typeOfMap, element); + final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteService.java new file mode 100644 index 00000000000..cf861c98f3c --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteService.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +/** + * Collects money on a loan that was already written off, and reverses that collection. A recovery payment is the only + * monetary transaction allowed while the loan is in {@code CLOSED_WRITTEN_OFF}: it recognizes recovery income without + * touching the zeroed balance or the loan status. + */ +public interface WorkingCapitalLoanRecoveryPaymentWriteService { + + CommandProcessingResult recoveryPayment(Long loanId, JsonCommand command); + + /** + * Reverses a recovery payment. The loan and the transaction are resolved by the caller, which dispatches the + * generic transaction-undo command by transaction type. + */ + CommandProcessingResult undoRecoveryPayment(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction transaction, JsonCommand command); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteServiceImpl.java new file mode 100644 index 00000000000..8d5cf2b8de5 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteServiceImpl.java @@ -0,0 +1,215 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import com.google.gson.JsonElement; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +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.WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * A recovery payment is money collected after the loan was written off. It is deliberately NOT run through the + * repayment pipeline: the balance was zeroed by the write-off and must stay that way, so the transaction carries no + * allocation, moves no principal, fee or penalty, and leaves the amortization, delinquency and breach schedules alone. + * The only balance-side effect is the running {@code totalRecovered}, which caps how much more can be recovered. + *

+ * The loan status is likewise untouched: it stays {@code CLOSED_WRITTEN_OFF}. Calling the state machine here would be + * wrong twice over - there is no transition to make, and {@code determineAndTransition} would see a zero outstanding + * and try {@code LOAN_REPAID_IN_FULL}, which throws from {@code CLOSED_WRITTEN_OFF}. + *

+ */ +@Service +@RequiredArgsConstructor +public class WorkingCapitalLoanRecoveryPaymentWriteServiceImpl implements WorkingCapitalLoanRecoveryPaymentWriteService { + + private final WorkingCapitalLoanRepository loanRepository; + private final WorkingCapitalLoanDataValidator validator; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanNoteRepository noteRepository; + private final PaymentDetailWritePlatformService paymentDetailService; + private final ExternalIdFactory externalIdFactory; + private final FromJsonHelper fromApiJsonHelper; + private final WorkingCapitalLoanAccountingProcessor accountingProcessor; + private final BusinessEventNotifierService businessEventNotifierService; + + @Transactional + @Override + public CommandProcessingResult recoveryPayment(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + this.validator.validateRecoveryPayment(command, loan); + + final LocalDate transactionDate = command.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.transactionDateParamName); + final BigDecimal transactionAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, command.parsedJson(), new HashSet<>()); + + final Map changes = new LinkedHashMap<>(); + changes.put(WorkingCapitalLoanConstants.transactionDateParamName, transactionDate); + changes.put(WorkingCapitalLoanConstants.transactionAmountParamName, transactionAmount); + final PaymentDetail paymentDetail = createAndPersistPaymentDetailFromCommand(command, changes); + + final ExternalId externalId = this.externalIdFactory.createFromCommand(command, + WorkingCapitalLoanConstants.externalIdParameterName); + final WorkingCapitalLoanTransaction transaction = WorkingCapitalLoanTransaction.recoveryPayment(loan, transactionAmount, + paymentDetail, transactionDate, externalId); + this.transactionRepository.saveAndFlush(transaction); + + final WorkingCapitalLoanBalance balance = requireBalance(loan); + balance.setTotalRecovered(MathUtil.add(balance.getTotalRecovered(), transactionAmount)); + this.balanceRepository.saveAndFlush(balance); + + createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName), loan, changes); + + postJournalEntries(loan, transaction); + this.businessEventNotifierService + .notifyPostBusinessEvent(new WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent(transaction, loan.getId())); + + return buildResult(command, loan, transaction, changes); + } + + @Transactional + @Override + public CommandProcessingResult undoRecoveryPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction transaction, + final JsonCommand command) { + this.validator.validateUndoRecoveryPayment(command, loan, transaction); + + final ExternalId reversalExternalId = this.externalIdFactory.createFromCommand(command, + WorkingCapitalLoanConstants.reversalExternalIdParamName); + transaction.setReversed(true); + transaction.setReversalExternalId(reversalExternalId); + transaction.setReversedOnDate(DateUtils.getBusinessLocalDate()); + this.transactionRepository.saveAndFlush(transaction); + + // Giving back the recovered amount restores what is still recoverable, so the reversed money can be collected + // again. Floored at zero so a repaired or partially migrated total cannot drive the running figure negative. + final WorkingCapitalLoanBalance balance = requireBalance(loan); + balance.setTotalRecovered(MathUtil.subtract(balance.getTotalRecovered(), transaction.getTransactionAmount()).max(BigDecimal.ZERO)); + this.balanceRepository.saveAndFlush(balance); + + final Map changes = new LinkedHashMap<>(); + changes.put("reversed", true); + changes.put(WorkingCapitalLoanConstants.reversalExternalIdParamName, reversalExternalId); + changes.put("reversedOnDate", transaction.getReversedOnDate()); + // The note parameter is only readable when the request carried a body; the command permits none at all. + final String noteText = command.parsedJson() != null + ? command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName) + : null; + createNote(noteText, loan, changes); + + postReversalJournalEntries(loan, transaction); + this.businessEventNotifierService + .notifyPostBusinessEvent(new WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent(transaction, loan.getId())); + + return buildResult(command, loan, transaction, changes); + } + + private void postJournalEntries(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction transaction) { + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + // No allocation: the whole amount is recovery income, not a repayment split across principal, fees and + // penalties. The processor books Dr Fund Source / Cr Income from Recovery from the transaction amount. + this.accountingProcessor.postJournalEntries(loan, transaction, null, loan.isChargedOff()); + } + } + + private void postReversalJournalEntries(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction transaction) { + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + this.accountingProcessor.postReversalJournalEntries(loan, transaction); + } + } + + /** + * A written-off loan always has a balance row - the write-off could not have zeroed it otherwise. A missing one + * means the account became inconsistent, and the recovery would silently record income against a balance nobody + * looked at. + */ + private WorkingCapitalLoanBalance requireBalance(final WorkingCapitalLoan loan) { + final WorkingCapitalLoanBalance balance = loan.getBalance(); + if (balance == null) { + throw new GeneralPlatformDomainRuleException("error.msg.wc.loan.balance.not.found", + "No balance found for Working Capital Loan " + loan.getId(), loan.getId()); + } + return balance; + } + + private PaymentDetail createAndPersistPaymentDetailFromCommand(final JsonCommand command, final Map changes) { + final JsonElement paymentDetailsElement = command.jsonElement(WorkingCapitalLoanConstants.paymentDetailsParamName); + if (paymentDetailsElement != null && paymentDetailsElement.isJsonNull()) { + return null; + } + if (paymentDetailsElement != null && paymentDetailsElement.isJsonObject()) { + final JsonCommand paymentDetailsCommand = JsonCommand.fromExistingCommand(command, paymentDetailsElement); + return this.paymentDetailService.createPaymentDetail(paymentDetailsCommand, changes); + } + return this.paymentDetailService.createPaymentDetail(command, changes); + } + + private void createNote(final String noteText, final WorkingCapitalLoan loan, final Map changes) { + if (StringUtils.isNotBlank(noteText)) { + this.noteRepository.save(WorkingCapitalLoanNote.create(loan, noteText)); + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + } + + private CommandProcessingResult buildResult(final JsonCommand command, final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction transaction, final Map changes) { + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loan.getId()) // + .withLoanExternalId(loan.getExternalId()) // + .withEntityId(transaction.getId()) // + .withEntityExternalId(transaction.getExternalId()) // + .with(changes) // + .build(); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java index bff3be66107..15106b63c7d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java @@ -112,6 +112,14 @@ public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(fin .chargeOffReasonOptions( codeValueReadPlatformService.retrieveCodeValuesByCode(WorkingCapitalLoanConstants.CHARGE_OFF_REASONS)) .build(); + } else if (WorkingCapitalLoanConstants.RECOVERY_PAYMENT_LOAN_COMMAND.equals(command)) { + // The amount to pre-fill is what is still recoverable, NOT the gross amount written off: a recovery may + // not exceed it, so offering the gross figure after a partial recovery would pre-fill a value the API + // rejects. Term loan pre-fills the gross figure and has that problem. + return WorkingCapitalLoanCommandTemplateData.builder() + .expectedAmount(wcLoan.getBalance() != null ? wcLoan.getBalance().getWrittenOffOutstanding() : BigDecimal.ZERO) + .currency(wcLoan.getLoanProduct().getCurrency().toData()) + .paymentTypeOptions(paymentTypeReadPlatformService.retrieveAllPaymentTypes()).build(); } return null; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 156fce46652..0e49c821636 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 @@ -114,6 +114,7 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; private final PaymentDetailWritePlatformService paymentDetailService; private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanRecoveryPaymentWriteService recoveryPaymentWriteService; private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; private final CodeValueRepository codeValueRepository; private final BusinessEventNotifierService businessEventNotifierService; @@ -670,6 +671,9 @@ public CommandProcessingResult undoTransaction(final Long loanId, final Long tra "Working capital loan transaction not found", WorkingCapitalLoanConstants.transactionIdParamName)); return switch (transaction.getTypeOf()) { case DISCOUNT_FEE_ADJUSTMENT -> undoDiscountFeeAdjustment(loan, transaction, command); + // A recovery payment never entered the balance, so the generic undo (which rewinds an allocation and + // replays the schedule) does not apply: it has its own reversal, allowed while the loan is written off. + case RECOVERY_REPAYMENT -> recoveryPaymentWriteService.undoRecoveryPayment(loan, transaction, command); case REPAYMENT, GOODWILL_CREDIT, CHARGE_ADJUSTMENT, PAYOUT_REFUND -> undoTransaction(loan, transaction, command); default -> throw new PlatformApiDataValidationException("validation.msg.wc.loan.transaction.undo.not.supported", "Undo is not supported for transaction type " + transaction.getTypeOf(), diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index e9ff8b41152..618a1acc1fc 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -91,4 +91,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_recovery_payment.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_recovery_payment.xml new file mode 100644 index 00000000000..dd0b1086c7a --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_recovery_payment.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'RECOVERYPAYMENT_WORKINGCAPITALLOAN' + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent' + + + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent' + + + + + + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalanceRecoveryCapTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalanceRecoveryCapTest.java new file mode 100644 index 00000000000..4f9c7d5763d --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalanceRecoveryCapTest.java @@ -0,0 +1,98 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Recovery payments are capped by what is still recoverable, not by the gross amount that was written off. Term loan + * caps each recovery against the gross figure instead, so N recoveries can each pass on their own and together collect + * more than was ever written off; Working Capital deliberately diverges here. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WorkingCapitalLoanBalanceRecoveryCapTest { + + @Mock + private WorkingCapitalLoan loan; + + private WorkingCapitalLoanBalance balance; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + MoneyHelper.initializeTenantRoundingMode("default", RoundingMode.HALF_UP.ordinal()); + balance = WorkingCapitalLoanBalance.createFor(loan); + // A loan written off for 100 principal + 10 fee + 5 penalty. + balance.setPrincipalWrittenOff(new BigDecimal("100")); + balance.setFeeWrittenOff(new BigDecimal("10")); + balance.setPenaltyWrittenOff(new BigDecimal("5")); + } + + @AfterEach + void tearDown() { + MoneyHelper.clearCacheForTenant("default"); + ThreadLocalContextUtil.reset(); + } + + @Test + void everythingWrittenOffIsRecoverableBeforeAnyRecovery() { + assertThat(balance.getTotalWrittenOff()).isEqualByComparingTo(new BigDecimal("115")); + assertThat(balance.getWrittenOffOutstanding()).isEqualByComparingTo(new BigDecimal("115")); + } + + @Test + void eachRecoveryLowersWhatIsStillRecoverable() { + balance.setTotalRecovered(new BigDecimal("40")); + assertThat(balance.getWrittenOffOutstanding()).isEqualByComparingTo(new BigDecimal("75")); + + balance.setTotalRecovered(new BigDecimal("115")); + assertThat(balance.getWrittenOffOutstanding()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + void grossWrittenOffStaysUnchangedAsRecoveriesComeIn() { + balance.setTotalRecovered(new BigDecimal("115")); + + // The gross figure is the accounting record of the loss and must not shrink; only the recoverable view does. + assertThat(balance.getTotalWrittenOff()).isEqualByComparingTo(new BigDecimal("115")); + } + + @Test + void recoverableNeverGoesNegative() { + // Defensive: a stored total that overshoots (data repair, historical migration) must not report a negative cap. + balance.setTotalRecovered(new BigDecimal("200")); + + assertThat(balance.getWrittenOffOutstanding()).isEqualByComparingTo(BigDecimal.ZERO); + } +} diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorRecoveryPaymentTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorRecoveryPaymentTest.java new file mode 100644 index 00000000000..f4291445151 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorRecoveryPaymentTest.java @@ -0,0 +1,237 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.serialization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.domain.ActionContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionFinder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Pins the recovery payment request contract, which mirrors the rules term loan applies to + * {@code ?command=recoverypayment} with one deliberate divergence. + *

+ * Term loan caps each recovery against {@code LoanSummary#getTotalWrittenOff()}, the GROSS amount written off, which is + * never reduced by the recoveries collected against it. Two recoveries of the full written-off amount therefore both + * pass on their own and together collect twice the loss. Working Capital caps against what is still recoverable + * instead, so the recoveries cannot add up past the loss that was booked. + *

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WorkingCapitalLoanDataValidatorRecoveryPaymentTest { + + private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 3, 15); + private static final LocalDate WRITE_OFF_DATE = LocalDate.of(2026, 2, 20); + + private WorkingCapitalLoanDataValidator validator; + + @Mock + private WorkingCapitalLoanTransactionFinder transactionFinder; + @Mock + private WorkingCapitalLoan loan; + + private WorkingCapitalLoanBalance balance; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, BUSINESS_DATE))); + MoneyHelper.initializeTenantRoundingMode("default", RoundingMode.HALF_UP.ordinal()); + + validator = new WorkingCapitalLoanDataValidator(new FromJsonHelper(), null, null, transactionFinder, null, null, null); + + // A loan written off for 100, with nothing recovered yet. + balance = WorkingCapitalLoanBalance.createFor(loan); + balance.setPrincipalWrittenOff(new BigDecimal("100")); + + lenient().when(loan.getLoanStatus()).thenReturn(LoanStatus.CLOSED_WRITTEN_OFF); + lenient().when(loan.getBalance()).thenReturn(balance); + lenient().when(transactionFinder.getLastUserTransactionDate(loan)).thenReturn(Optional.of(WRITE_OFF_DATE)); + } + + @AfterEach + void tearDown() { + MoneyHelper.clearCacheForTenant("default"); + ThreadLocalContextUtil.reset(); + } + + @Test + void shouldAcceptARecoveryOnTheBusinessDateWithinTheWrittenOffAmount() { + assertDoesNotThrow(() -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE, "40"), loan)); + } + + @Test + void shouldAcceptARecoveryForExactlyTheRemainingAmount() { + balance.setTotalRecovered(new BigDecimal("60")); + assertDoesNotThrow(() -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE, "40"), loan)); + } + + @Test + void shouldRejectASecondRecoveryThatWouldExceedWhatWasWrittenOff() { + // The divergence from term loan: 100 was written off, 60 already recovered, so only 40 is left. Term loan + // would still compare 50 against the gross 100 and let it through. + balance.setTotalRecovered(new BigDecimal("60")); + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE, "50"), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getUserMessageGlobalisationCode().contains("cannot.be.greater.than.remaining.written.off.amount")); + } + + @Test + void shouldRejectARecoveryLargerThanTheWrittenOffAmount() { + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE, "101"), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getUserMessageGlobalisationCode().contains("cannot.be.greater.than.remaining.written.off.amount")); + } + + @Test + void shouldRejectARecoveryOnALoanThatIsNotWrittenOff() { + when(loan.getLoanStatus()).thenReturn(LoanStatus.ACTIVE); + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE, "40"), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getUserMessageGlobalisationCode().contains("error.msg.wc.loan.is.not.written.off")); + } + + @Test + void shouldRejectARecoveryInTheFuture() { + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE.plusDays(1), "40"), loan)); + assertThat(ex.getErrors()).anyMatch(error -> error.getUserMessageGlobalisationCode().contains("cannot.be.a.future.date")); + } + + @Test + void shouldRejectARecoveryDatedBeforeTheWriteOff() { + // The write-off is a user transaction, so the "not before the last transaction" rule keeps a recovery from + // predating the write-off that made it possible. + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(recoveryCommand(WRITE_OFF_DATE.minusDays(1), "40"), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getUserMessageGlobalisationCode().contains("cannot.be.before.last.transaction.date")); + } + + @Test + void shouldAcceptARecoveryOnTheWriteOffDateItself() { + assertDoesNotThrow(() -> validator.validateRecoveryPayment(recoveryCommand(WRITE_OFF_DATE, "40"), loan)); + } + + @Test + void shouldRejectAZeroOrNegativeAmount() { + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(recoveryCommand(BUSINESS_DATE, "0"), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getParameterName().equals(WorkingCapitalLoanConstants.transactionAmountParamName)); + } + + @Test + void shouldRejectAMissingAmount() { + final JsonObject json = recoveryJson(BUSINESS_DATE); + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateRecoveryPayment(command(json), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getParameterName().equals(WorkingCapitalLoanConstants.transactionAmountParamName)); + } + + @Test + void shouldRejectTheRepaymentClassificationParameter() { + // A recovery is not a repayment: it carries no allocation, so a repayment classification has nothing to + // classify and must not be silently ignored. + final JsonObject json = recoveryJson(BUSINESS_DATE); + json.addProperty(WorkingCapitalLoanConstants.transactionAmountParamName, 40); + json.addProperty(WorkingCapitalLoanConstants.classificationIdParamName, 3L); + final UnsupportedParameterException ex = assertThrows(UnsupportedParameterException.class, + () -> validator.validateRecoveryPayment(command(json), loan)); + assertThat(ex.getUnsupportedParameters()).contains(WorkingCapitalLoanConstants.classificationIdParamName); + } + + @Test + void shouldRejectUndoWriteOffOnceARecoveryHasBeenCollected() { + // Undoing the write-off would restore the full outstanding while the recovered cash stays booked as income, + // so the same money would be counted twice. The recoveries have to be reversed first. + balance.setTotalRecovered(new BigDecimal("40")); + final JsonObject json = new JsonObject(); + json.addProperty("locale", "en"); + json.addProperty("dateFormat", "yyyy-MM-dd"); + final PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateUndoWriteOff(command(json), loan)); + assertThat(ex.getErrors()) + .anyMatch(error -> error.getUserMessageGlobalisationCode().contains("cannot.undo.write.off.with.recovery.payments")); + } + + @Test + void shouldAllowUndoWriteOffWhenNothingHasBeenRecovered() { + assertDoesNotThrow(() -> validator.validateUndoWriteOff(command(new JsonObject()), loan)); + } + + private JsonCommand recoveryCommand(final LocalDate transactionDate, final String amount) { + final JsonObject json = recoveryJson(transactionDate); + json.addProperty(WorkingCapitalLoanConstants.transactionAmountParamName, new BigDecimal(amount)); + return command(json); + } + + private JsonObject recoveryJson(final LocalDate transactionDate) { + final JsonObject json = new JsonObject(); + json.addProperty("locale", "en"); + json.addProperty("dateFormat", "yyyy-MM-dd"); + json.addProperty(WorkingCapitalLoanConstants.transactionDateParamName, transactionDate.toString()); + return json; + } + + private JsonCommand command(final JsonObject json) { + final FromJsonHelper helper = new FromJsonHelper(); + final JsonElement parsed = helper.parse(json.toString()); + return JsonCommand.from(json.toString(), parsed, helper, null, null, null, null, null, null, null, null, null, null, null, null, + null, null); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRecoveryPaymentAccountingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRecoveryPaymentAccountingTest.java new file mode 100644 index 00000000000..b24c6c62dea --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRecoveryPaymentAccountingTest.java @@ -0,0 +1,419 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.LinkedHashMap; +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.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.JournalEntryTransactionItem; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest.AccountingRuleEnum; +import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignJournalEntryHelper; +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.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for money collected on a Working Capital Loan after it was written off. + *

+ * A recovery payment is recognized as income, not as a repayment: the portfolio and receivables were already relieved + * by the write-off, so the whole amount books as Dr Fund Source / Cr Income from Recovery with no split by principal, + * fee or penalty. Reversing it posts the mirror. The loan keeps its CLOSED_WRITTEN_OFF status throughout. + *

+ *

+ * The amount is capped by what is still recoverable rather than by the gross amount written off, so successive + * recoveries cannot collect more than the loss that was booked -- and while any recovery stands, the write-off cannot + * be undone. + *

+ */ +public class WorkingCapitalLoanRecoveryPaymentAccountingTest { + + private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + private static final PostWorkingCapitalLoansLoanIdRequest CLEANUP_EMPTY_COMMAND_REQUEST = WorkingCapitalLoanApplicationTestBuilder + .buildUndoApproveRequest(); + + private final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + private final List createdLoanIds = new ArrayList<>(); + private final Map> recoveryTxnIdsByLoanId = new LinkedHashMap<>(); + private final List createdProductIds = new ArrayList<>(); + private static Long createdClientId; + + // GL accounts for accrual with deferred revenue amortization accounting + private static Account fundSourceAccount; + private static Account loanPortfolioAccount; + private static Account transfersSuspenseAccount; + private static Account incomeFromDiscountFeeAccount; + private static Account feesReceivableAccount; + private static Account penaltiesReceivableAccount; + private static Account incomeFromFeeAccount; + private static Account incomeFromPenaltyAccount; + private static Account incomeFromRecoveryAccount; + private static Account writeOffAccount; + private static Account overpaymentAccount; + private static Account deferredIncomeAccount; + private static Account chargeOffExpenseAccount; + private static Account incomeFromChargeOffFeesAccount; + private static Account incomeFromChargeOffPenaltyAccount; + + @BeforeAll + public static void setupAccounts() { + createdClientId = createClient(); + final FeignAccountHelper accountHelper = new FeignAccountHelper(FineractFeignClientHelper.getFineractFeignClient()); + fundSourceAccount = accountHelper.createLiabilityAccount("wcRpFundSource"); + loanPortfolioAccount = accountHelper.createAssetAccount("wcRpLoanPortfolio"); + transfersSuspenseAccount = accountHelper.createAssetAccount("wcRpTransfersSuspense"); + incomeFromDiscountFeeAccount = accountHelper.createIncomeAccount("wcRpIncomeDiscountFee"); + feesReceivableAccount = accountHelper.createAssetAccount("wcRpFeesReceivable"); + penaltiesReceivableAccount = accountHelper.createAssetAccount("wcRpPenaltiesReceivable"); + incomeFromFeeAccount = accountHelper.createIncomeAccount("wcRpIncomeFee"); + incomeFromPenaltyAccount = accountHelper.createIncomeAccount("wcRpIncomePenalty"); + incomeFromRecoveryAccount = accountHelper.createIncomeAccount("wcRpIncomeRecovery"); + writeOffAccount = accountHelper.createExpenseAccount("wcRpWriteOff"); + overpaymentAccount = accountHelper.createLiabilityAccount("wcRpOverpayment"); + deferredIncomeAccount = accountHelper.createLiabilityAccount("wcRpDeferredIncome"); + chargeOffExpenseAccount = accountHelper.createExpenseAccount("wcRpChargeOffExpense"); + incomeFromChargeOffFeesAccount = accountHelper.createIncomeAccount("wcRpIncomeChargeOffFees"); + incomeFromChargeOffPenaltyAccount = accountHelper.createIncomeAccount("wcRpIncomeChargeOffPenalty"); + } + + @AfterEach + void cleanupEntities() { + for (final Long loanId : createdLoanIds) { + if (loanId == null) { + continue; + } + // A standing recovery blocks the undo write-off, so give the money back first. + for (final Long recoveryTxnId : recoveryTxnIdsByLoanId.getOrDefault(loanId, List.of())) { + try { + loanHelper.undoTransactionByLoanId(loanId, recoveryTxnId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (recovery may already be reversed) + } + } + try { + loanHelper.undoWriteOffByLoanId(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoWriteOffRequest()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be written off) + } + try { + loanHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseRequest()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be disbursed / already removed) + } + try { + loanHelper.undoApprovalById(loanId, CLEANUP_EMPTY_COMMAND_REQUEST); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be approved / already removed) + } + try { + loanHelper.deleteById(loanId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may be in non-deletable state / already removed) + } + } + createdLoanIds.clear(); + recoveryTxnIdsByLoanId.clear(); + for (final Long productId : createdProductIds) { + if (productId == null) { + continue; + } + try { + productHelper.deleteWorkingCapitalLoanProductById(productId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (product may be already removed) + } + } + createdProductIds.clear(); + } + + @Test + public void testRecoveryPaymentPostsIncomeFromRecoveryJournalEntries() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + final AtomicLong recoveryTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + loanHelper.writeOffByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildWriteOffRequest(currentDate)); + recoveryTxnId.set(trackRecovery(loanId.get(), loanHelper.recoveryPaymentByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2000))))); + }); + + // The recovery does not reopen the loan: it stays closed as written off. + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assert loanData.getStatus() != null; + assertEquals("loanStatusType.closed.written.off", loanData.getStatus().getCode()); + + // Dr Fund Source 2000, Cr Income from Recovery 2000 -- one line each, no split by portion. + final List entries = getJournalEntriesForWCTransaction(recoveryTxnId.get()); + assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); + assertJournalEntry(entries, "DEBIT", fundSourceAccount, 2000.0); + assertJournalEntry(entries, "CREDIT", incomeFromRecoveryAccount, 2000.0); + } + + @Test + public void testRecoveryPaymentUpdatesTheRecoverableBalance() { + 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)); + loanHelper.writeOffByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildWriteOffRequest(currentDate)); + trackRecovery(loanId.get(), loanHelper.recoveryPaymentByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2000)))); + }); + + // The read API has to explain the zeroed outstanding and what is left to recover, or a client cannot tell a + // written-off loan from a paid one, nor why a further recovery gets rejected. + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assert loanData.getBalance() != null; + assertEquals(0, BigDecimal.valueOf(5000).compareTo(loanData.getBalance().getTotalWrittenOff())); + assertEquals(0, BigDecimal.valueOf(2000).compareTo(loanData.getBalance().getTotalRecovered())); + assertEquals(0, BigDecimal.valueOf(3000).compareTo(loanData.getBalance().getWrittenOffOutstanding())); + assertEquals(0, BigDecimal.ZERO.compareTo(loanData.getBalance().getTotalOutstanding())); + } + + @Test + public void testUndoRecoveryPaymentPostsMirrorJournalEntries() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + final AtomicLong recoveryTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + loanHelper.writeOffByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildWriteOffRequest(currentDate)); + recoveryTxnId.set(loanHelper.recoveryPaymentByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2000)))); + loanHelper.undoTransactionByLoanId(loanId.get(), recoveryTxnId.get()); + }); + + // Reversal is a mirror appended to the same transaction: Dr Income from Recovery, Cr Fund Source. + final List entries = getJournalEntriesForWCTransaction(recoveryTxnId.get()); + assertEquals(4, entries.size(), "Expected 4 journal entries (original 2 + reversal 2)"); + assertJournalEntry(entries, "DEBIT", incomeFromRecoveryAccount, 2000.0); + assertJournalEntry(entries, "CREDIT", fundSourceAccount, 2000.0); + + // Giving the money back makes it recoverable again, and the loan is still written off. + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assert loanData.getStatus() != null; + assertEquals("loanStatusType.closed.written.off", loanData.getStatus().getCode()); + assert loanData.getBalance() != null; + assertEquals(0, BigDecimal.ZERO.compareTo(loanData.getBalance().getTotalRecovered())); + assertEquals(0, BigDecimal.valueOf(5000).compareTo(loanData.getBalance().getWrittenOffOutstanding())); + } + + @Test + public void testRecoveryPaymentsCannotAddUpPastTheAmountWrittenOff() { + 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)); + loanHelper.writeOffByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildWriteOffRequest(currentDate)); + trackRecovery(loanId.get(), loanHelper.recoveryPaymentByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(3000)))); + + // 5000 was written off and 3000 already recovered, so only 2000 is left. Term loan compares each recovery + // against the gross 5000 and would let this through, collecting more than the loss that was booked. + final CallFailedRuntimeException error = loanHelper.runRecoveryPaymentByLoanIdExpectingFailure(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2500))); + assertTrue(error.getMessage() != null && error.getMessage().contains("cannot.be.greater.than.remaining.written.off.amount"), + "Expected remaining-written-off validation error, got: " + error.getMessage()); + + // Exactly the remainder is still accepted. + trackRecovery(loanId.get(), loanHelper.recoveryPaymentByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2000)))); + }); + + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assert loanData.getBalance() != null; + assertEquals(0, BigDecimal.valueOf(5000).compareTo(loanData.getBalance().getTotalRecovered())); + assertEquals(0, BigDecimal.ZERO.compareTo(loanData.getBalance().getWrittenOffOutstanding())); + } + + @Test + public void testRecoveryPaymentIsRejectedOnALoanThatIsNotWrittenOff() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate); + final CallFailedRuntimeException error = loanHelper.runRecoveryPaymentByLoanIdExpectingFailure(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(100))); + assertTrue(error.getMessage() != null && error.getMessage().contains("is.not.written.off"), + "Expected not-written-off validation error, got: " + error.getMessage()); + }); + } + + @Test + public void testUndoWriteOffIsRejectedWhileARecoveryStands() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + final AtomicLong recoveryTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + loanHelper.writeOffByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildWriteOffRequest(currentDate)); + recoveryTxnId.set(trackRecovery(loanId.get(), loanHelper.recoveryPaymentByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2000))))); + + // Undoing the write-off would restore the full outstanding while the recovered cash stays booked as + // income, so the same money would be counted twice. + final CallFailedRuntimeException error = loanHelper.runUndoWriteOffByLoanIdExpectingFailure(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildUndoWriteOffRequest()); + assertTrue(error.getMessage() != null && error.getMessage().contains("cannot.undo.write.off.with.recovery.payments"), + "Expected recovery-payments validation error, got: " + error.getMessage()); + + // Reversing the recovery clears the way. + loanHelper.undoTransactionByLoanId(loanId.get(), recoveryTxnId.get()); + loanHelper.undoWriteOffByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildUndoWriteOffRequest()); + }); + + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assert loanData.getStatus() != null; + assertEquals("loanStatusType.active", loanData.getStatus().getCode()); + } + + @Test + public void testRecoveryPaymentWithNoAccountingCreatesNoJournalEntries() { + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong recoveryTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + final String uniqueName = "WCL RpNoAcct " + 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).build()) + .getResourceId(); + createdProductIds.add(productId); + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate); + loanHelper.writeOffByLoanId(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildWriteOffRequest(currentDate)); + recoveryTxnId.set(trackRecovery(loanId, loanHelper.recoveryPaymentByLoanId(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildRecoveryPaymentRequest(currentDate, BigDecimal.valueOf(2000))))); + }); + + final List entries = getJournalEntriesForWCTransaction(recoveryTxnId.get()); + assertTrue(entries.isEmpty(), "Expected no journal entries for NONE accounting rule"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Long createAccrualWithDeferredRevenueAmortizationProduct() { + final String uniqueName = "WCL WoAcct " + 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).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()).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)); + return loanId; + } + + private List getJournalEntriesForWCTransaction(final Long wcTransactionId) { + final String transactionId = "WC" + wcTransactionId; + final FeignJournalEntryHelper journalHelper = new FeignJournalEntryHelper(FineractFeignClientHelper.getFineractFeignClient()); + final GetJournalEntriesTransactionIdResponse response = journalHelper.getJournalEntriesByTransactionId(transactionId); + if (response == null || response.getPageItems() == null) { + return List.of(); + } + return response.getPageItems(); + } + + private void assertJournalEntry(final List entries, final String expectedType, + final Account expectedAccount, final double expectedAmount) { + final boolean found = entries.stream().anyMatch(entry -> { + assert entry != null; + assert entry.getEntryType() != null; + final boolean typeMatch = expectedType.equals(entry.getEntryType().getValue()); + final boolean accountMatch = expectedAccount.getAccountID().longValue() == entry.getGlAccountId(); + final boolean amountMatch = Double.compare(expectedAmount, entry.getAmount()) == 0; + return typeMatch && accountMatch && amountMatch; + }); + assertTrue(found, "Expected journal entry: " + expectedType + " " + expectedAccount.getAccountID() + " amount=" + expectedAmount + + " not found in entries: " + entries.stream().map(e -> { + assert e.getEntryType() != null; + return e.getEntryType().getValue() + " acct=" + e.getGlAccountId() + " amt=" + e.getAmount(); + }).toList()); + } + + private static Long createClient() { + return ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + } + + private Long trackRecovery(final Long loanId, final Long recoveryTxnId) { + recoveryTxnIdsByLoanId.computeIfAbsent(loanId, key -> new ArrayList<>()).add(recoveryTxnId); + return recoveryTxnId; + } + + private Long submitAndTrack(final PostWorkingCapitalLoansRequest submitJson) { + final Long loanId = loanHelper.submit(submitJson); + createdLoanIds.add(loanId); + return loanId; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java index 9b64b52696a..89982d7c640 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java @@ -177,6 +177,8 @@ public final class ExternalEventConfigurationTestData { "WorkingCapitalLoanBalanceChangedBusinessEvent", // "WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent", // "WorkingCapitalLoanWrittenOffBusinessEvent", // + "WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent", // + "WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent", // "WorkingCapitalLoanUndoWrittenOffBusinessEvent", // "WorkingCapitalLoanPeriodPaymentRateChangedBusinessEvent", // "WorkingCapitalLoanDelinquencyScheduleChangedBusinessEvent", // diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java index 75ddcc2cbe6..8f1cdc50dfa 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java @@ -196,6 +196,19 @@ public static PostWorkingCapitalLoanTransactionsRequest buildUndoWriteOffRequest return new PostWorkingCapitalLoanTransactionsRequest().locale(DEFAULT_LOCALE); } + public static PostWorkingCapitalLoanTransactionsRequest buildRecoveryPaymentRequest(final LocalDate transactionDate, + final BigDecimal transactionAmount) { + final PostWorkingCapitalLoanTransactionsRequest request = new PostWorkingCapitalLoanTransactionsRequest().locale(DEFAULT_LOCALE) + .dateFormat(DEFAULT_DATE_FORMAT); + if (transactionDate != null) { + request.transactionDate(format(transactionDate)); + } + if (transactionAmount != null) { + request.transactionAmount(transactionAmount); + } + return request; + } + private static PostWorkingCapitalLoansLoanIdRequest baseLoanIdRequest() { return new PostWorkingCapitalLoansLoanIdRequest().locale(DEFAULT_LOCALE).dateFormat(DEFAULT_DATE_FORMAT); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java index d30f99710e5..2bb0554f190 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java @@ -167,6 +167,21 @@ public Long undoWriteOffByLoanId(final Long loanId, final PostWorkingCapitalLoan .getResourceId(); } + public Long recoveryPaymentByLoanId(final Long loanId, final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.ok(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "recoveryPayment", request)) + .getResourceId(); + } + + public CallFailedRuntimeException runRecoveryPaymentByLoanIdExpectingFailure(final Long loanId, + final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.fail(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "recoveryPayment", request)); + } + + public CallFailedRuntimeException runUndoWriteOffByLoanIdExpectingFailure(final Long loanId, + final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.fail(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "undoWriteOff", request)); + } + public void undoTransactionByLoanId(final Long loanId, final Long transactionId) { FeignCalls.ok(() -> transactionsApi().executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId(loanId, transactionId, "undo", new ExecuteWorkingCapitalLoanTransactionCommandRequest()));