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
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 @@ -31,3 +31,4 @@ include::working-capital-planned-projected-balances-eir.adoc[leveloffset=+1]
include::working-capital-discount.adoc[leveloffset=+1]
include::savings-interest-posting.adoc[leveloffset=+1]
include::working-capital-breach-management.adoc[leveloffset=+1]
include::working-capital-breach-grace-days.adoc[leveloffset=+1]
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
= Working Capital Breach Grace Days

== Overview

Breach Grace Days is a per-product (and per-loan) integer setting that shifts the start date of the first period of the Working Capital (WC) Breach Schedule by N days after the actual disbursement date. Because Near-Breach evaluation runs over the same breach schedule, shifting the first breach period implicitly shifts the first Near-Breach evaluation window — no separate Near-Breach grace days field is required.

The feature is implemented in the `fineract-working-capital-loan` module and is exposed on the Working Capital Loan Product and Working Capital Loan REST APIs as `breachGraceDays`.

=== Purpose

Breach Grace Days gives lenders an explicit, intent-revealing knob to delay the start of breach tracking after disbursement, decoupled from the pre-existing `delinquencyGraceDays` (which now exclusively drives delinquency machinery). This allows a product configuration such as "begin enforcing minimum payment obligations 5 days after disbursement" without conflating breach and delinquency semantics.

=== Scope

The scope of this document includes:

* New `breachGraceDays` column on `m_wc_loan_product` and `m_wc_loan`.
* New `breachGraceDays` field on the product and loan REST APIs (create, update, retrieve).
* Validation rule `breachGraceDays >= 0` at both product and loan levels.
* Application of `breachGraceDays` only to the first period generated by `BreachScheduleBusinessStep`.
* Implicit behavior on Near-Breach evaluation as a consequence of the shifted first period.

The scope explicitly excludes:

* No post-period grace — once a period closes with `outstanding_amount > 0`, the period is flagged `breach = true` immediately, regardless of `breachGraceDays`.
* `delinquencyGraceDays` is not renamed and not removed; it retains its original semantics.
* No automatic data migration from `delinquency_grace_days` to `breach_grace_days`.

=== Applicability

* Applies to Working Capital loans whose product (or individual loan) has a breach configuration assigned (`breach_id` not null). When no breach configuration is set, the breach schedule is not generated and `breachGraceDays` is inert.
* Applies only to the first period in the schedule. Subsequent periods chain from `previousPeriod.toDate + 1` and do not re-apply the grace.
* Can be overridden at the loan-application level only when the product allows breach overrides (`allowAttributeOverrides.breach = true`).

=== Definitions and Key Concepts

*`breachGraceDays`:* Optional non-negative integer representing the number of calendar days added to the actual disbursement date before the first breach period begins. Stored on `m_wc_loan_product.breach_grace_days` (product default) and `m_wc_loan.breach_grace_days` (loan-level value, copied from product or overridden). Columns are nullable with DB-level `DEFAULT 0`; runtime treats a `null` value as `0`.

*First Breach Period Start:* `firstPeriod.fromDate = actualDisbursementDate + breachGraceDays days`. Computed by `WorkingCapitalLoanBreachScheduleServiceImpl.generateInitialPeriod` using the helper `getBreachGraceDays(loan)`.

*Implicit Near-Breach Shift:* `WorkingCapitalLoanNearBreachEvaluationServiceImpl.evaluatePeriod` computes its first checkpoint as `firstEvalDate = addFrequency(period.fromDate, frequency, frequencyType)`. Since `period.fromDate` is already shifted by `breachGraceDays`, the first near-breach checkpoint is shifted by the same amount.

== Design Decisions and Considerations

=== Decoupled from `delinquencyGraceDays`

Prior to this feature, the breach schedule generator read `delinquencyGraceDays` to offset the first period — a coupling that conflated two unrelated business concepts. `breachGraceDays` is a new, independent field. The two fields can now be set to different values on the same product.

=== Sibling Helper Instead of Rename

`WorkingCapitalLoanBreachScheduleServiceImpl` exposes a new private helper `getBreachGraceDays(loan)`. The original `getGraceDays(loan)` (which reads `delinquencyGraceDays`) is intentionally retained to keep the delinquency-side concept available without confusion.

=== Hard Cut-Over Without SQL Backfill

The Liquibase changeset adds `breach_grace_days` as a nullable column with DB-level `DEFAULT 0`; existing rows receive `0` via that default. No automatic copy from `delinquency_grace_days` is performed. Tenants that previously relied on `delinquencyGraceDays` to shift the breach schedule must update `breachGraceDays` explicitly via the REST API or SQL.

=== Null Treated as Zero

The column is nullable with DB-level `DEFAULT 0`, and the Java field has no in-class initializer — so `breachGraceDays` may legitimately be `null` in memory (e.g., after a create request that omits the parameter, before the entity is reloaded from the DB). The service helper `getBreachGraceDays` returns `0` when it encounters a `null` value, so `null` and an explicit `0` are semantically equivalent at runtime.

=== No Override Flag of Its Own

`breachGraceDays` inherits the existing `allowAttributeOverrides.breach` flag. No new boolean is added to `m_wc_loan_product_configurable_attributes`.

== Database Design

=== Overview

Two existing tables gain one column each. No new tables are introduced.

=== Changes to Existing Tables

==== m_wc_loan_product

New column:

[cols="1,2,1,3",options="header"]
|===
| Column Name | Type | Constraints | Description
| `breach_grace_days` | INT | nullable, default `0` | Product-level default number of days the first breach period start is shifted past the actual disbursement date
|===

==== m_wc_loan

New column:

[cols="1,2,1,3",options="header"]
|===
| Column Name | Type | Constraints | Description
| `breach_grace_days` | INT | nullable, default `0` | Loan-level value (copied from product at origination; may be overridden when the product allows breach overrides)
|===

The Liquibase changeset is `fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0043_wc_breach_grace_days.xml`. Both add-column changesets are guarded by `columnExists` preconditions so they are idempotent.

== Configuration

=== Loan Product Configuration

`breachGraceDays` is optional on the product create/update request. When omitted on create, the DB column default (`0`) applies.

[source,json]
----
{
"breachId": 1, // breach config required for the schedule to be generated
"breachGraceDays": 5, // optional; integer >= 0; column has DB default 0
"allowAttributeOverrides": {
"breach": true // optional; required to allow per-loan override of breachGraceDays
}
}
----

=== Loan-Level Override

When the loan application omits `breachGraceDays`, the loan inherits the product's value. When provided, the loan stores its own value. Override only takes effect when the product allows breach overrides; otherwise the assembler keeps the product default.

== API Design

=== Endpoints

==== Working Capital Loan Product — Create

[source]
----
POST /v1/working-capital/products
----

**Request Body (relevant fields):**

[source,json]
----
{
"name": "WC Product",
"shortName": "WCP",
"breachId": 1,
"breachGraceDays": 5, // optional; integer >= 0; column has DB default 0
"delinquencyGraceDays": 0, // unrelated field, retained for delinquency
"locale": "en_US",
"dateFormat": "yyyy-MM-dd"
}
----

==== Working Capital Loan Product — Update

[source]
----
PUT /v1/working-capital/products/{productId}
----

Accepts `breachGraceDays` as a partial-update field. When present, the change is tracked in the response `changes` map under the key `breachGraceDays`.

==== Working Capital Loan Product — Retrieve

[source]
----
GET /v1/working-capital/products/{productId}
GET /v1/working-capital/products
----

The response includes `breachGraceDays` (integer) at the top level.

==== Working Capital Loan — Create

[source]
----
POST /v1/working-capital/loans
----

Accepts an optional `breachGraceDays` integer. Inherited from the product when omitted; overrides only apply when the product allows breach overrides.

==== Working Capital Loan — Update

[source]
----
PUT /v1/working-capital/loans/{loanId}
----

Accepts `breachGraceDays` as a partial-update field.

==== Working Capital Loan — Retrieve

[source]
----
GET /v1/working-capital/loans/{loanId}
----

The response includes the loan's effective `breachGraceDays` value.

== Validation Rules

=== Product

* `breachGraceDays` is optional on create; when present must be `>= 0`. Error code: `[breachGraceDays] The parameter \`breachGraceDays\` must be zero or greater.` (HTTP 400).
* Enforced by `WorkingCapitalLoanProductDataValidator.validateSettingsFields` via `integerZeroOrGreater()` (applied both for create and partial update).

=== Loan Application

* Same `integerZeroOrGreater()` validation in `WorkingCapitalLoanApplicationDataValidator` for both create and update paths.
* No upper bound is enforced.

== Business Rules

=== Breach Schedule Generation

* `WorkingCapitalLoanBreachScheduleServiceImpl.generateInitialPeriod` reads `loan.getLoanProductRelatedDetails().getBreachGraceDays()` and computes `fromDate = actualDisbursementDate.plusDays(breachGraceDays)`.
* `numberOfDays = (toDate − fromDate) + 1` (inclusive count).
* The grace is applied only when generating period number `1`. `generateNextPeriodIfNeeded` chains subsequent periods as `nextFromDate = latestPeriod.toDate + 1 day` and never re-applies the grace.
* When the product has no breach configuration, `BreachScheduleBusinessStep` short-circuits and no schedule is created — `breachGraceDays` has no effect.

=== Near-Breach Evaluation

* No code change in `WorkingCapitalLoanNearBreachEvaluationServiceImpl`.
* Near-breach checkpoints are derived from `period.fromDate`. Because the first period's `fromDate` is already shifted by `breachGraceDays`, the first near-breach checkpoint within period `1` is shifted by the same amount.
* Checkpoints in later periods are unaffected (their periods are chained without re-applying the grace).

=== Loan Assembly From Product

* `WorkingCapitalLoanAssemblerImpl` copies `breachGraceDays` from the product's `WorkingCapitalLoanProductRelatedDetail` into the loan's `WorkingCapitalLoanProductRelatedDetails` at origination.
* If the loan create/update request includes `breachGraceDays`, the assembler honours the override only when the product allows breach overrides; otherwise the product default is retained.

== Example Scenarios

=== Scenario #1: Breach Schedule First Period Shifted

**Setup:**

* Product configured with `breachId` (FLAT, `breachAmount = 500`, `breachFrequency = 1`, `breachFrequencyType = MONTHS`) and `breachGraceDays = 5`.
* Loan disbursed on `2026-01-01` with principal `9000`.

**Action:**

The Close-of-Business pipeline runs `BreachScheduleBusinessStep` on the day of disbursement.

**Expected Behavior:**

* Period `1` is created with `fromDate = 2026-01-06`, `toDate = 2026-02-05`, `numberOfDays = 31`, `minPaymentAmount = 500.00`, `breach = null`, `nearBreach = null`.
* Subsequent periods chain from `2026-02-06` and do not re-apply the 5-day grace.

=== Scenario #2: Near-Breach Window Implicitly Shifted

**Setup:**

* Product configured with breach (FLAT, `breachAmount = 900`, `breachFrequency = 3`, `breachFrequencyType = MONTHS`), near-breach (`nearBreachFrequency = 60 DAYS`, `nearBreachThreshold = 33.33`), and `breachGraceDays = 5`.
* Loan disbursed on `2026-01-01`. No repayment is made.

**Action:**

Business date advances to `2026-03-07` and COB runs.

**Expected Behavior:**

* Period `1` of the breach schedule starts on `2026-01-06` (disbursement + 5 days grace) and ends on `2026-04-05`.
* The first near-breach checkpoint is `2026-01-06 + 60 days = 2026-03-07`.
* Since `paidAmount = 0` at `2026-03-07` and `requiredCumulative = (1) × 33.33% × 900 ≈ 299.97`, the period is flagged `nearBreach = true`.
* `breach` remains `null` because the period has not yet ended.

== Summary

`breachGraceDays` is an independent, validated configuration field on the Working Capital Loan Product and Loan that delays the start of the first breach period (and, by chaining, the first near-breach evaluation window). Key aspects:

* Stored as a nullable `INT` column with DB-level `DEFAULT 0` on `m_wc_loan_product` and `m_wc_loan` (changeset `0043_wc_breach_grace_days.xml`); the breach-schedule service treats `null` as `0`.
* Exposed via `breachGraceDays` in product and loan REST APIs (create / update / retrieve).
* Applied only to period `1` of the breach schedule; chained periods are not regraced.
* Drives Near-Breach evaluation shift implicitly — no separate near-breach grace field is needed.
* Decoupled from the legacy `delinquencyGraceDays`, which keeps its original delinquency semantics.
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ public void createWorkingCapitalLoan(final DataTable table) {

@When("Admin creates a working capital loan using created product with the following data:")
public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) {
submitLoanUsingCreatedProduct(table, null);
}

@When("Admin creates a working capital loan using created product with breachGraceDays {int} and the following data:")
public void createWorkingCapitalLoanUsingCreatedProductWithBreachGraceDays(final int breachGraceDays, final DataTable table) {
submitLoanUsingCreatedProduct(table, breachGraceDays);
}

private void submitLoanUsingCreatedProduct(final DataTable table, final Integer breachGraceDays) {
final List<List<String>> data = table.asLists();
final List<String> rawData = data.get(1);
final Long clientId = extractClientId();
Expand All @@ -201,6 +210,9 @@ public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) {
.principalAmount(new BigDecimal(principal)).totalPaymentVolume(new BigDecimal(totalPaymentVolume))
.periodPaymentRate(new BigDecimal(periodPaymentRate))
.discount(discount != null && !discount.isEmpty() ? new BigDecimal(discount) : null);
if (breachGraceDays != null) {
loansRequest.breachGraceDays(breachGraceDays);
}
testContext().set(TestContextKey.LOAN_CREATE_REQUEST, loansRequest);

final PostWorkingCapitalLoansResponse response = ok(
Expand All @@ -210,6 +222,14 @@ public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) {
log.info("Working Capital Loan created with dynamic product ID: {}, Loan ID: {}", loanProductId, response.getLoanId());
}

@Then("Working capital loan account has breachGraceDays {int}")
public void verifyLoanBreachGraceDays(final int expectedBreachGraceDays) {
final Long loanId = getCreatedLoanId();
final GetWorkingCapitalLoansLoanIdResponse response = ok(
() -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId));
assertThat(response.getBreachGraceDays()).as("breachGraceDays").isEqualTo(expectedBreachGraceDays);
}

@Then("Working capital loan creation was successful")
public void verifyWorkingCapitalLoanCreationSuccess() {
final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public class WorkingCapitalStepDef extends AbstractStepDef {
public static final String DELINQUENCY_BUCKET_ID_FIELD_NAME = "delinquencyBucketId";
public static final String DELINQUENCY_GRACE_DAYS_FIELD_NAME = "delinquencyGraceDays";
public static final String DELINQUENCY_START_TYPE_FIELD_NAME = "delinquencyStartType";
public static final String BREACH_GRACE_DAYS_FIELD_NAME = "breachGraceDays";
public static final String BREACH_ID_FIELD_NAME = "breachId";
public static final String NEAR_BREACH_ID_FIELD_NAME = "nearBreachId";
public static final String LOCALE_FIELD_NAME = "locale";
Expand Down Expand Up @@ -340,13 +341,17 @@ public void createWorkingCapitalLoanProductWithCustomBreachConfig(final DataTabl

final String graceDaysStr = data.get("delinquencyGraceDays");
final Integer graceDays = graceDaysStr != null && !graceDaysStr.isEmpty() ? Integer.valueOf(graceDaysStr) : null;
final String breachGraceDaysStr = data.get(BREACH_GRACE_DAYS_FIELD_NAME);
final Integer breachGraceDays = breachGraceDaysStr != null && !breachGraceDaysStr.isEmpty() ? Integer.valueOf(breachGraceDaysStr)
: null;

final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10);
final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory
.defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest() //
.name(name) //
.breachId(breachId) //
.delinquencyGraceDays(graceDays);
.delinquencyGraceDays(graceDays) //
.breachGraceDays(breachGraceDays);

final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request);
testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response);
Expand Down Expand Up @@ -380,14 +385,18 @@ public void createWorkingCapitalLoanProductWithBreachAndNearBreachConfig(final D

final String graceDaysStr = data.get("delinquencyGraceDays");
final Integer graceDays = graceDaysStr != null && !graceDaysStr.isEmpty() ? Integer.valueOf(graceDaysStr) : null;
final String breachGraceDaysStr = data.get(BREACH_GRACE_DAYS_FIELD_NAME);
final Integer breachGraceDays = breachGraceDaysStr != null && !breachGraceDaysStr.isEmpty() ? Integer.valueOf(breachGraceDaysStr)
: null;

final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10);
final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory
.defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest() //
.name(name) //
.breachId(breachId) //
.nearBreachId(nearBreachId) //
.delinquencyGraceDays(graceDays);
.delinquencyGraceDays(graceDays) //
.breachGraceDays(breachGraceDays);

final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request);
testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response);
Expand Down
Loading