From 42f9e7b15f89086ad185dd7af76956a70160dbb9 Mon Sep 17 00:00:00 2001
From: Jose Alberto Hernandez
Date: Thu, 20 Aug 2026 12:08:44 -0500
Subject: [PATCH] FINERACT-2455: Working Capital Loan - Recovery payment
transaction
---
.../service/CommandWrapperBuilder.java | 9 +
.../src/docs/en/chapters/features/index.adoc | 1 +
.../working-capital-recovery-payment.adoc | 291 ++++++++++++
.../features/working-capital-write-off.adoc | 22 +-
.../fineract/test/data/TransactionType.java | 1 +
.../WorkingCapitalLoanAccountStepDef.java | 44 ++
.../WorkingCapitalLoanRecoveryPayment.feature | 152 +++++++
...ountingProcessorForWorkingCapitalLoan.java | 11 +
...entConfigurationValidationServiceTest.java | 16 +-
...coveryPaymentTransactionBusinessEvent.java | 40 ++
...coveryPaymentTransactionBusinessEvent.java | 40 ++
.../WorkingCapitalLoanConstants.java | 1 +
.../WorkingCapitalLoanApiResourceSwagger.java | 12 +
...ingCapitalLoanTransactionsApiResource.java | 6 +-
...talLoanTransactionsApiResourceSwagger.java | 2 +-
.../data/WorkingCapitalLoanBalanceData.java | 13 +
.../domain/WorkingCapitalLoanBalance.java | 26 ++
.../domain/WorkingCapitalLoanTransaction.java | 12 +
...italLoanRecoveryPaymentCommandHandler.java | 43 ++
.../WorkingCapitalLoanDataValidator.java | 159 ++++++-
...apitalLoanRecoveryPaymentWriteService.java | 40 ++
...alLoanRecoveryPaymentWriteServiceImpl.java | 215 +++++++++
...oanTransactionReadPlatformServiceImpl.java | 8 +
...ngCapitalLoanWritePlatformServiceImpl.java | 4 +
.../module-changelog-master.xml | 1 +
.../parts/0070_wc_loan_recovery_payment.xml | 79 ++++
...kingCapitalLoanBalanceRecoveryCapTest.java | 98 ++++
...lLoanDataValidatorRecoveryPaymentTest.java | 237 ++++++++++
...italLoanRecoveryPaymentAccountingTest.java | 419 ++++++++++++++++++
.../ExternalEventConfigurationTestData.java | 2 +
...ingCapitalLoanDisbursementTestBuilder.java | 13 +
.../WorkingCapitalLoanHelper.java | 15 +
32 files changed, 1994 insertions(+), 38 deletions(-)
create mode 100644 fineract-doc/src/docs/en/chapters/features/working-capital-recovery-payment.adoc
create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRecoveryPayment.feature
create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanRecoveryPaymentTransactionBusinessEvent.java
create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanUndoRecoveryPaymentTransactionBusinessEvent.java
create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanRecoveryPaymentCommandHandler.java
create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteService.java
create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRecoveryPaymentWriteServiceImpl.java
create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_recovery_payment.xml
create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalanceRecoveryCapTest.java
create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorRecoveryPaymentTest.java
create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRecoveryPaymentAccountingTest.java
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