Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ gradleExp/
.java-version

.windsurf/
docs/
/docs/
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions fineract-doc/src/docs/en/chapters/features/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <<wc-charge-off-read-model>>). 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 <<wc-charge-off-accounting>>).
* 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,12 @@ private GetWorkingCapitalLoanSummary() {}
public List<GetWorkingCapitalLoansLoanIdOriginatorData> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,9 @@ public class WorkingCapitalLoanCommandTemplateData implements Serializable {

private Collection<PaymentTypeData> paymentTypeOptions;
private Collection<CodeValueData> 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<CodeValueData> chargeOffReasonOptions;
}
Loading
Loading