Skip to content

Commit fd9a343

Browse files
FINERACT-2455: Working Capital Loan Charge-Off
1 parent 2fca3a4 commit fd9a343

37 files changed

Lines changed: 1958 additions & 13 deletions

File tree

fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,6 +1287,24 @@ public CommandWrapperBuilder goodwillCreditWorkingCapitalLoanTransaction(final L
12871287
return this;
12881288
}
12891289

1290+
public CommandWrapperBuilder chargeOffWorkingCapitalLoanTransaction(final Long loanId) {
1291+
this.actionName = ACTION_CHARGEOFF;
1292+
this.entityName = ENTITY_WORKINGCAPITALLOAN;
1293+
this.entityId = loanId;
1294+
this.loanId = loanId;
1295+
this.href = "/working-capital-loans/" + loanId + "/transactions?command=chargeOff";
1296+
return this;
1297+
}
1298+
1299+
public CommandWrapperBuilder undoChargeOffWorkingCapitalLoanTransaction(final Long loanId) {
1300+
this.actionName = ACTION_UNDOCHARGEOFF;
1301+
this.entityName = ENTITY_WORKINGCAPITALLOAN;
1302+
this.entityId = loanId;
1303+
this.loanId = loanId;
1304+
this.href = "/working-capital-loans/" + loanId + "/transactions?command=undoChargeOff";
1305+
return this;
1306+
}
1307+
12901308
public CommandWrapperBuilder loanInterestPaymentWaiverTransaction(final Long loanId) {
12911309
this.actionName = ACTION_INTERESTPAYMENTWAIVER;
12921310
this.entityName = ENTITY_LOAN;

fineract-doc/src/docs/en/chapters/features/index.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ include::working-capital-payment-allocation.adoc
2323
include::working-capital-amortization-schedule.adoc[leveloffset=+1]
2424
include::working-capital-discount-fee-txn.adoc[leveloffset=+1]
2525
include::working-capital-charges.adoc[leveloffset=+1]
26+
include::working-capital-charge-off.adoc[leveloffset=+1]
2627
include::working-capital-credit-balance-refund.adoc[leveloffset=+1]
2728
include::working-capital-goodwill-credit.adoc[leveloffset=+1]
2829
include::working-capital-delinquency-management.adoc[leveloffset=+1]
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
//
2+
// Licensed to the Apache Software Foundation (ASF) under one
3+
// or more contributor license agreements. See the NOTICE file
4+
// distributed with this work for additional information
5+
// regarding copyright ownership. The ASF licenses this file
6+
// to you under the Apache License, Version 2.0 (the
7+
// "License"); you may not use this file except in compliance
8+
// with the License. You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing,
13+
// software distributed under the License is distributed on an
14+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
// KIND, either express or implied. See the License for the
16+
// specific language governing permissions and limitations
17+
// under the License.
18+
//
19+
20+
= Working Capital Loan Charge-Off
21+
22+
This documentation describes the Charge-Off feature for Working Capital Loans.
23+
24+
Charge-off marks a loan account as charged off for accounting purposes. In the Working Capital Loan
25+
module it is modeled as a *pure accounting tag with no portfolio impact*: the loan stays `ACTIVE`, its
26+
schedule and balance are unchanged, and all other actions and calculations behave exactly as they do on
27+
a loan that is not charged off. This differs from the Term Loan implementation, whose charge-off carries
28+
interest-recalculation behaviour — Working Capital Loans have no interest concept, so that machinery does
29+
not apply here.
30+
31+
[NOTE]
32+
====
33+
Charge-off is an accounting treatment only. It does not change the schedule, balance or delinquency
34+
data returned by the existing Working Capital Loan APIs; the loan resource only gains the charged-off
35+
state fields (see <<wc-charge-off-read-model>>). The loan remains `ACTIVE` until its balance is cured, and the
36+
charged-off tag is never removed automatically — even if the loan is subsequently paid off. It is
37+
cleared only by an explicit undo.
38+
====
39+
40+
== Charge-Off a Loan
41+
42+
Marks the loan account as charged off and creates a non-monetary charge-off transaction.
43+
44+
* *Endpoint*: `POST /working-capital-loans/{loanId}/transactions?command=chargeOff`
45+
* *Permission*: `CHARGEOFF_WORKINGCAPITALLOAN`
46+
47+
=== Supported Fields
48+
49+
==== Mandatory Fields
50+
51+
* `transactionDate`
52+
** The charge-off date. It may be backdated but must not be in the future, and must not be earlier than
53+
the last user transaction date (system transactions such as accrual or discount-fee amortization are ignored).
54+
55+
* `locale`
56+
57+
* `dateFormat`
58+
59+
==== Optional Fields
60+
61+
* `chargeOffReasonId`
62+
** Code value id from the `ChargeOffReasons` code.
63+
64+
* `note`
65+
** Free-text note (up to 1000 characters).
66+
67+
* `externalId`
68+
** External identifier for the charge-off transaction (up to 100 characters).
69+
70+
=== Behaviour
71+
72+
* A non-monetary `CHARGE_OFF` transaction is created for the charge-off amount, which is the outstanding
73+
balance as of the charge-off date. Because the charge-off date cannot precede the last user transaction, the
74+
current outstanding balance equals the as-of-date balance.
75+
* The transaction does *not* move the loan balance and is excluded from transaction replay.
76+
* The loan is flagged as charged off (`chargedOff = true`) and remains `ACTIVE`.
77+
* Repayments and payment, waiver or adjustment of *existing* charges remain allowed afterwards, so the
78+
balance can still be cured. While the loan is charged off, those credits are recognized as recovery
79+
income and discount-fee amortization is routed to the charge-off expense (see <<wc-charge-off-accounting>>).
80+
* Goodwill credit and payout refund are not supported while the loan is charged off; posting either one
81+
is rejected.
82+
83+
.Charge-off request/response flow
84+
[plantuml,format=svg]
85+
....
86+
@startuml
87+
actor User
88+
participant "Transactions API" as API
89+
participant "ChargeOff Write Service" as SVC
90+
participant "Accounting Processor" as ACC
91+
database "m_wc_loan" as DB
92+
93+
User -> API : POST .../transactions?command=chargeOff
94+
API -> SVC : chargeOff(loanId, command)
95+
SVC -> SVC : validate (active, not already charged off, date rules)
96+
SVC -> DB : mark loan charged off + create CHARGE_OFF txn
97+
SVC -> ACC : post charge-off journal entries
98+
SVC --> User : transaction id
99+
@enduml
100+
....
101+
102+
== Undo Charge-Off
103+
104+
Reverses a charge-off that was applied in error.
105+
106+
* *Endpoint*: `POST /working-capital-loans/{loanId}/transactions?command=undoChargeOff`
107+
* *Permission*: `UNDOCHARGEOFF_WORKINGCAPITALLOAN`
108+
109+
Undo removes the charged-off tag, reverses the charge-off transaction and reverses its journal entries.
110+
It is only allowed when the charge-off is still the last user transaction, that is, when no user transaction
111+
has been posted after it (the same restriction as term and progressive loans). System transactions
112+
(accrual and discount-fee amortization) are not user transactions and therefore do not block undo.
113+
114+
== Charge-Off Template
115+
116+
Returns the data needed to prefill the charge-off form.
117+
118+
* *Endpoint*: `GET /working-capital-loans/{loanId}/template?templateType=chargeOff`
119+
120+
[cols="1,3"]
121+
|===
122+
|*Field* |*Description*
123+
124+
|`chargeOffAmount`
125+
|Auto-calculated outstanding balance as of the charge-off date. Read-only.
126+
127+
|`chargeOffDate`
128+
|Suggested charge-off date, defaulting to the current business date.
129+
130+
|`chargeOffReasonOptions`
131+
|Available `ChargeOffReasons` code values.
132+
133+
|`currency`
134+
|Loan currency.
135+
|===
136+
137+
[#wc-charge-off-read-model]
138+
== Loan Read Model
139+
140+
The Working Capital Loan resource exposes the charged-off state:
141+
142+
[cols="1,3"]
143+
|===
144+
|*Field* |*Description*
145+
146+
|`chargedOff`
147+
|`true` when the loan is charged off, otherwise `false`.
148+
149+
|`chargedOffOnDate`
150+
|The charge-off date. Only present while the loan is charged off.
151+
152+
|`chargeOffReason`
153+
|The `ChargeOffReasons` code value selected at charge-off time, when one was provided.
154+
|===
155+
156+
== Validations
157+
158+
[cols="1,3,2"]
159+
|===
160+
|*Operation* |*Rule* |*Error code*
161+
162+
|Charge-off |The loan must be active. |`error.msg.wc.loan.is.not.active`
163+
|Charge-off |The loan must not already be charged off. |`error.msg.wc.loan.is.already.charged.off`
164+
|Charge-off |The charge-off date must not be earlier than the last user transaction date. |`cannot.be.before.last.transaction.date`
165+
|Charge-off |The charge-off date must not be in the future. |`cannot.be.a.future.date`
166+
|Add charge |New charges cannot be added once the loan is charged off. |`error.msg.wc.loan.is.charged.off`
167+
|Undo charge-off |The loan must be charged off. |`error.msg.wc.loan.is.not.charged.off`
168+
|Undo charge-off |Undo is not allowed if any user transaction was posted after the charge-off. |`error.msg.wc.loan.charge.off.is.not.the.last.user.transaction`
169+
|===
170+
171+
[NOTE]
172+
====
173+
A second charge-off on an already charged-off loan, and a second undo on a loan that is not charged off,
174+
both return a validation error.
175+
====
176+
177+
[#wc-charge-off-accounting]
178+
== Accounting
179+
180+
Charge-off accounting applies to products configured with the *Accrual (deferred revenue amortization)*
181+
accounting rule. The charge-off transaction writes off the outstanding receivables against the charge-off
182+
expense. There is no interest leg — Working Capital Loans have no interest concept.
183+
184+
[cols="1,2,2"]
185+
|===
186+
|*Portion* |*Debit* |*Credit*
187+
188+
|Principal |`CHARGE_OFF_EXPENSE` |`LOAN_PORTFOLIO`
189+
|Fee |`INCOME_FROM_CHARGE_OFF_FEES` |`FEES_RECEIVABLE`
190+
|Penalty |`INCOME_FROM_CHARGE_OFF_PENALTY` |`PENALTIES_RECEIVABLE`
191+
|===
192+
193+
Undo reverses these journal entries. The charge-off General Ledger accounts are configured per product
194+
through the Working Capital Loan product accounting mapping.
195+
196+
=== While the Loan Is Charged Off
197+
198+
Transactions posted while the loan carries the charged-off tag receive a different accounting
199+
treatment: credits that would normally reduce the portfolio or receivables are recognized as recovery
200+
income, and discount-fee amortization is recognized against the charge-off expense instead of
201+
discount-fee income.
202+
203+
[cols="2,2,3"]
204+
|===
205+
|*Transaction* |*Debit* |*Credit*
206+
207+
|Repayment
208+
|`FUND_SOURCE`
209+
|`INCOME_FROM_RECOVERY` for the principal, fee and penalty portions; `OVERPAYMENT` for any overpaid
210+
excess
211+
212+
|Charge adjustment
213+
|`INCOME_FROM_RECOVERY`
214+
|`LOAN_PORTFOLIO` / `FEES_RECEIVABLE` / `PENALTIES_RECEIVABLE` per portion
215+
216+
|Discount-fee amortization
217+
|`DEFERRED_INCOME_LIABILITY`
218+
|`CHARGE_OFF_EXPENSE` (instead of `INCOME_FROM_DISCOUNT_FEE`)
219+
|===
220+
221+
Charge accrual keeps its regular treatment (receivable against fee/penalty income). Once the
222+
charge-off is undone, subsequent transactions return to the regular accounting treatment.
223+
224+
== Permissions
225+
226+
[cols="1,1,1"]
227+
|===
228+
|*Code* |*Entity* |*Action*
229+
230+
|`CHARGEOFF_WORKINGCAPITALLOAN` |`WORKINGCAPITALLOAN` |`CHARGEOFF`
231+
|`UNDOCHARGEOFF_WORKINGCAPITALLOAN` |`WORKINGCAPITALLOAN` |`UNDOCHARGEOFF`
232+
|===
233+
234+
== Out of Scope
235+
236+
The following are intentionally not part of the first version:
237+
238+
* Overriding the charge-off expense account per charge-off reason (currently the default
239+
`CHARGE_OFF_EXPENSE` account is used).
240+
* Fraud-expense routing (`CHARGE_OFF_FRAUD_EXPENSE`), which depends on the Working Capital Loan fraud
241+
feature.

fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ public Long insertLoan(final LoanStatus status, final LocalDate lastClosedBusine
8282
.addValue("principal_amount_proposed", DEFAULT_PRINCIPAL)//
8383
.addValue("approved_principal", DEFAULT_PRINCIPAL)//
8484
.addValue("total_payment_volume", DEFAULT_PRINCIPAL)//
85-
.addValue("breach_start_type", "DISBURSEMENT");
85+
.addValue("breach_start_type", "DISBURSEMENT")//
86+
// SimpleJdbcInsert without usingColumns() binds explicit NULL for every unmapped
87+
// table column, which bypasses the DB default and violates the NOT NULL constraint.
88+
.addValue("is_charged_off", false);
8689
final Number key = wcLoanInsert.executeAndReturnKey(params);
8790
return Objects.requireNonNull(key, "Generated key must not be null").longValue();
8891
}

fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalChargeStepDef.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,37 @@ private void assertErrorMessage(final CallFailedRuntimeException exception, fina
611611
assertThat(exception.getMessage()).as("Error message should contain: " + expectedMessage).contains(expectedMessage);
612612
}
613613

614+
@Then("Initiating adding {string} specified due date charge to working capital loan with {string} due date and {double} transaction amount results an error with the following data:")
615+
public void addWorkingCapitalChargeResultsAnError(final String chargeType, final String dueDate, final Double amount,
616+
final DataTable table) {
617+
final Long loanId = getLoanId();
618+
final ChargeProductType chargeProductType = ChargeProductType.valueOf(chargeType);
619+
final Long chargeTypeId = chargeProductResolver.resolve(chargeProductType);
620+
621+
final LocalDate dueDateParsed = LocalDate.parse(dueDate, FORMATTER);
622+
final String dueDateFormatted = dueDateParsed.format(FORMATTER_API);
623+
624+
final PostLoansLoanIdChargesRequest request = new PostLoansLoanIdChargesRequest() //
625+
.chargeId(chargeTypeId) //
626+
.amount(amount) //
627+
.dueDate(dueDateFormatted) //
628+
.dateFormat(DATE_FORMAT_API) //
629+
.locale("en");
630+
631+
final CallFailedRuntimeException exception = fail(
632+
() -> fineractClient.workingCapitalLoanCharges().createLoanCharge(loanId, request));
633+
634+
final List<List<String>> data = table.asLists();
635+
final String expectedHttpCode = data.get(1).getFirst();
636+
final String expectedErrorMessage = data.get(1).get(1);
637+
638+
assertThat(exception.getStatus()).as("HTTP status code should be " + expectedHttpCode)
639+
.isEqualTo(Integer.parseInt(expectedHttpCode));
640+
assertThat(exception.getMessage()).as("Should contain error message").contains(expectedErrorMessage);
641+
642+
log.info("Verified adding charge {} after charge-off failed for loan {}", chargeType, loanId);
643+
}
644+
614645
private void assertSingleOption(final List<EnumOptionData> options, final String optionName, final Long expectedId) {
615646
assertThat(options).as(optionName + " should not be null or empty").isNotNull().isNotEmpty();
616647
assertThat(options).hasSize(1);

0 commit comments

Comments
 (0)