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
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ As repayments are received, the cursor-based amortization mechanism determines h
| *Approve* | Generates a projected schedule from approved loan parameters and expected disbursement date.
| *Disburse* | Regenerates the schedule using the actual disbursement amount and actual disbursement date.
| *Repayment* | `applyPayment()` records the repayment against the calculated schedule date and rebuilds the schedule. Calculates actual amortization and income modification for applied payment rows.
| *Period Payment Rate Change* | Adds a rate segment from the business date and rebuilds the schedule using the new period payment rate for the remaining term.
| *Period Payment Rate Change* | Adds a rate segment from the change's effective date and rebuilds the schedule using the new period payment rate for the remaining term. The effective date may be in the future, in which case the schedule carries the new segment immediately while the rate stored on the loan is switched over by the `WC_PERIOD_PAYMENT_RATE_EFFECTIVE_DATE` COB step once that date arrives.
|===

=== State Transitions
Expand Down Expand Up @@ -518,24 +518,36 @@ The following business steps are executed during COB for each eligible loan:
| Step Order | Step Name | Description

| 2
| `WC_MISSED_PAYMENT_ACKNOWLEDGEMENT`
| Advances the projected schedule to the business date, so instalment dates that passed with no payment against them report a nil payment instead of being left blank. Affects the actual columns only — the expected projection is restated when a payment lands, not when a date elapses. Runs ahead of the steps that read the schedule.

| 3
| `WC_DELINQUENCY_RANGE_SCHEDULE`
| Generates and evaluates delinquency range periods based on product configuration.

| 3
| 4
| `WC_LOAN_DELINQUENCY_CLASSIFICATION`
| Assigns or lifts delinquency range tags based on the configured bucket ranges.

| 4
| 5
| `WC_BREACH_SCHEDULE`
| Generates and evaluates breach schedule periods.

| 5
| 6
| `WC_NEAR_BREACH_EVALUATION`
| Evaluates near-breach conditions against the configured breach.

| 6
| 7
| `WC_DISCOUNT_FEE_AMORTIZATION`
| Posts newly recognized discount fee amortization from the projected schedule.

| 8
| `WC_CHARGE_ACCRUAL`
| Accrues charges that have fallen due on the loan.

| 9
| `WC_PERIOD_PAYMENT_RATE_EFFECTIVE_DATE`
| Brings the period payment rate stored on the loan up to date with its rate-change history. A rate change may be booked with a future effective date, and the stored rate is otherwise only refreshed when a change is submitted, so without this step the loan keeps reporting the old rate after the effective date arrives — the projected schedule already carries the new segment, so it is the summary on the loan that would drift. Resolves the rate in force as at the business date; does nothing when the loan has no rate changes or the rate has not moved.
|===

=== Inline COB
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The scope of this document includes:
* System-level near-breach configuration (`m_wc_near_breach`) — defines evaluation checkpoints and threshold percentage.
* Loan product configuration — association of breach and near-breach configurations to a product, controlled by the breach overridable flag.
* Per-loan breach schedule (`m_wc_loan_breach_schedule`) — generated and maintained by the COB pipeline.
* COB business steps: `WC_BREACH_SCHEDULE` (step 4) and `WC_NEAR_BREACH_EVALUATION` (step 5).
* COB business steps: `WC_BREACH_SCHEDULE` (step 5) and `WC_NEAR_BREACH_EVALUATION` (step 6).
* API endpoints for managing breach and near-breach configurations and retrieving a loan's breach schedule.
* Validation rules enforced during configuration creation and product setup.

Expand Down Expand Up @@ -363,7 +363,7 @@ GET /v1/working-capital-loans/{loanId}/breach-schedule

=== Near-Breach Evaluation

* Triggered by the `WC_NEAR_BREACH_EVALUATION` COB step (order 5), which runs after `WC_BREACH_SCHEDULE`.
* Triggered by the `WC_NEAR_BREACH_EVALUATION` COB step (order 6), which runs after `WC_BREACH_SCHEDULE`.
* The step is skipped for loans that are not active or have no near-breach configuration.
* Evaluation checkpoints within a period are computed as multiples of the near-breach frequency starting from `fromDate`. Checkpoints that fall on or after `toDate` are excluded.
* On each checkpoint date `d` the required cumulative payment is:
Expand Down Expand Up @@ -421,6 +421,6 @@ Working Capital Breach Management provides a periodic, schedule-driven mechanism

* Independent, reusable breach and near-breach configurations managed at the system level and assigned to loan products.
* A per-loan breach schedule generated by the COB pipeline — one period per breach frequency interval — starting from the disbursement date offset by delinquency grace days.
* A two-stage COB evaluation: `WC_BREACH_SCHEDULE` (step 4) generates periods and evaluates end-of-period breaches; `WC_NEAR_BREACH_EVALUATION` (step 5) assesses mid-period cumulative-payment checkpoints.
* A two-stage COB evaluation: `WC_BREACH_SCHEDULE` (step 5) generates periods and evaluates end-of-period breaches; `WC_NEAR_BREACH_EVALUATION` (step 6) assesses mid-period cumulative-payment checkpoints.
* Minimum payment amounts computed as either a flat amount or a percentage of `approvedPrincipal + discount`.
* Idempotent evaluation: once a breach or near-breach flag is resolved (`true` or `false`), subsequent COB runs leave it unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,11 @@ Delinquency processing for Working Capital loans is part of the `WORKING_CAPITAL
[cols="1,2,3",options="header"]
|===
| Step Order | Step Name | Description
| 2 | `WC_DELINQUENCY_RANGE_SCHEDULE` | Generates the initial period on first run, advances to the next period when needed, and evaluates all expired periods (sets `minPaymentCriteriaMet`).
| 3 | `WC_LOAN_DELINQUENCY_CLASSIFICATION` | Iterates over all periods whose `toDate < businessDate + 1` and assigns or lifts delinquency range tags based on the configured bucket ranges.
| 3 | `WC_DELINQUENCY_RANGE_SCHEDULE` | Generates the initial period on first run, advances to the next period when needed, and evaluates all expired periods (sets `minPaymentCriteriaMet`).
| 4 | `WC_LOAN_DELINQUENCY_CLASSIFICATION` | Iterates over all periods whose `toDate < businessDate + 1` and assigns or lifts delinquency range tags based on the configured bucket ranges.
|===

Step 2 (`DelinquencyRangeScheduleBusinessStep`) is skipped for loans that have not yet been disbursed. If no delinquency bucket or minimum payment rule exists, no schedule period is generated. Step 3 (`WorkingCapitalLoanDelinquencyClassificationBusinessStep`) is skipped when no delinquency bucket is configured on the product.
Step 3 (`DelinquencyRangeScheduleBusinessStep`) is skipped for loans that have not yet been disbursed. If no delinquency bucket or minimum payment rule exists, no schedule period is generated. Step 4 (`WorkingCapitalLoanDelinquencyClassificationBusinessStep`) is skipped when no delinquency bucket is configured on the product.

== API Design

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The COB step that drives EIR income recognition is registered as:
| Column Name | Type | Constraints | Description
| job_name | VARCHAR | not null | `WORKING_CAPITAL_LOAN_CLOSE_OF_BUSINESS`
| step_name | VARCHAR | not null | `WC_DISCOUNT_FEE_AMORTIZATION`
| step_order | INT | not null | `6`
| step_order | INT | not null | `7`
|===

== Configuration
Expand Down Expand Up @@ -120,7 +120,7 @@ GET /v1/working-capital-loans/external-id/{loanExternalId}/transactions/external

=== Discount Fee Amortization Business Step

The `DiscountFeeAmortizationBusinessStep` (COB step `WC_DISCOUNT_FEE_AMORTIZATION`, order 6) runs at end of day. It skips the loan when both of the following are true:
The `DiscountFeeAmortizationBusinessStep` (COB step `WC_DISCOUNT_FEE_AMORTIZATION`, order 7) runs at end of day. It skips the loan when both of the following are true:

* `loanProductRelatedDetails.discount` is zero or null, AND
* `loanBalance.realizedIncomeFromDiscountFee` is zero or null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ The scope of this document includes:

*Income Modification:* The per-period difference `actualAmortization − expectedAmortization`. Positive when the borrower over-pays, negative on underpayment, `null` on no payment.

*Original Projected Payments:* A snapshot list of the pure contracted schedule (`originalProjectedPayments`) that contains only planned-track fields and is not influenced by actual payments. Used internally by COB and reports that need the unmodified contracted schedule.

== Design Decisions and Considerations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1185,4 +1185,8 @@ public static String workingCapitalInputValuesCauseUnableCalculateEIrFailure() {
public static String workingCapitalInputValuesCauseUnableCalculateEIrErrorCodeFailure() {
return "validation.msg.WORKINGCAPITALLOAN.principalAmount.unable.to.calculate.valid.eir";
}

public static String workingCapitalPeriodPaymentRateAfterMaturityDateFailure() {
return "[effectiveDate] Failed data validation due to: cannot.be.after.maturity.date.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
import org.apache.fineract.client.models.InlineJobRequest;
import org.apache.fineract.client.models.IsCatchUpRunningDTO;
import org.apache.fineract.client.models.LockRequest;
import org.apache.fineract.client.models.OldestCOBProcessedLoanDTO;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse;
import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse;
Expand Down Expand Up @@ -356,6 +355,14 @@ public void checkWCLoanCOBCatchUpRunningUntilCOBBusinessDate() {
// too quickly for the poll to catch isCatchUpRunning = true.
// Bug fix #2: use cobProcessedDate (oldest loan's lastClosedBusinessDate) instead of
// cobBusinessDate (which is always == current COB date, making the check vacuous).
// Bug fix #3: Scoped to the loans this scenario created, not to the tenant. The catch-up API reports the oldest
// lastClosedBusinessDate across every eligible loan, and this same feature deliberately leaves loans locked
// with an error message - a lock COB is designed never to clear, so those loans never advance again. Once one
// exists, a tenant-wide reading of "caught up" is pinned to its date forever and this step can only time out,
// however long it waits. The tracked loans still make the check meaningful: catch-up has to walk them across
// the skipped business dates to satisfy it.
final List<Long> loanIds = getTrackedLoanIds();
assertThat(loanIds).as("No WC loan IDs tracked in test context").isNotEmpty();
await() //
.atMost(Duration.ofMinutes(4)) //
.pollInterval(Duration.ofSeconds(5)) //
Expand All @@ -368,16 +375,15 @@ public void checkWCLoanCOBCatchUpRunningUntilCOBBusinessDate() {
return false;
}

// Catch-up not running — check whether it processed all days up to the expected date.
// cobProcessedDate = the oldest loan's lastClosedBusinessDate after the last COB run.
OldestCOBProcessedLoanDTO catchUpStatus = ok(
() -> fineractClient.workingCapitalLoanCobCatchUpApi().getOldestCOBProcessedLoan1());
LocalDate cobProcessedDate = catchUpStatus.getCobProcessedDate();

boolean catchUpComplete = !cobProcessedDate.isBefore(expectedCompletionDate);
log.debug("WC COB catch-up complete check: cobProcessedDate={}, expectedCompletionDate={}, complete={}",
cobProcessedDate, expectedCompletionDate, catchUpComplete);
return catchUpComplete;
for (final Long loanId : loanIds) {
final LocalDate lastClosed = wcLoanHelper.getLastClosedBusinessDate(loanId);
if (lastClosed == null || lastClosed.isBefore(expectedCompletionDate)) {
log.debug("WC COB catch-up incomplete: loan {} lastClosedBusinessDate={}, expected at least {}", loanId,
lastClosed, expectedCompletionDate);
return false;
}
}
return true;
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ public void createClientAndDisburseWorkingCapitalLoanWithData(final DataTable ta
final String submittedOnDate = loanData.get(1);
final String expectedDisbursementDate = loanData.get(2);
final String principalAmount = loanData.get(3);
final String discountAmount = loanData.get(6);

// Create client with random data
final PostClientsRequest clientsRequest = clientRequestFactory.defaultClientCreationRequest();
Expand All @@ -174,6 +175,9 @@ public void createClientAndDisburseWorkingCapitalLoanWithData(final DataTable ta
.defaultWorkingCapitalLoanDisburseRequest()//
.actualDisbursementDate(submittedOnDate)//
.transactionAmount(new BigDecimal(principalAmount));
if (new BigDecimal(discountAmount).compareTo(new BigDecimal(0)) > 0) {
disburseRequest.discountAmount(new BigDecimal(discountAmount));
}
executeStateTransition("disburse", disburseRequest, TestContextKey.LOAN_DISBURSE_RESPONSE, false);

// Verify loan is ACTIVE
Expand Down Expand Up @@ -2116,6 +2120,22 @@ public void adminAddWorkingCapitalPeriodPaymentRateCauseUnableCalculateEIrFailur
updatePeriodPaymentRateFailed(periodPaymentRate, errorMessage, 403);
}

@When("Admin update Working Capital period payment rate failed with {string} value on {string} date cause after maturity date")
public void adminAddWorkingCapitalPeriodPaymentRateAfterMaturityDateFailure(final String periodPaymentRate,
final String effectiveDate) {
final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId = loanResponse.getLoanId();
PutWorkingCapitalLoansLoanIdRateRequest rateChangeRequest = workingCapitalLoanRequestFactory
.defaultWorkingCapitalLoanUpdateRateRequest().periodPaymentRate(new BigDecimal(periodPaymentRate))
.effectiveDate(effectiveDate);

CallFailedRuntimeException exception = fail(
() -> fineractClient.workingCapitalLoans().updateWorkingCapitalLoanRateById(loanId, rateChangeRequest));
String errorMessage = ErrorMessageHelper.workingCapitalPeriodPaymentRateAfterMaturityDateFailure();
assertThat(exception.getStatus()).as(errorMessage).isEqualTo(400);
assertThat(exception.getDeveloperMessage()).contains(errorMessage);
}

@When("Working Capital Loan Period Payment Rate changes history contains the following data:")
public void adminChecksWorkingCapitalPeriodPaymentRateChangesHistory(DataTable table) {
PostWorkingCapitalLoansResponse loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
Expand Down Expand Up @@ -3747,19 +3767,6 @@ public void undoWorkingCapitalLoanTransactionExpectError(String nthItemStr, Stri
}

public void updatePeriodPaymentRateFailed(String periodPaymentRate, String errorMessage) {
/*
* final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
* long loanId = loanResponse.getLoanId();
*
* PutWorkingCapitalLoansLoanIdRateRequest rateChangeRequest = workingCapitalLoanRequestFactory
* .defaultWorkingCapitalLoanUpdateRateRequest().periodPaymentRate(new BigDecimal(periodPaymentRate));
*
* CallFailedRuntimeException exception = fail( () ->
* fineractClient.workingCapitalLoans().updateWorkingCapitalLoanRateById(loanId, rateChangeRequest));
*
* assertThat(exception.getStatus()).as(errorMessage).isEqualTo(400);
* assertThat(exception.getDeveloperMessage()).contains(errorMessage);
*/
updatePeriodPaymentRateFailed(periodPaymentRate, errorMessage, 400);
}

Expand Down
Loading
Loading