From 02ec8e966bfd22840abad6635a10ff4d1ef20667 Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Thu, 16 Jul 2026 22:30:50 -0500 Subject: [PATCH] FINERACT-2455: Working Capital Loan Charge-Off --- .gitignore | 2 +- .../service/CommandWrapperBuilder.java | 18 + .../src/docs/en/chapters/features/index.adoc | 1 + .../features/working-capital-charge-off.adoc | 240 ++++++++++++ .../helper/WorkingCapitalLoanTestHelper.java | 5 +- .../WorkingCapitalLoanDetails.feature | 2 +- ...ountingProcessorForWorkingCapitalLoan.java | 22 ++ .../WorkingCapitalLoanConstants.java | 3 + .../WorkingCapitalLoanApiResourceSwagger.java | 6 +- ...ingCapitalLoanTransactionsApiResource.java | 4 + ...talLoanTransactionsApiResourceSwagger.java | 2 + ...WorkingCapitalLoanCommandTemplateData.java | 5 + .../data/WorkingCapitalLoanData.java | 3 + .../domain/WorkingCapitalLoan.java | 47 +++ .../domain/WorkingCapitalLoanTransaction.java | 12 + ...ingCapitalLoanChargeOffCommandHandler.java | 45 +++ ...apitalLoanUndoChargeOffCommandHandler.java | 45 +++ .../mapper/WorkingCapitalLoanMapper.java | 9 +- .../WorkingCapitalLoanDataValidator.java | 104 +++++ ...orkingCapitalLoanChargeAccrualService.java | 2 +- ...rkingCapitalLoanChargeOffWriteService.java | 36 ++ ...gCapitalLoanChargeOffWriteServiceImpl.java | 188 +++++++++ ...talLoanChargeWritePlatformServiceImpl.java | 8 +- ...oanDiscountFeeAmortizationServiceImpl.java | 8 +- ...oanTransactionReadPlatformServiceImpl.java | 9 + ...ngCapitalLoanWritePlatformServiceImpl.java | 4 +- .../module-changelog-master.xml | 1 + .../parts/0059_wc_loan_charge_off.xml | 94 +++++ ...ingCapitalLoanChargeOffAccountingTest.java | 369 ++++++++++++++++++ ...ingCapitalLoanDisbursementTestBuilder.java | 20 + .../WorkingCapitalLoanHelper.java | 20 + .../WorkingCapitalLoanProductTestBuilder.java | 21 + 32 files changed, 1342 insertions(+), 13 deletions(-) create mode 100644 fineract-doc/src/docs/en/chapters/features/working-capital-charge-off.adoc create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanChargeOffCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanUndoChargeOffCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0059_wc_loan_charge_off.xml create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java diff --git a/.gitignore b/.gitignore index b563a9895ec..d6d0d8dd325 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ gradleExp/ .java-version .windsurf/ -docs/ +/docs/ 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 baaa6b13a1c..ed377130937 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 @@ -1287,6 +1287,24 @@ public CommandWrapperBuilder goodwillCreditWorkingCapitalLoanTransaction(final L return this; } + public CommandWrapperBuilder chargeOffWorkingCapitalLoanTransaction(final Long loanId) { + this.actionName = ACTION_CHARGEOFF; + this.entityName = ENTITY_WORKINGCAPITALLOAN; + this.entityId = loanId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/transactions?command=chargeOff"; + return this; + } + + public CommandWrapperBuilder undoChargeOffWorkingCapitalLoanTransaction(final Long loanId) { + this.actionName = ACTION_UNDOCHARGEOFF; + this.entityName = ENTITY_WORKINGCAPITALLOAN; + this.entityId = loanId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/transactions?command=undoChargeOff"; + 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 c942f6d6a84..59bd08309e0 100644 --- a/fineract-doc/src/docs/en/chapters/features/index.adoc +++ b/fineract-doc/src/docs/en/chapters/features/index.adoc @@ -23,6 +23,7 @@ include::working-capital-payment-allocation.adoc include::working-capital-amortization-schedule.adoc[leveloffset=+1] include::working-capital-discount-fee-txn.adoc[leveloffset=+1] include::working-capital-charges.adoc[leveloffset=+1] +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-delinquency-management.adoc[leveloffset=+1] diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-charge-off.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-charge-off.adoc new file mode 100644 index 00000000000..6cb46a4b3bf --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-charge-off.adoc @@ -0,0 +1,240 @@ +// +// 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 Charge-Off + +This documentation describes the Charge-Off feature for Working Capital Loans. + +Charge-off marks a loan account as charged off for accounting purposes. In the Working Capital Loan +module it is modeled as a *pure accounting tag with no portfolio impact*: the loan stays `ACTIVE`, its +schedule and balance are unchanged, and all other actions and calculations behave exactly as they do on +a loan that is not charged off. This differs from the Term Loan implementation, whose charge-off carries +interest-recalculation behaviour — Working Capital Loans have no interest concept, so that machinery does +not apply here. + +[NOTE] +==== +Charge-off is an accounting treatment only. It does not change the schedule, balance or delinquency +data returned by the existing Working Capital Loan APIs; the loan resource only gains the charged-off +state fields (see <>). The loan remains `ACTIVE` until its balance is cured, and the +charged-off tag is never removed automatically — even if the loan is subsequently paid off. It is +cleared only by an explicit undo. +==== + +== Charge-Off a Loan + +Marks the loan account as charged off and creates a non-monetary charge-off transaction. + +* *Endpoint*: `POST /working-capital-loans/{loanId}/transactions?command=chargeOff` +* *Permission*: `CHARGEOFF_WORKINGCAPITALLOAN` + +=== Supported Fields + +==== Mandatory Fields + +* `transactionDate` +** The charge-off date. It may be backdated but must not be in the future, and must not be earlier than +the last transaction date. + +* `locale` + +* `dateFormat` + +==== Optional Fields + +* `chargeOffReasonId` +** Code value id from the `ChargeOffReasons` code. + +* `note` +** Free-text note (up to 1000 characters). + +* `externalId` +** External identifier for the charge-off transaction (up to 100 characters). + +=== Behaviour + +* A non-monetary `CHARGE_OFF` transaction is created for the charge-off amount, which is the outstanding +balance as of the charge-off date. Because the charge-off date cannot precede the last transaction, the +current outstanding balance equals the as-of-date balance. +* The transaction does *not* move the loan balance and is excluded from transaction replay. +* The loan is flagged as charged off (`chargedOff = true`) and remains `ACTIVE`. +* Repayments and payment, waiver or adjustment of *existing* charges remain allowed afterwards, so the +balance can still be cured. While the loan is charged off, those credits are recognized as recovery +income and discount-fee amortization is routed to the charge-off expense (see <>). +* Goodwill credit and payout refund are not supported while the loan is charged off; posting either one +is rejected. + +.Charge-off request/response flow +[plantuml,format=svg] +.... +@startuml +actor User +participant "Transactions API" as API +participant "ChargeOff Write Service" as SVC +participant "Accounting Processor" as ACC +database "m_wc_loan" as DB + +User -> API : POST .../transactions?command=chargeOff +API -> SVC : chargeOff(loanId, command) +SVC -> SVC : validate (active, not already charged off, date rules) +SVC -> DB : mark loan charged off + create CHARGE_OFF txn +SVC -> ACC : post charge-off journal entries +SVC --> User : transaction id +@enduml +.... + +== Undo Charge-Off + +Reverses a charge-off that was applied in error. + +* *Endpoint*: `POST /working-capital-loans/{loanId}/transactions?command=undoChargeOff` +* *Permission*: `UNDOCHARGEOFF_WORKINGCAPITALLOAN` + +Undo removes the charged-off tag, reverses the charge-off transaction and reverses its journal entries. +It is only allowed when no monetary transaction has been posted after the charge-off — non-monetary and +system transactions (for example discount-fee amortization) do not block undo. + +== Charge-Off Template + +Returns the data needed to prefill the charge-off form. + +* *Endpoint*: `GET /working-capital-loans/{loanId}/template?templateType=chargeOff` + +[cols="1,3"] +|=== +|*Field* |*Description* + +|`chargeOffAmount` +|Auto-calculated outstanding balance as of the charge-off date. Read-only. + +|`chargeOffDate` +|Suggested charge-off date, defaulting to the current business date. + +|`chargeOffReasonOptions` +|Available `ChargeOffReasons` code values. + +|`currency` +|Loan currency. +|=== + +[#wc-charge-off-read-model] +== Loan Read Model + +The Working Capital Loan resource exposes the charged-off state: + +[cols="1,3"] +|=== +|*Field* |*Description* + +|`chargedOff` +|`true` when the loan is charged off, otherwise `false`. + +|`chargedOffOnDate` +|The charge-off date. Only present while the loan is charged off. + +|`chargeOffReason` +|The `ChargeOffReasons` code value selected at charge-off time, when one was provided. +|=== + +== Validations + +[cols="1,3,2"] +|=== +|*Operation* |*Rule* |*Error code* + +|Charge-off |The loan must be active. |`error.msg.wc.loan.is.not.active` +|Charge-off |The loan must not already be charged off. |`error.msg.wc.loan.is.already.charged.off` +|Charge-off |The charge-off date must not be earlier than the last transaction date. |`cannot.be.before.last.transaction.date` +|Charge-off |The charge-off date must not be in the future. |`cannot.be.a.future.date` +|Add charge |New charges cannot be added once the loan is charged off. |`error.msg.wc.loan.is.charged.off` +|Undo charge-off |The loan must be charged off. |`error.msg.wc.loan.is.not.charged.off` +|Undo charge-off |Undo is not allowed if a monetary transaction was posted after the charge-off. |`error.msg.wc.loan.charge.off.is.not.the.last.transaction` +|=== + +[NOTE] +==== +A second charge-off on an already charged-off loan, and a second undo on a loan that is not charged off, +both return a validation error. +==== + +[#wc-charge-off-accounting] +== Accounting + +Charge-off accounting applies to products configured with the *Accrual (deferred revenue amortization)* +accounting rule. The charge-off transaction writes off the outstanding receivables against the charge-off +expense. There is no interest leg — Working Capital Loans have no interest concept. + +[cols="1,2,2"] +|=== +|*Portion* |*Debit* |*Credit* + +|Principal |`CHARGE_OFF_EXPENSE` |`LOAN_PORTFOLIO` +|Fee |`INCOME_FROM_CHARGE_OFF_FEES` |`FEES_RECEIVABLE` +|Penalty |`INCOME_FROM_CHARGE_OFF_PENALTY` |`PENALTIES_RECEIVABLE` +|=== + +Undo reverses these journal entries. The charge-off General Ledger accounts are configured per product +through the Working Capital Loan product accounting mapping. + +=== While the Loan Is Charged Off + +Transactions posted while the loan carries the charged-off tag receive a different accounting +treatment: credits that would normally reduce the portfolio or receivables are recognized as recovery +income, and discount-fee amortization is recognized against the charge-off expense instead of +discount-fee income. + +[cols="2,2,3"] +|=== +|*Transaction* |*Debit* |*Credit* + +|Repayment +|`FUND_SOURCE` +|`INCOME_FROM_RECOVERY` for the principal, fee and penalty portions; `OVERPAYMENT` for any overpaid +excess + +|Charge adjustment +|`INCOME_FROM_RECOVERY` +|`LOAN_PORTFOLIO` / `FEES_RECEIVABLE` / `PENALTIES_RECEIVABLE` per portion + +|Discount-fee amortization +|`DEFERRED_INCOME_LIABILITY` +|`CHARGE_OFF_EXPENSE` (instead of `INCOME_FROM_DISCOUNT_FEE`) +|=== + +Charge accrual keeps its regular treatment (receivable against fee/penalty income). Once the +charge-off is undone, subsequent transactions return to the regular accounting treatment. + +== Permissions + +[cols="1,1,1"] +|=== +|*Code* |*Entity* |*Action* + +|`CHARGEOFF_WORKINGCAPITALLOAN` |`WORKINGCAPITALLOAN` |`CHARGEOFF` +|`UNDOCHARGEOFF_WORKINGCAPITALLOAN` |`WORKINGCAPITALLOAN` |`UNDOCHARGEOFF` +|=== + +== Out of Scope + +The following are intentionally not part of the first version: + +* Overriding the charge-off expense account per charge-off reason (currently the default +`CHARGE_OFF_EXPENSE` account is used). +* Fraud-expense routing (`CHARGE_OFF_FRAUD_EXPENSE`), which depends on the Working Capital Loan fraud +feature. diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java index 66b6a03fb7d..bb79db34aa9 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java @@ -82,7 +82,10 @@ public Long insertLoan(final LoanStatus status, final LocalDate lastClosedBusine .addValue("principal_amount_proposed", DEFAULT_PRINCIPAL)// .addValue("approved_principal", DEFAULT_PRINCIPAL)// .addValue("total_payment_volume", DEFAULT_PRINCIPAL)// - .addValue("breach_start_type", "DISBURSEMENT"); + .addValue("breach_start_type", "DISBURSEMENT")// + // SimpleJdbcInsert without usingColumns() binds explicit NULL for every unmapped + // table column, which bypasses the DB default and violates the NOT NULL constraint. + .addValue("is_charged_off", false); final Number key = wcLoanInsert.executeAndReturnKey(params); return Objects.requireNonNull(key, "Generated key must not be null").longValue(); } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature index 9b9e74c7116..5b2f4a23170 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature @@ -159,7 +159,7 @@ Feature: Working Capital Loan Details | originators.size | 0 | | enableInstallmentLevelDelinquency | true | | fraud | null | - | chargedOff | null | + | chargedOff | false | And Working capital loan details has the auto-generated fields present @TestRailId:C85424 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 134044c2d33..6be03314963 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 @@ -96,6 +96,7 @@ public void postJournalEntries(final WorkingCapitalLoan loan, final WorkingCapit case LoanTransactionType.CHARGE_ADJUSTMENT -> postChargeAdjustmentJournalEntries(loan, txn, principalPortion, feesPortion, penaltiesPortion, overpaymentPortion, isChargedOff); case LoanTransactionType.ACCRUAL -> postChargeAccrualJournalEntries(loan, txn, feesPortion, penaltiesPortion); + case LoanTransactionType.CHARGE_OFF -> postChargeOffJournalEntries(loan, txn, principalPortion, feesPortion, penaltiesPortion); default -> { throw new NotImplementedException( "Post Journal Entries is not implemented yet for " + txn.getTypeOf().getCode() + " for Working Capital Loan"); @@ -121,6 +122,27 @@ private void postChargeAdjustmentJournalEntries(final WorkingCapitalLoan loan, f accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.OVERPAYMENT, overpaymentPortion); } + /** + * Charge-off is a pure accounting tag with no portfolio impact. It writes off the outstanding receivables against + * the charge-off expense (principal) and reverses the accrued fee/penalty income. There is no interest leg -- WC + * has no interest concept. Fraud-expense routing (CHARGE_OFF_FRAUD_EXPENSE) is out of scope until the fraud feature + * lands. + */ + private void postChargeOffJournalEntries(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn, + final BigDecimal principalPortion, final BigDecimal feesPortion, final BigDecimal penaltiesPortion) { + final JournalEntryPostingHelper accountPostHelper = new JournalEntryPostingHelper(loan, txn); + + // debit: recognize the loss on principal, reverse accrued fee/penalty income + accountPostHelper.postDebitJournalEntry(CashAccountsForLoan.CHARGE_OFF_EXPENSE, principalPortion); + accountPostHelper.postDebitJournalEntry(CashAccountsForLoan.INCOME_FROM_CHARGE_OFF_FEES, feesPortion); + accountPostHelper.postDebitJournalEntry(CashAccountsForLoan.INCOME_FROM_CHARGE_OFF_PENALTY, penaltiesPortion); + + // credit: write off the outstanding receivables + accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.LOAN_PORTFOLIO, principalPortion); + accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.FEES_RECEIVABLE, feesPortion); + accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.PENALTIES_RECEIVABLE, penaltiesPortion); + } + private boolean isAdjustedChargeAPenalty(final WorkingCapitalLoanTransaction txn) { return txn.getLoanTransactionRelations().stream() .filter(relation -> relation.getToCharge() != null 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 6e0ddc959ea..39d11ad22b6 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 @@ -52,6 +52,8 @@ private WorkingCapitalLoanConstants() { public static final String PAYOUT_REFUND_COMMAND = "payoutRefund"; public static final String DISCOUNT_FEE_LOAN_COMMAND = "discountFee"; public static final String DISCOUNT_FEE_ADJUSTMENT_LOAN_COMMAND = "discountFeeAdjustment"; + public static final String CHARGE_OFF_LOAN_COMMAND = "chargeOff"; + public static final String UNDO_CHARGE_OFF_LOAN_COMMAND = "undoChargeOff"; // Approval / Rejection / Undo-approval parameters public static final String RESOURCE_NAME = WCL_RESOURCE_NAME; @@ -80,6 +82,7 @@ private WorkingCapitalLoanConstants() { public static final String receiptNumberParamName = "receiptNumber"; public static final String bankNumberParamName = "bankNumber"; public static final String transactionDateParamName = "transactionDate"; + public static final String chargeOffReasonIdParamName = "chargeOffReasonId"; public static final String transactionTypeParamName = "transactionType"; public static final String transactionIdParamName = "transactionId"; public static final String loanStatusParamName = "loanStatus"; 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 7f238367d38..6a0804a00d9 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 @@ -345,8 +345,12 @@ private GetWorkingCapitalLoanSummary() {} public List originators; @Schema(description = "Fraud flag. Placeholder: null until the WCP fraud feature is implemented") public Boolean fraud; - @Schema(description = "Charge-off flag. Placeholder: null until the WCP charge-off feature is implemented") + @Schema(description = "Whether the loan is charged off (pure accounting tag; the loan stays active)") public Boolean chargedOff; + @Schema(description = "Date the loan was charged off", example = "2026-07-16") + public LocalDate chargedOffOnDate; + @Schema(description = "Charge-off reason code value, when one was provided") + public CodeValueData chargeOffReason; @Schema(description = "Originator data associated with the loan") public static final class GetWorkingCapitalLoansLoanIdOriginatorData { 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 df067c57e0b..cf991735e4f 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 @@ -232,6 +232,10 @@ private CommandProcessingResult executeTransaction(final Long loanId, final Stri commandRequest = builder.discountFeeWorkingCapitalLoanTransaction(resolvedLoanId).build(); } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.DISCOUNT_FEE_ADJUSTMENT_LOAN_COMMAND)) { commandRequest = builder.discountFeeAdjustmentWorkingCapitalLoanTransaction(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.CHARGE_OFF_LOAN_COMMAND)) { + commandRequest = builder.chargeOffWorkingCapitalLoanTransaction(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.UNDO_CHARGE_OFF_LOAN_COMMAND)) { + commandRequest = builder.undoChargeOffWorkingCapitalLoanTransaction(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 816f781a9a6..f3dbc3c9da3 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 @@ -185,6 +185,8 @@ private PostWorkingCapitalLoanTransactionsRequest() {} public BigDecimal transactionAmount; @Schema(example = "12", description = "Optional code value id for transaction classification") public Long classificationId; + @Schema(example = "7", description = "Optional charge-off reason code value id (command=chargeOff)") + public Long chargeOffReasonId; @Schema(example = "Repayment note") public String note; @Schema(example = "repayment-ext-001") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java index edfaced285f..51ea8f42399 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java @@ -55,4 +55,9 @@ public class WorkingCapitalLoanCommandTemplateData implements Serializable { private Collection paymentTypeOptions; private Collection classificationOptions; + + // Charge-off template: amount is the auto-calculated outstanding balance as of the charge-off date (read-only). + private BigDecimal chargeOffAmount; + private LocalDate chargeOffDate; + private Collection chargeOffReasonOptions; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java index a8b0a3313c8..507ff288333 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java @@ -27,6 +27,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.organisation.monetary.data.CurrencyData; @@ -107,4 +108,6 @@ public class WorkingCapitalLoanData implements Serializable { private List originators; private Boolean fraud; private Boolean chargedOff; + private LocalDate chargedOffOnDate; + private CodeValueData chargeOffReason; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java index 15910897054..ca62561e5a6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -37,6 +37,7 @@ import java.util.List; import lombok.Getter; import lombok.Setter; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.client.domain.Client; @@ -170,6 +171,24 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom toDataList(List loans); @@ -105,6 +107,11 @@ default LoanStatusEnumData loanStatusData(final LoanStatus loanStatus) { return LoanEnumerations.status(loanStatus); } + @Named("chargeOffReasonData") + default CodeValueData chargeOffReasonData(final CodeValue chargeOffReason) { + return chargeOffReason != null ? chargeOffReason.toData() : null; + } + @Named("monetaryCurrencyToCurrencyData") default CurrencyData monetaryCurrencyToCurrencyData(final WorkingCapitalLoanProductRelatedDetails detail) { return (detail != null && detail.getCurrency() != null) ? detail.getCurrency().toData() : null; 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 604f5944d9e..37b7fb5e525 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 @@ -111,6 +111,13 @@ public class WorkingCapitalLoanDataValidator { WorkingCapitalLoanConstants.externalIdParameterName, WorkingCapitalLoanConstants.transactionDateParamName)); private static final Set CREDIT_BALANCE_REFUND_SUPPORTED_PARAMETERS = new HashSet<>(REPAYMENT_SUPPORTED_PARAMETERS); + private static final Set CHARGE_OFF_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("locale", "dateFormat", + WorkingCapitalLoanConstants.transactionDateParamName, WorkingCapitalLoanConstants.chargeOffReasonIdParamName, + WorkingCapitalLoanConstants.noteParamName, WorkingCapitalLoanConstants.externalIdParameterName)); + + private static final Set UNDO_CHARGE_OFF_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList("locale", WorkingCapitalLoanConstants.reversalExternalIdParamName, WorkingCapitalLoanConstants.noteParamName)); + private static final Set UPDATE_RATE_SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList(WorkingCapitalLoanConstants.localeParameterName, WorkingCapitalLoanConstants.periodPaymentRateParamName, WorkingCapitalLoanConstants.noteParamName)); @@ -952,4 +959,101 @@ public void validateUndoTransaction(JsonCommand command, WorkingCapitalLoan loan throwExceptionIfValidationWarningsExist(dataValidationErrors); } + + public void validateChargeOff(final JsonCommand command, final WorkingCapitalLoan loan) { + final String json = command.getJsonCommand(); + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CHARGE_OFF_SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + // The loan must be active; charge-off keeps it ACTIVE and has no portfolio impact. + if (!LoanStatus.ACTIVE.equals(loan.getLoanStatus())) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("error.msg.wc.loan.is.not.active"); + } + // A loan cannot be charged off twice. + if (loan.isChargedOff()) { + baseDataValidator.reset().parameter("chargedOff").failWithCode("error.msg.wc.loan.is.already.charged.off"); + } + + final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed(WorkingCapitalLoanConstants.transactionDateParamName, + element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName).value(transactionDate).notNull(); + if (transactionDate != null) { + // Charge-off can be backdated, but not into the future nor before the last transaction. + if (DateUtils.isDateInTheFuture(transactionDate)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName).value(transactionDate) + .failWithCode("cannot.be.a.future.date"); + } + final LocalDate lastTransactionDate = findLastTransactionDate(loan); + if (lastTransactionDate != null && DateUtils.isBefore(transactionDate, lastTransactionDate)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName).value(transactionDate) + .failWithCode("cannot.be.before.last.transaction.date"); + } + } + + // Charge-off reason is optional. + final Long chargeOffReasonId = this.fromApiJsonHelper.extractLongNamed(WorkingCapitalLoanConstants.chargeOffReasonIdParamName, + element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.chargeOffReasonIdParamName).value(chargeOffReasonId).ignoreIfNull() + .integerGreaterThanZero(); + + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(1000); + + validateTransactionExternalId(baseDataValidator, element, WorkingCapitalLoanConstants.externalIdParameterName); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateUndoChargeOff(final JsonCommand command, final WorkingCapitalLoan loan) { + 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_CHARGE_OFF_SUPPORTED_PARAMETERS); + } + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + + if (!loan.isChargedOff()) { + baseDataValidator.reset().parameter("chargedOff").failWithCode("error.msg.wc.loan.is.not.charged.off"); + } else { + // Undo is only allowed when no monetary transaction has been posted after the charge-off date. + final LocalDate chargeOffDate = loan.getChargedOffOnDate(); + final boolean hasMonetaryActivityAfterChargeOff = this.transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loan.getId()).stream().filter(t -> !t.isReversed()) + .filter(t -> t.getTypeOf().isRepaymentType()) + .anyMatch(t -> chargeOffDate != null && DateUtils.isAfter(t.getTransactionDate(), chargeOffDate)); + if (hasMonetaryActivityAfterChargeOff) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName) + .failWithCode("error.msg.wc.loan.charge.off.is.not.the.last.transaction"); + } + } + + 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(1000); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + private LocalDate findLastTransactionDate(final WorkingCapitalLoan loan) { + return this.transactionRepository.findByWcLoan_IdOrderByTransactionDateAscIdAsc(loan.getId()).stream().filter(t -> !t.isReversed()) + .map(WorkingCapitalLoanTransaction::getTransactionDate).max(LocalDate::compareTo).orElse(null); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java index de107978bc8..af64913b1c2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java @@ -130,7 +130,7 @@ private void createChargeAccrualIfMissing(final WorkingCapitalLoan loan, final W final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation .forChargeAccrual(accrualTransaction, charge.getAmount(), charge.isPenaltyCharge()); allocationRepository.saveAndFlush(allocation); - accountingProcessor.postJournalEntries(loan, accrualTransaction, allocation, false); + accountingProcessor.postJournalEntries(loan, accrualTransaction, allocation, loan.isChargedOff()); } private boolean isAlreadyAccrued(final WorkingCapitalLoanCharge charge) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteService.java new file mode 100644 index 00000000000..45db77877b6 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteService.java @@ -0,0 +1,36 @@ +/** + * 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; + +/** + * Write operations for the Working Capital Loan charge-off feature. + *

+ * Charge-off is a pure accounting tag: it marks the account as charged-off and records a non-monetary charge-off + * transaction, but it does not affect the portfolio (balance, schedule) and the loan stays {@code ACTIVE}. The tag is + * only removed by an explicit undo (used when the charge-off was applied in error). + */ +public interface WorkingCapitalLoanChargeOffWriteService { + + CommandProcessingResult chargeOff(Long loanId, JsonCommand command); + + CommandProcessingResult undoChargeOff(Long loanId, JsonCommand command); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java new file mode 100644 index 00000000000..daf9048d0f8 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeOffWriteServiceImpl.java @@ -0,0 +1,188 @@ +/** + * 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 java.math.BigDecimal; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; +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.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +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.domain.WorkingCapitalLoanTransactionAllocation; +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.WorkingCapitalLoanTransactionAllocationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkingCapitalLoanChargeOffWriteServiceImpl implements WorkingCapitalLoanChargeOffWriteService { + + private final PlatformSecurityContext context; + private final WorkingCapitalLoanRepository loanRepository; + private final WorkingCapitalLoanDataValidator validator; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanNoteRepository noteRepository; + private final CodeValueRepository codeValueRepository; + private final ExternalIdFactory externalIdFactory; + private final WorkingCapitalLoanAccountingProcessor accountingProcessor; + + @Transactional + @Override + public CommandProcessingResult chargeOff(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + this.validator.validateChargeOff(command, loan); + + final AppUser currentUser = this.context.authenticatedUser(); + final LocalDate transactionDate = command.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.transactionDateParamName); + final ExternalId txnExternalId = this.externalIdFactory.createFromCommand(command, + WorkingCapitalLoanConstants.externalIdParameterName); + + // Resolve the optional charge-off reason. + final Long chargeOffReasonId = command.longValueOfParameterNamed(WorkingCapitalLoanConstants.chargeOffReasonIdParamName); + final CodeValue chargeOffReason = chargeOffReasonId != null + ? this.codeValueRepository.findByCodeNameAndId(WorkingCapitalLoanConstants.CHARGE_OFF_REASONS, chargeOffReasonId) + : null; + + // The charge-off amount is the outstanding balance as of the charge-off date. Because the charge-off date + // cannot + // be earlier than the last transaction, the current outstanding equals the as-of-date balance. + final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) + .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); + final BigDecimal chargeOffAmount = balance.getTotalOutstanding(); + + // Non-monetary tag transaction: records the charged-off amount but does not move the running balance. + final WorkingCapitalLoanTransaction chargeOffTransaction = WorkingCapitalLoanTransaction.chargeOff(loan, chargeOffAmount, + transactionDate, txnExternalId); + this.transactionRepository.saveAndFlush(chargeOffTransaction); + + // Snapshot the outstanding portions so charge-off accounting can post per-portion journal entries later. + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation.forPortions(chargeOffTransaction, + balance.getPrincipalOutstanding(), balance.getFeeOutstanding(), balance.getPenaltyOutstanding()); + this.allocationRepository.saveAndFlush(allocation); + + loan.markAsChargedOff(transactionDate, currentUser, chargeOffReason); + this.loanRepository.saveAndFlush(loan); + + // Post charge-off journal entries: write off the outstanding receivables against charge-off expense / income + // reversal. No portfolio or schedule impact -- pure accounting tag. + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + this.accountingProcessor.postJournalEntries(loan, chargeOffTransaction, allocation, loan.isChargedOff()); + } + + final Map changes = new LinkedHashMap<>(); + changes.put(WorkingCapitalLoanConstants.transactionDateParamName, transactionDate); + if (chargeOffReasonId != null) { + changes.put(WorkingCapitalLoanConstants.chargeOffReasonIdParamName, chargeOffReasonId); + } + createNote(command, loan, changes); + + log.debug("Charged off WC loan {} on {}", loanId, transactionDate); + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(chargeOffTransaction.getId()) // + .withEntityExternalId(chargeOffTransaction.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .withLoanExternalId(loan.getExternalId()) // + .with(changes) // + .build(); + } + + @Transactional + @Override + public CommandProcessingResult undoChargeOff(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + this.validator.validateUndoChargeOff(command, loan); + + final WorkingCapitalLoanTransaction chargeOffTransaction = this.transactionRepository + .findActiveByTypeOrderByIdDesc(loanId, LoanTransactionType.CHARGE_OFF).stream().findFirst() + .orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.wc.loan.charge.off.transaction.not.found", + "No active charge-off transaction found for loan " + loanId, loanId)); + + final ExternalId reversalExternalId = this.externalIdFactory + .create(command.stringValueOfParameterNamedAllowingNull(WorkingCapitalLoanConstants.reversalExternalIdParamName)); + chargeOffTransaction.setReversed(true); + chargeOffTransaction.setReversalExternalId(reversalExternalId); + chargeOffTransaction.setReversedOnDate(DateUtils.getBusinessLocalDate()); + this.transactionRepository.saveAndFlush(chargeOffTransaction); + + loan.liftChargeOff(); + this.loanRepository.saveAndFlush(loan); + + // Reverse the charge-off journal entries. No schedule reprocessing -- pure tag. + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + this.accountingProcessor.postReversalJournalEntries(loan, chargeOffTransaction); + } + + final Map changes = new LinkedHashMap<>(); + changes.put("reversalExternalId", reversalExternalId); + createNote(command, loan, changes); + + log.debug("Reversed charge-off for WC loan {}", loanId); + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(chargeOffTransaction.getId()) // + .withEntityExternalId(chargeOffTransaction.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .withLoanExternalId(loan.getExternalId()) // + .with(changes) // + .build(); + } + + private void createNote(final JsonCommand command, final WorkingCapitalLoan loan, final Map changes) { + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + if (StringUtils.isNotBlank(noteText)) { + this.noteRepository.save(WorkingCapitalLoanNote.create(loan, noteText)); + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java index 5ae60f84db8..125e87afa84 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java @@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; @@ -113,6 +114,11 @@ public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); final LoanStatus statusBeforeCharge = loan.getLoanStatus(); + // New charges cannot be added once the loan is charged off (modify/waive/pay of existing charges stay allowed). + if (loan.isChargedOff()) { + throw new GeneralPlatformDomainRuleException("error.msg.wc.loan.is.charged.off", + "Adding a charge to Working Capital Loan " + loanId + " is not allowed. The loan is charged off.", loanId); + } WorkingCapitalLoanCharge loanCharge = assemblyChargeFromCommand(loan, command); @@ -278,7 +284,7 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final LoanTransactionType.CHARGE_ADJUSTMENT, transactionDate, amount); if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { - accountingProcessor.postJournalEntries(loan, adjustmentTx, allocation, false); + accountingProcessor.postJournalEntries(loan, adjustmentTx, allocation, loan.isChargedOff()); } final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanChargeConstants.noteParamName); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java index 40a1a56da67..51f9f466f67 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java @@ -81,14 +81,14 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final return; } - // Charge-off accounting is out of scope here (see the full-discount note above), so amortization is always - // posted as not-charged-off. + // On a charged-off loan the recognized amortization is routed to the charge-off expense account instead of + // discount-fee income (handled by the accounting processor via the isChargedOff flag). if (MathUtil.isGreaterThanZero(amortizationAmount)) { final WorkingCapitalLoanTransaction amortizationTxn = WorkingCapitalLoanTransaction.discountFeeAmortization(loan, amortizationAmount, transactionDate, externalIdFactory.create()); transactionRepository.saveAndFlush(amortizationTxn); if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { - accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, false); + accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, loan.isChargedOff()); } } else { final BigDecimal adjustmentAmount = amortizationAmount.negate(); @@ -97,7 +97,7 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final linkToTriggeringDiscountAdjustment(loan, adjustmentTxn); transactionRepository.saveAndFlush(adjustmentTxn); if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { - accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, false); + accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, loan.isChargedOff()); } } 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 b37ebabaadd..8a21c284175 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 @@ -24,6 +24,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; @@ -100,6 +101,14 @@ public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(fin .classificationOptions(codeValueReadPlatformService .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.DISCOUNT_FEE_CLASSIFICATION_CODE_NAME)) .build(); + } else if (WorkingCapitalLoanConstants.CHARGE_OFF_LOAN_COMMAND.equals(command)) { + // Charge-off amount is the auto-calculated outstanding balance; the date defaults to the business date. + return WorkingCapitalLoanCommandTemplateData.builder() + .chargeOffAmount(wcLoan.getBalance() != null ? wcLoan.getBalance().getTotalOutstanding() : BigDecimal.ZERO) + .chargeOffDate(DateUtils.getBusinessLocalDate()).currency(wcLoan.getLoanProduct().getCurrency().toData()) + .chargeOffReasonOptions( + codeValueReadPlatformService.retrieveCodeValuesByCode(WorkingCapitalLoanConstants.CHARGE_OFF_REASONS)) + .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 28068405a3b..7e6d6bc17d1 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 @@ -697,7 +697,7 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, handleNote(loan, command, changes); if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { - accountingProcessor.postJournalEntries(loan, transaction, allocation, false); + accountingProcessor.postJournalEntries(loan, transaction, allocation, loan.isChargedOff()); } notifyPostBusinessEvent(transactionType, transaction, loan); @@ -817,7 +817,7 @@ public CommandProcessingResult creditBalanceRefund(final Long loanId, final Json this.loanRepository.saveAndFlush(loan); if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { - accountingProcessor.postJournalEntries(loan, creditBalanceRefundTransaction, allocation, false); + accountingProcessor.postJournalEntries(loan, creditBalanceRefundTransaction, allocation, loan.isChargedOff()); } businessEventNotifierService.notifyPostBusinessEvent( 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 015b71c3ad8..d0f431f012b 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 @@ -80,4 +80,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0059_wc_loan_charge_off.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0059_wc_loan_charge_off.xml new file mode 100644 index 00000000000..faeced7c755 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0059_wc_loan_charge_off.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CHARGEOFF_WORKINGCAPITALLOAN' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UNDOCHARGEOFF_WORKINGCAPITALLOAN' + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java new file mode 100644 index 00000000000..02a199db662 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanChargeOffAccountingTest.java @@ -0,0 +1,369 @@ +/** + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.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.PostWorkingCapitalLoansRequest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignJournalEntryHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests verifying the Working Capital Loan charge-off accounting treatment. + *

+ * Charge-off is a pure accounting tag: it writes off the outstanding receivables against the charge-off expense (no + * interest leg -- WC has no interest concept), keeps the loan ACTIVE, and is reversible via undo. + */ +public class WorkingCapitalLoanChargeOffAccountingTest { + + private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + + private final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + private final List createdLoanIds = new ArrayList<>(); + 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 = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + final FeignAccountHelper accountHelper = new FeignAccountHelper(FineractFeignClientHelper.getFineractFeignClient()); + fundSourceAccount = accountHelper.createLiabilityAccount("wcCoFundSource"); + loanPortfolioAccount = accountHelper.createAssetAccount("wcCoLoanPortfolio"); + transfersSuspenseAccount = accountHelper.createAssetAccount("wcCoTransfersSuspense"); + incomeFromDiscountFeeAccount = accountHelper.createIncomeAccount("wcCoIncomeDiscountFee"); + feesReceivableAccount = accountHelper.createAssetAccount("wcCoFeesReceivable"); + penaltiesReceivableAccount = accountHelper.createAssetAccount("wcCoPenaltiesReceivable"); + incomeFromFeeAccount = accountHelper.createIncomeAccount("wcCoIncomeFee"); + incomeFromPenaltyAccount = accountHelper.createIncomeAccount("wcCoIncomePenalty"); + incomeFromRecoveryAccount = accountHelper.createIncomeAccount("wcCoIncomeRecovery"); + writeOffAccount = accountHelper.createExpenseAccount("wcCoWriteOff"); + overpaymentAccount = accountHelper.createLiabilityAccount("wcCoOverpayment"); + deferredIncomeAccount = accountHelper.createLiabilityAccount("wcCoDeferredIncome"); + chargeOffExpenseAccount = accountHelper.createExpenseAccount("wcCoChargeOffExpense"); + incomeFromChargeOffFeesAccount = accountHelper.createIncomeAccount("wcCoIncomeChargeOffFees"); + incomeFromChargeOffPenaltyAccount = accountHelper.createIncomeAccount("wcCoIncomeChargeOffPenalty"); + } + + @Test + public void testChargeOffWritesOffPrincipalAndKeepsLoanActive() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), + () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + + final LocalDate chargeOffDate = currentDate.plusDays(1); + final AtomicLong chargeOffTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> chargeOffTxnId.set(loanHelper.chargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, "charge-off note")))); + + // The loan stays ACTIVE and is flagged as charged off (pure accounting tag, no portfolio impact). + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assertNotNull(loanData.getStatus()); + assertEquals("loanStatusType.active", loanData.getStatus().getCode()); + assertEquals(Boolean.TRUE, loanData.getChargedOff()); + + // Only principal was outstanding (no fees/penalties): Dr Charge-off expense 5000, Cr Loan portfolio 5000. + final List entries = getJournalEntriesForWCTransaction(chargeOffTxnId.get()); + assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); + assertJournalEntry(entries, "DEBIT", chargeOffExpenseAccount, 5000.0); + assertJournalEntry(entries, "CREDIT", loanPortfolioAccount, 5000.0); + } + + @Test + public void testUndoChargeOffReversesJournalEntriesAndClearsTag() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), + () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + + final LocalDate chargeOffDate = currentDate.plusDays(1); + final AtomicLong chargeOffTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> chargeOffTxnId.set(loanHelper.chargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)))); + + final LocalDate undoDate = chargeOffDate.plusDays(1); + BusinessDateHelper.runAt(undoDate.format(BUSINESS_DATE), () -> loanHelper.undoChargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildUndoChargeOffRequest("undo note"))); + + // The charge-off tag is cleared and the loan stays ACTIVE. + final GetWorkingCapitalLoansLoanIdResponse loanData = loanHelper.retrieveById(loanId.get()); + assertNotNull(loanData.getStatus()); + assertEquals("loanStatusType.active", loanData.getStatus().getCode()); + assertFalse(Boolean.TRUE.equals(loanData.getChargedOff())); + + // The original 2 entries are reversed: querying the charge-off transaction now returns the originals plus their + // reversals (Dr Loan portfolio 5000, Cr Charge-off expense 5000). + final List entries = getJournalEntriesForWCTransaction(chargeOffTxnId.get()); + assertEquals(4, entries.size(), "Expected 4 journal entries (2 original + 2 reversal)"); + assertJournalEntry(entries, "DEBIT", loanPortfolioAccount, 5000.0); + assertJournalEntry(entries, "CREDIT", chargeOffExpenseAccount, 5000.0); + } + + @Test + public void testSecondChargeOffFails() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), + () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + + final LocalDate chargeOffDate = currentDate.plusDays(1); + BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> { + loanHelper.chargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)); + final CallFailedRuntimeException error = loanHelper.runChargeOffByLoanIdExpectingFailure(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)); + assertTrue(error.getMessage() != null && error.getMessage().contains("already.charged.off"), + "Expected already-charged-off validation error, got: " + error.getMessage()); + }); + } + + @Test + public void testChargeOffWithNoAccountingCreatesNoJournalEntries() { + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + final String uniqueName = "WCL CoNoAcct " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + final Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + }); + + final LocalDate chargeOffDate = currentDate.plusDays(1); + final AtomicLong chargeOffTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> chargeOffTxnId.set(loanHelper.chargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)))); + + final List entries = getJournalEntriesForWCTransaction(chargeOffTxnId.get()); + assertTrue(entries.isEmpty(), "Expected no journal entries for NONE accounting rule"); + } + + @Test + public void testRepaymentAfterChargeOffPostsToRecoveryIncome() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), + () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + + final LocalDate chargeOffDate = currentDate.plusDays(1); + BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> loanHelper.chargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null))); + + // Repayments stay allowed after charge-off (the balance can be cured); their credits are routed to recovery + // income instead of the loan portfolio / receivables. + final LocalDate repaymentDate = chargeOffDate.plusDays(1); + final AtomicLong repaymentTxnId = new AtomicLong(0L); + BusinessDateHelper.runAt(repaymentDate.format(BUSINESS_DATE), + () -> repaymentTxnId.set(loanHelper.makeRepaymentByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder + .buildRepaymentRequest(repaymentDate, BigDecimal.valueOf(1000), null, null, null, null)))); + + final List entries = getJournalEntriesForWCTransaction(repaymentTxnId.get()); + assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); + assertJournalEntry(entries, "DEBIT", fundSourceAccount, 1000.0); + assertJournalEntry(entries, "CREDIT", incomeFromRecoveryAccount, 1000.0); + } + + @Test + public void testAddChargeAfterChargeOffIsRejected() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), + () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + + final LocalDate chargeOffDate = currentDate.plusDays(1); + BusinessDateHelper.runAt(chargeOffDate.format(BUSINESS_DATE), () -> { + loanHelper.chargeOffByLoanId(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(chargeOffDate, null)); + + // New charges cannot be added once the loan is charged off. + final FeignWorkingCapitalLoanHelper feignLoanHelper = new FeignWorkingCapitalLoanHelper( + FineractFeignClientHelper.getFineractFeignClient()); + final Long chargeId = feignLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, 100)); + final CallFailedRuntimeException error = assertThrows(CallFailedRuntimeException.class, + () -> feignLoanHelper.addCharge(loanId.get(), + WorkingCapitalLoanRequestBuilders.addCharge(chargeId, 100, chargeOffDate.format(BUSINESS_DATE)))); + assertTrue(error.getMessage() != null && error.getMessage().contains("charged.off"), + "Expected charged-off rejection for addCharge, got: " + error.getMessage()); + }); + } + + @Test + public void testChargeOffWithFutureDateFails() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), () -> { + loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate)); + + final CallFailedRuntimeException error = loanHelper.runChargeOffByLoanIdExpectingFailure(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(currentDate.plusDays(1), null)); + assertTrue(error.getMessage() != null && error.getMessage().contains("future.date"), + "Expected future-date validation error, got: " + error.getMessage()); + }); + } + + @Test + public void testChargeOffDatedBeforeLastTransactionFails() { + final Long productId = createAccrualWithDeferredRevenueAmortizationProduct(); + final LocalDate currentDate = LocalDate.now(ZoneId.systemDefault()); + final AtomicLong loanId = new AtomicLong(0L); + BusinessDateHelper.runAt(currentDate.format(BUSINESS_DATE), + () -> loanId.set(createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), currentDate))); + + final LocalDate repaymentDate = currentDate.plusDays(2); + BusinessDateHelper.runAt(repaymentDate.format(BUSINESS_DATE), () -> { + loanHelper.makeRepaymentByLoanId(loanId.get(), WorkingCapitalLoanDisbursementTestBuilder.buildRepaymentRequest(repaymentDate, + BigDecimal.valueOf(1000), null, null, null, null)); + + // The charge-off can be backdated, but never behind the last (non-reversed) transaction. + final CallFailedRuntimeException error = loanHelper.runChargeOffByLoanIdExpectingFailure(loanId.get(), + WorkingCapitalLoanDisbursementTestBuilder.buildChargeOffRequest(currentDate.plusDays(1), null)); + assertTrue(error.getMessage() != null && error.getMessage().contains("last.transaction"), + "Expected before-last-transaction validation error, got: " + error.getMessage()); + }); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Long createAccrualWithDeferredRevenueAmortizationProduct() { + final String uniqueName = "WCL CoAcct " + 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 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/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java index 9ac2e400b9d..d66187fa8c2 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 @@ -155,6 +155,26 @@ public static PostWorkingCapitalLoanTransactionsRequest buildCreditBalanceRefund return buildTransactionRequest(transactionDate, transactionAmount, classificationId, note, paymentTypeId, accountNumber, null); } + public static PostWorkingCapitalLoanTransactionsRequest buildChargeOffRequest(final LocalDate transactionDate, final String note) { + final PostWorkingCapitalLoanTransactionsRequest request = new PostWorkingCapitalLoanTransactionsRequest().locale(DEFAULT_LOCALE) + .dateFormat(DEFAULT_DATE_FORMAT); + if (transactionDate != null) { + request.transactionDate(format(transactionDate)); + } + if (note != null) { + request.note(note); + } + return request; + } + + public static PostWorkingCapitalLoanTransactionsRequest buildUndoChargeOffRequest(final String note) { + final PostWorkingCapitalLoanTransactionsRequest request = new PostWorkingCapitalLoanTransactionsRequest().locale(DEFAULT_LOCALE); + if (note != null) { + request.note(note); + } + 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 2a2e1d7988c..e6e1ea85f84 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 @@ -168,6 +168,26 @@ public CallFailedRuntimeException runCreditBalanceRefundByLoanIdExpectingFailure return FeignCalls.fail(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "creditBalanceRefund", request)); } + public Long chargeOffByLoanId(final Long loanId, final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.ok(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "chargeOff", request)) + .getResourceId(); + } + + public Long undoChargeOffByLoanId(final Long loanId, final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.ok(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "undoChargeOff", request)) + .getResourceId(); + } + + public CallFailedRuntimeException runChargeOffByLoanIdExpectingFailure(final Long loanId, + final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.fail(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "chargeOff", request)); + } + + public CallFailedRuntimeException runUndoChargeOffByLoanIdExpectingFailure(final Long loanId, + final PostWorkingCapitalLoanTransactionsRequest request) { + return FeignCalls.fail(() -> transactionsApi().executeWorkingCapitalLoanTransactionById(loanId, "undoChargeOff", request)); + } + public GetWorkingCapitalLoanTransactionsResponse retrieveTransactionsByLoanId(final Long loanId) { return FeignCalls.ok(() -> transactionsApi().retrieveWorkingCapitalLoanTransactionsById(loanId)); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java index 2c0d95cecdf..8efb3ec4802 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java @@ -97,6 +97,9 @@ public class WorkingCapitalLoanProductTestBuilder { private Long writeOffAccountId; private Long overpaymentLiabilityAccountId; private Long deferredIncomeLiabilityAccountId; + private Long chargeOffExpenseAccountId; + private Long incomeFromChargeOffFeesAccountId; + private Long incomeFromChargeOffPenaltyAccountId; public WorkingCapitalLoanProductTestBuilder withName(final String name) { this.name = name; @@ -314,6 +317,21 @@ public WorkingCapitalLoanProductTestBuilder withDeferredIncomeLiabilityAccountId return this; } + public WorkingCapitalLoanProductTestBuilder withChargeOffExpenseAccountId(final Long chargeOffExpenseAccountId) { + this.chargeOffExpenseAccountId = chargeOffExpenseAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withIncomeFromChargeOffFeesAccountId(final Long incomeFromChargeOffFeesAccountId) { + this.incomeFromChargeOffFeesAccountId = incomeFromChargeOffFeesAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withIncomeFromChargeOffPenaltyAccountId(final Long incomeFromChargeOffPenaltyAccountId) { + this.incomeFromChargeOffPenaltyAccountId = incomeFromChargeOffPenaltyAccountId; + return this; + } + public PostWorkingCapitalLoanProductsRequest build() { final PostWorkingCapitalLoanProductsRequest request = new PostWorkingCapitalLoanProductsRequest(); populateCommonFields(request); @@ -375,6 +393,9 @@ private void populateCommonFields(final PostWorkingCapitalLoanProductsRequest re request.setWriteOffAccountId(this.writeOffAccountId); request.setOverpaymentLiabilityAccountId(this.overpaymentLiabilityAccountId); request.setDeferredIncomeLiabilityAccountId(this.deferredIncomeLiabilityAccountId); + request.setChargeOffExpenseAccountId(this.chargeOffExpenseAccountId); + request.setIncomeFromChargeOffFeesAccountId(this.incomeFromChargeOffFeesAccountId); + request.setIncomeFromChargeOffPenaltyAccountId(this.incomeFromChargeOffPenaltyAccountId); request.setLocale("en_US"); request.setDateFormat("yyyy-MM-dd"); }