diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc index 170660c3f3f..b5ba2b973ff 100644 --- a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc @@ -67,6 +67,15 @@ "string" ] }, + { + "default": null, + "name": "breachEffectiveStartDate", + "doc": "breachStartDate shifted by the breach grace days; only set for the first breach period.", + "type": [ + "null", + "string" + ] + }, { "default": null, "name": "nearBreach", diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-loan-start-dates.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-loan-start-dates.adoc index 7b775341386..bd718f20aee 100644 --- a/fineract-doc/src/docs/en/chapters/features/working-capital-loan-start-dates.adoc +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-loan-start-dates.adoc @@ -2,43 +2,45 @@ == Overview -The Working Capital (WC) Loan retrieve response exposes three read-only derived fields, `breachStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate`, that tell a consumer *when* a loan first entered breach and when it first became delinquent. All three values are computed on-the-fly from the loan's breach schedule and delinquency-range schedule when a single WC loan is fetched. They are `null` while the loan is healthy and become populated once the corresponding schedule period is flagged by the WC Close-of-Business (COB) job. +The Working Capital (WC) Loan retrieve response exposes four read-only derived fields, `breachStartDate`, `breachEffectiveStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate`, that tell a consumer *when* a loan first entered breach and when it first became delinquent. All four values are computed on-the-fly from the loan's breach schedule and delinquency-range schedule when a single WC loan is fetched. They are `null` while the loan is healthy and become populated once the corresponding schedule period is flagged by the WC Close-of-Business (COB) job. -The feature is implemented in the `fineract-working-capital-loan` module and is exposed on the WC Loan retrieve REST API as `breachStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate`. +The feature is implemented in the `fineract-working-capital-loan` module and is exposed on the WC Loan retrieve REST API as `breachStartDate`, `breachEffectiveStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate`. === Purpose Lenders and collection officers need a single, stable anchor date for each loan that answers "since when has this loan been in trouble?" — for ageing reports, dunning timelines, and SLA tracking. Rather than forcing API consumers to scan the full breach/delinquency schedule and re-apply grace-day arithmetic themselves, the platform derives the earliest affected period and returns its start date directly. -The first delinquency period can additionally be shifted by delinquency grace days, which act as a "cool off" period for the borrower. `delinquencyEffectiveStartDate` makes that shift explicit so it can be shared with the customer without the consumer having to know how the schedule is generated. +The first breach period and the first delinquency period can additionally be shifted by their grace days (`breachGraceDays` and `delinquencyGraceDays`), which act as a "cool off" period for the borrower. `breachEffectiveStartDate` and `delinquencyEffectiveStartDate` make that shift explicit so it can be shared with the customer without the consumer having to know how the schedules are generated. === Scope The scope of this document includes: -* Read-only fields `breachStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate` on the WC Loan retrieve response. +* Read-only fields `breachStartDate`, `breachEffectiveStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate` on the WC Loan retrieve response. * Derivation logic in `WorkingCapitalLoanApplicationReadPlatformServiceImpl.enrichWithStartDates`. * Two repository finder methods that locate the earliest breached / delinquent period. * The grace-day handling shared by the breach schedule and the delinquency-range schedule. The scope explicitly excludes: -* No new database tables or columns — both values are derived at read time from existing schedules. +* No new database tables or columns — all values are derived at read time from existing schedules. * No dedicated business events are emitted for these fields. They do travel inside every WC loan business event payload, because `WorkingCapitalLoanBusinessEventSerializer` builds that payload from `retrieveOne`. * The fields are populated only by the single-loan retrieve path (`retrieveOne`); they are not enriched in the paginated list response. === Applicability * Applies to Working Capital loans returned by `GET /v1/working-capital-loans/{loanId}` and `GET /v1/working-capital-loans/external-id/{loanExternalId}`. -* `breachStartDate` is meaningful only when the product/loan has a breach configuration (`breach_id` not null) so that a breach schedule exists. +* `breachStartDate` and `breachEffectiveStartDate` are meaningful only when the product/loan has a breach configuration (`breach_id` not null) so that a breach schedule exists. * `delinquencyStartDate` and `delinquencyEffectiveStartDate` are meaningful only when the loan has a delinquency bucket assigned so that a delinquency-range schedule exists. -* `delinquencyEffectiveStartDate` is additionally restricted to the first delinquency period, which is the only one the delinquency grace days shift. +* `breachEffectiveStartDate` and `delinquencyEffectiveStartDate` are additionally restricted to the first period of their schedule, which is the only one the grace days shift. * Values reflect the state at the time of the last WC COB run; a loan whose periods have not yet been evaluated will report `null`. === Definitions and Key Concepts *`breachStartDate`:* The `fromDate` of the earliest breached breach-schedule period (`m_wc_loan_breach_schedule` row with `breach = true`, ordered by `from_date` ascending). The breach schedule applies `breachGraceDays` to the `toDate` of its first period, so no adjustment is applied to the `fromDate`. `null` when no period is in breach. +*`breachEffectiveStartDate`:* `breachStartDate` shifted forward by `breachGraceDays` — the date the breach clock effectively starts ticking once the cool off period is taken into account. It is set **only** when the earliest breached period is the first one (`period_number = 1`) and `breachGraceDays > 0`; `null` otherwise. + *`delinquencyStartDate`:* The `fromDate` of the earliest delinquent range-schedule period (`m_wc_loan_delinquency_range_schedule` row with `min_payment_criteria_met = false`, ordered by `from_date` ascending). The delinquency-range schedule already reflects `delinquencyGraceDays` in the `toDate` of its first period, so no further adjustment is applied to the `fromDate`. `null` when no period is delinquent. *`delinquencyEffectiveStartDate`:* `delinquencyStartDate` shifted forward by `delinquencyGraceDays` — the date the delinquency clock effectively starts ticking once the cool off period is taken into account. It is set **only** when the earliest delinquent period is the first one (`period_number = 1`) and `delinquencyGraceDays > 0`; `null` otherwise. @@ -56,39 +58,48 @@ Both schedules treat grace days the same way: the *first* period's `toDate` is e * *Breach schedule*: `WorkingCapitalLoanBreachScheduleServiceImpl.naturalToDate` adds `breachGraceDays` only when `periodNumber == 1`. * *Delinquency-range schedule*: `WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.generateInitialPeriod` adds `delinquencyGraceDays` to the first period's `toDate`; `generateNextPeriodIfNeeded` chains later periods from `toDate + 1` without any grace. -Because the whole chain shifts with the first period's end date, extending the first period by `g` days is equivalent to starting it `g` days later. That equivalence is what `delinquencyEffectiveStartDate` exposes: `fromDate + delinquencyGraceDays` is the date the borrower's cool off period ends and the delinquency window effectively begins. +Because the whole chain shifts with the first period's end date, extending the first period by `g` days is equivalent to starting it `g` days later. That equivalence is what `breachEffectiveStartDate` and `delinquencyEffectiveStartDate` expose: `fromDate + graceDays` is the date the borrower's cool off period ends and the breach / delinquency window effectively begins. `breachStartDate` and `delinquencyStartDate` are therefore both the raw `fromDate` of their earliest flagged period, with no grace arithmetic applied. See <>. === Derived at Read Time, Not Persisted -All three fields are computed in `enrichWithStartDates` during `retrieveOne` rather than stored as columns. This keeps the values always consistent with the current schedule state (e.g., after a backdated payment clears the earliest breached period, the next retrieve naturally reports the new earliest breached period) without requiring a migration or a maintenance job to keep a denormalized column in sync. +All four fields are computed in `enrichWithStartDates` during `retrieveOne` rather than stored as columns. This keeps the values always consistent with the current schedule state (e.g., after a backdated payment clears the earliest breached period, the next retrieve naturally reports the new earliest breached period) without requiring a migration or a maintenance job to keep a denormalized column in sync. === The Effective Start Date Is Only Emitted When It Adds Information -`resolveDelinquencyEffectiveStartDate` returns `null` rather than echoing `delinquencyStartDate`: +Both effective start dates are resolved by the same `resolveEffectiveStartDate`, which returns `null` rather than echoing the plain start date: [source,java] ---- -if (!Integer.valueOf(1).equals(period.getPeriodNumber()) || delinquencyGraceDays == null || delinquencyGraceDays <= 0) { +if (!Integer.valueOf(1).equals(periodNumber) || graceDays == null || graceDays <= 0) { return null; } -return period.getFromDate().plusDays(delinquencyGraceDays); +final LocalDate effectiveStartDate = fromDate.plusDays(graceDays); +return DateUtils.isAfter(effectiveStartDate, toDate) ? null : effectiveStartDate; ---- A `null` or zero grace value yields `null`, as does any period other than the first one — those periods carry no grace days, so an "effective" start identical to the plain start would only be noise for the consumer. +=== A Cool Off Period That Never Took Place Is Not Reported + +The grace days belong to the first period, and an operation can end that period before they are over. On the breach schedule, a reset that restarts the schedule from its own date cuts the first period down to `[fromDate, resetDate - 1]` while keeping it as period 1, and once that shortened period expires unpaid it is flagged as breached like any other. + +When `fromDate + graceDays` falls after the period's own `toDate`, the cool off period never finished running inside the period it belongs to, so there is nothing to report and `resolveEffectiveStartDate` returns `null`. Reporting the raw arithmetic instead would hand the consumer a date after the period had already ended — and, in the reset case, after the breach itself had been flagged. + +The guard is shared by both effective start dates and covers any future operation that shortens a first period; it is not specific to resets. + == Database Design === Overview -No tables or columns are introduced by this feature. All three fields are derived by querying two existing schedule tables. +No tables or columns are introduced by this feature. All four fields are derived by querying two existing schedule tables. === Existing Tables -*`m_wc_loan_breach_schedule`*: read to find the earliest period flagged `breach = true`. The relevant columns are `wc_loan_id`, `from_date`, and `breach`. +*`m_wc_loan_breach_schedule`*: read to find the earliest period flagged `breach = true`. The relevant columns are `wc_loan_id`, `from_date`, `to_date`, `period_number`, and `breach`. -*`m_wc_loan_delinquency_range_schedule`*: read to find the earliest period flagged `min_payment_criteria_met = false`. The relevant columns are `wc_loan_id`, `from_date`, `period_number`, and `min_payment_criteria_met`. +*`m_wc_loan_delinquency_range_schedule`*: read to find the earliest period flagged `min_payment_criteria_met = false`. The relevant columns are `wc_loan_id`, `from_date`, `to_date`, `period_number`, and `min_payment_criteria_met`. == API Design @@ -113,6 +124,7 @@ GET /v1/working-capital-loans/external-id/{loanExternalId} "delinquencyStartType": { "id": 2, "code": "DISBURSEMENT", "value": "Disbursement" }, "breachGraceDays": 5, // product/loan breach grace days "breachStartDate": [2026, 1, 1], // fromDate of earliest breached period + "breachEffectiveStartDate": [2026, 1, 6], // breachStartDate + breachGraceDays (first period only) "delinquencyStartDate": [2026, 1, 1], // fromDate of earliest delinquent period "delinquencyEffectiveStartDate": [2026, 1, 4] // delinquencyStartDate + delinquencyGraceDays (first period only) } @@ -120,21 +132,30 @@ GET /v1/working-capital-loans/external-id/{loanExternalId} [NOTE] ==== -`breachStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate` are read-only and derived. They cannot be set in a create/update request and are absent (or `null`) on responses where the loan is not yet in breach / delinquency, or where the breach / delinquency configuration is not present. +`breachStartDate`, `breachEffectiveStartDate`, `delinquencyStartDate` and `delinquencyEffectiveStartDate` are read-only and derived. They cannot be set in a create/update request and are absent (or `null`) on responses where the loan is not yet in breach / delinquency, or where the breach / delinquency configuration is not present. ==== === Business Events -Every WC loan business event carries the same `delinquency` block as the retrieve response, because `WorkingCapitalLoanBusinessEventSerializer` builds its Avro payload from `retrieveOne`. The `delinquencyEffectiveStartDate` field is an optional (`null`-defaulted) addition to `WorkingCapitalLoanDelinquencyDataV1`, so existing consumers remain compatible. +Every WC loan business event carries the same `breach` and `delinquency` blocks as the retrieve response, because `WorkingCapitalLoanBusinessEventSerializer` builds its Avro payload from `retrieveOne`. `breachEffectiveStartDate` is an optional (`null`-defaulted) addition to `WorkingCapitalBreachDataV1` and `delinquencyEffectiveStartDate` is an optional (`null`-defaulted) addition to `WorkingCapitalLoanDelinquencyDataV1`, so existing consumers remain compatible. == Business Rules === Breach Start Date -* `breachStartDate` is the `fromDate` of the earliest `m_wc_loan_breach_schedule` row with `breach = true` (`findTopByLoanIdAndBreachTrueOrderByFromDateAsc`). +* `breachStartDate` is the `fromDate` of the earliest `m_wc_loan_breach_schedule` row with `breach = true` (`findTopByLoanIdAndBreachTrueOrderByFromDateAsc`), with no grace arithmetic applied. +* The breach schedule is anchored according to `breachStartType`: `LOAN_CREATION` anchors the first period on the loan submitted-on date, while `DISBURSEMENT` (or an unset type) anchors it on the actual disbursement date. * The breach grace days are *not* added — the breach schedule generator applies them to the first period's `toDate`, not to its `fromDate`. * When no breach period exists or none is flagged `breach = true`, `breachStartDate` is `null`. +=== Breach Effective Start Date + +* `breachEffectiveStartDate` is `breachStartDate + breachGraceDays`. +* It is emitted only when the earliest breached period is the first one (`period_number = 1`) — later periods carry no grace days. +* It is emitted only when `breachGraceDays` is set and greater than `0`; a `null` or `0` grace yields `null`. +* It is emitted only when the resulting date falls within the period it belongs to. When an operation cut that period short — a reset restarting the schedule from its own date — and `breachStartDate + breachGraceDays` lands after the period's `toDate`, the value is `null`. +* When no breached period exists, `breachEffectiveStartDate` is `null`. + === Delinquency Start Date * `delinquencyStartDate` is the `fromDate` of the earliest `m_wc_loan_delinquency_range_schedule` row with `min_payment_criteria_met = false` (`findTopByLoanIdAndMinPaymentCriteriaMetFalseOrderByFromDateAsc`), with no grace arithmetic applied. @@ -146,6 +167,7 @@ Every WC loan business event carries the same `delinquency` block as the retriev * `delinquencyEffectiveStartDate` is `delinquencyStartDate + delinquencyGraceDays`. * It is emitted only when the earliest delinquent period is the first one (`period_number = 1`) — later periods carry no grace days. * It is emitted only when `delinquencyGraceDays` is set and greater than `0`; a `null` or `0` grace yields `null`. +* It is emitted only when the resulting date falls within the period it belongs to; otherwise it is `null`. * When no delinquent period exists, `delinquencyEffectiveStartDate` is `null`. == Example Scenarios @@ -166,12 +188,13 @@ The business date is advanced to `2026-01-26`, past the end of both first period **Expected Behavior:** * `breachStartDate = 2026-01-01` — the `fromDate` of the first breached period; the breach grace only extended its `toDate`. +* `breachEffectiveStartDate = 2026-01-06` — `breachStartDate` plus `breachGraceDays` (`+5`): the cool off period runs through `2026-01-05`. * `delinquencyStartDate = 2026-01-01` — the `fromDate` of the first delinquent period. * `delinquencyEffectiveStartDate = 2026-01-04` — `delinquencyStartDate` plus `delinquencyGraceDays` (`+3`): the cool off period runs through `2026-01-03`. This is the exact behavior asserted by `WorkingCapitalLoanStartDatesTest.testStartDatesArePopulatedWhenLoanBreachesAndBecomesDelinquent`. -=== Scenario #2: Healthy loan — both start dates null +=== Scenario #2: Healthy loan — all start dates null **Setup:** @@ -184,6 +207,7 @@ The `WC_LOAN_COB` job runs on the disbursement date itself, before any schedule **Expected Behavior:** * `breachStartDate = null` — the loan is not in breach. +* `breachEffectiveStartDate = null` — there is no breached period to shift. * `delinquencyStartDate = null` — the loan is not delinquent. * `delinquencyEffectiveStartDate = null` — there is no delinquent period to shift. @@ -205,22 +229,44 @@ The borrower pays the minimum payment on `2026-01-05`, so the first period meets * `delinquencyStartDate = 2026-01-24` — the `fromDate` of the second period, now the earliest delinquent one. * `delinquencyEffectiveStartDate = null` — the second period carries no grace days, so there is no cool off period to report. -This is asserted by `WorkingCapitalLoanStartDatesTest.testDelinquencyEffectiveStartDateIsNullWhenTheDelinquentPeriodIsNotTheFirstOne`. +This is asserted by `WorkingCapitalLoanStartDatesTest.testDelinquencyEffectiveStartDateIsNullWhenTheDelinquentPeriodIsNotTheFirstOne`. The breach counterpart is asserted by `WorkingCapitalLoanStartDatesTest.testBreachEffectiveStartDateIsNullWhenTheBreachedPeriodIsNotTheFirstOne`. + +=== Scenario #4: Reset inside the breach grace window + +**Setup:** + +* Same product/loan configuration as Scenario #1, disbursed on `D = 2026-01-01`, so the first breach period is `[2026-01-01 .. 2026-01-20]` and the cool off period would end on `2026-01-06`. + +**Action:** + +On `2026-01-03` — inside the grace window — a `reset` breach action with `restartPeriodFromResetDate = true` is recorded. `splitPeriodAtReset` cuts the first period down to `[2026-01-01 .. 2026-01-02]` and starts a new period on the reset date. The business date is advanced to `2026-01-04` and the `WC_LOAN_COB` job runs, flagging the shortened first period as breached. + +**Expected Behavior:** + +* `breachStartDate = 2026-01-01` — the `fromDate` of the cut first period, unchanged by the reset. +* `breachEffectiveStartDate = null` — the cool off period would have ended on `2026-01-06`, after the period itself ended on `2026-01-02`, so there is no cool off period to report. + +Undoing the reset rebuilds the first period from its natural length through `restoreSplitPeriod`, which puts the grace days back. Once that restored period expires unpaid, `breachEffectiveStartDate` reports `2026-01-06` again. + +This is asserted by `WorkingCapitalLoanStartDatesTest.testBreachEffectiveStartDateIsNullWhenAResetCutsTheFirstPeriodInsideTheGraceWindow` and `WorkingCapitalLoanStartDatesTest.testBreachEffectiveStartDateComesBackWhenTheResetIsUndone`. === Exception Cases -* *No breach configuration:* When the product/loan has no `breach_id`, the breach schedule is never generated; `findTopByLoanIdAndBreachTrueOrderByFromDateAsc` returns empty and `breachStartDate` stays `null` regardless of payment behavior. +* *No breach configuration:* When the product/loan has no `breach_id`, the breach schedule is never generated; `findTopByLoanIdAndBreachTrueOrderByFromDateAsc` returns empty and both `breachStartDate` and `breachEffectiveStartDate` stay `null` regardless of payment behavior. * *No delinquency bucket:* With no delinquency-range schedule generated, both `delinquencyStartDate` and `delinquencyEffectiveStartDate` stay `null`. -* *`null` or zero delinquency grace days:* `delinquencyStartDate` is unaffected and `delinquencyEffectiveStartDate` is `null`, because there is no cool off period to report. Asserted by `WorkingCapitalLoanStartDatesTest.testDelinquencyEffectiveStartDateIsNullWhenNoGraceDaysConfigured`. -* *Delinquent period other than the first:* `delinquencyEffectiveStartDate` is `null`; only the first period is shifted by the grace days. +* *`null` or zero grace days:* The plain start date is unaffected and the matching effective start date is `null`, because there is no cool off period to report. Asserted by `WorkingCapitalLoanStartDatesTest.testBreachEffectiveStartDateIsNullWhenNoGraceDaysConfigured` and `WorkingCapitalLoanStartDatesTest.testDelinquencyEffectiveStartDateIsNullWhenNoGraceDaysConfigured`. +* *Flagged period other than the first:* `breachEffectiveStartDate` / `delinquencyEffectiveStartDate` is `null`; only the first period is shifted by the grace days. +* *Reset inside the breach grace window:* A reset that restarts the schedule cuts the first period at the reset date. When that cut lands before the grace days are over, `breachEffectiveStartDate` is `null`; undoing the reset restores the period's natural end date and the value becomes reportable again. * *Earliest period subsequently cured:* Because the values are derived at read time, if a backdated payment clears the earliest breached/delinquent period, the *next* retrieve reports the new earliest still-flagged period — or `null` if none remain. * *List vs. single retrieve:* The paginated WC loan list response does not invoke `enrichWithStartDates`; these fields are populated only by the single-loan retrieve path. == Summary -Working Capital Loan Start Dates surface three derived, read-only anchor dates on the WC loan retrieve response. Key aspects include: +Working Capital Loan Start Dates surface four derived, read-only anchor dates on the WC loan retrieve response. Key aspects include: * `breachStartDate` — the `fromDate` of the earliest breached breach-schedule period. The breach grace days extend the first period's `toDate`, so they do not move this date. +* `breachEffectiveStartDate` — `breachStartDate` plus `breachGraceDays`, emitted only for the first breach period, only when grace days are configured, and only when the resulting date still falls inside that period. * `delinquencyStartDate` — the `fromDate` of the earliest delinquent range-schedule period, likewise unaffected by the delinquency grace days. -* `delinquencyEffectiveStartDate` — `delinquencyStartDate` plus `delinquencyGraceDays`, emitted only for the first delinquency period and only when grace days are configured. It is the customer-facing answer to "when does the cool off period end?". -* All three values are computed at read time in `enrichWithStartDates`, are `null` while the loan is healthy or unconfigured, and stay consistent with the current schedule state without any persisted column. +* `delinquencyEffectiveStartDate` — `delinquencyStartDate` plus `delinquencyGraceDays`, under the same rules as `breachEffectiveStartDate`. +* The two effective start dates are the customer-facing answer to "when does the cool off period end?". +* All four values are computed at read time in `enrichWithStartDates`, are `null` while the loan is healthy or unconfigured, and stay consistent with the current schedule state without any persisted column. diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java index edcc19fc247..6e104950e1f 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java @@ -1181,9 +1181,29 @@ public void workingCapitalLoanBreachPastDueChangeEventCheck(final Long loanId, f public void workingCapitalLoanBreachChangeEventCheck(final Long loanId, final Boolean expectedBreach) { workingCapitalLoanEventPayloadCheck(WorkingCapitalLoanBreachChangeEvent.class, loanId, event -> { assertThat(event.getBreach()).isNotNull(); - assertThat(event.getBreach().getBreachSchedule()).isNotNull().isNotEmpty(); - assertThat(event.getBreach().getBreachSchedule()).as("breach.breachSchedule has a period with breach=%s", expectedBreach) - .anyMatch(period -> expectedBreach.equals(period.getBreach())); + assertBreachScheduleHasPeriodWithBreach(event.getBreach(), expectedBreach); + }); + } + + private static void assertBreachScheduleHasPeriodWithBreach(final WorkingCapitalBreachDataV1 breach, final Boolean expectedBreach) { + assertThat(breach.getBreachSchedule()).as("breach.breachSchedule").isNotNull().isNotEmpty(); + assertThat(breach.getBreachSchedule()).as("breach.breachSchedule has a period with breach=%s", expectedBreach) + .anyMatch(period -> expectedBreach.equals(period.getBreach())); + } + + public void workingCapitalLoanBreachChangeEventWithBreachDataCheck(final Long loanId, final Map expected) { + workingCapitalLoanEventPayloadCheck(WorkingCapitalLoanBreachChangeEvent.class, loanId, event -> { + final WorkingCapitalBreachDataV1 breach = event.getBreach(); + assertThat(breach).as("breach").isNotNull(); + Optional.ofNullable(expected.get("breachFlag")) + .ifPresent(flag -> assertBreachScheduleHasPeriodWithBreach(breach, Boolean.valueOf(flag))); + assertEventDateEquals("breach.breachStartDate", breach.getBreachStartDate(), expected.get("breachStartDate")); + assertEventDateEquals("breach.breachEffectiveStartDate", breach.getBreachEffectiveStartDate(), + expected.get("breachEffectiveStartDate")); + Optional.ofNullable(expected.get("breachAmount")) + .ifPresent(amount -> assertAmountEquals("breach.breachAmount", breach.getBreachAmount(), new BigDecimal(amount))); + Optional.ofNullable(expected.get("breachPastDueAmount")).ifPresent( + amount -> assertAmountEquals("breach.breachPastDueAmount", breach.getBreachPastDueAmount(), new BigDecimal(amount))); }); } @@ -1454,8 +1474,13 @@ private static void assertEventDateEqualsApiDate(final String description, final } private static void assertEventDateEquals(final String description, final String eventDate, final String expectedDate) { - Optional.ofNullable(expectedDate).filter(s -> !s.isEmpty()) - .ifPresent(expected -> assertThat(eventDate).as(description).isEqualTo(expected)); + Optional.ofNullable(expectedDate).filter(s -> !s.isEmpty()).ifPresent(expected -> { + if ("null".equals(expected)) { + assertThat(eventDate).as(description).isNull(); + } else { + assertThat(eventDate).as(description).isEqualTo(expected); + } + }); } private void workingCapitalLoanAccountDataV1Check(final Class eventClazz, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index 53f5ce5d0de..3d0ea0c76fd 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -2125,6 +2125,11 @@ public void aWorkingCapitalLoanBreachChangeBusinessEventIsRaised(final String br eventCheckHelper.workingCapitalLoanBreachChangeEventCheck(getCreatedLoanId(), Boolean.valueOf(breachFlag)); } + @Then("a Working Capital Loan Breach Change business event is raised with breach data:") + public void aWorkingCapitalLoanBreachChangeBusinessEventIsRaisedWithBreachData(final DataTable table) { + eventCheckHelper.workingCapitalLoanBreachChangeEventWithBreachDataCheck(getCreatedLoanId(), table.asMaps().get(0)); + } + @Then("a Working Capital Loan Near Breach Change business event is raised with near breach flag {string}") public void aWorkingCapitalLoanNearBreachChangeBusinessEventIsRaised(final String nearBreachFlag) { eventCheckHelper.workingCapitalLoanNearBreachChangeEventCheck(getCreatedLoanId(), Boolean.valueOf(nearBreachFlag)); @@ -3695,6 +3700,8 @@ private List fetchValuesOfWorkingCapitalLoan(final List header, : new Utils.DoubleFormatter(response.getBalance().getTotalDiscountFee().doubleValue()).format()); case "breachStartDate" -> actualValues.add(response.getBreachStartDate() == null ? "null" : response.getBreachStartDate().toString()); + case "breachEffectiveStartDate" -> actualValues + .add(response.getBreachEffectiveStartDate() == null ? "null" : response.getBreachEffectiveStartDate().toString()); case "delinquencyStartDate" -> actualValues.add(response.getDelinquencyStartDate() == null ? "null" : response.getDelinquencyStartDate().toString()); case "delinquencyEffectiveStartDate" -> actualValues.add(response.getDelinquencyEffectiveStartDate() == null ? "null" diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature index ee621d3b3ca..52116945421 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature @@ -197,3 +197,312 @@ Feature: Working Capital Breach Start Date Type And Working capital loan account has the correct data: | breachStartDate | | 2026-01-01 | + + @TestRailId:C110950 + Scenario: Verify breachEffectiveStartDate - UC1: first breach period with grace days + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- First breach period evaluated after the grace window --- + When Admin sets the business date to "21 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | 2026-01-06 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-20 | 20 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-21 | 2026-02-04 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | 2026-01-06 | 500.0 | 500.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "21 January 2026" + + @TestRailId:C110951 + Scenario: Verify breachEffectiveStartDate - UC2: LOAN_CREATION anchor + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | LOAN_CREATION | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 08 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "08 January 2026" + # --- Disbursement happens after the LOAN_CREATION anchor date --- + When Admin sets the business date to "08 January 2026" + And Admin successfully disburse the Working Capital loan on "08 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "21 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan details has the following field values: + | breachStartType.code | LOAN_CREATION | + And Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | 2026-01-06 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-20 | 20 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-21 | 2026-02-04 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | 2026-01-06 | 500.0 | 500.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "21 January 2026" + + @TestRailId:C110952 + Scenario: Verify breachEffectiveStartDate - UC3: no grace days configured + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 0 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- First breach period without grace days --- + When Admin sets the business date to "16 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-15 | 15 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-16 | 2026-01-30 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | null | 500.0 | 500.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "16 January 2026" + + @TestRailId:C110953 + Scenario: Verify breachEffectiveStartDate - UC4: breach period is not the first one + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Repayment covers the first period --- + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 500.0 transaction amount on Working Capital loan + When Admin sets the business date to "21 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | null | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-20 | 20 | 500.00 | 0.00 | null | false | + | 2 | 2026-01-21 | 2026-02-04 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "0" + # --- Second breach period is evaluated later --- + When Admin sets the business date to "05 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-21 | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-20 | 20 | 500.00 | 0.00 | null | false | + | 2 | 2026-01-21 | 2026-02-04 | 15 | 500.00 | 500.00 | null | true | + | 3 | 2026-02-05 | 2026-02-19 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-21 | null | 500.0 | 500.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "05 February 2026" + + @TestRailId:C110954 + Scenario: Verify breachEffectiveStartDate - UC5: reset cuts first period inside the grace window + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Reset cuts the first period inside the grace window --- + When Admin sets the business date to "03 January 2026" + And Admin creates WC breach reset action with restart period from reset date + When Admin sets the business date to "04 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-02 | 2 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-03 | 2026-01-17 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "0" + Then Admin closes the Working Capital loan with a full repayment on "04 January 2026" + + @TestRailId:C110955 + Scenario: Verify breachEffectiveStartDate - UC6: business event payload exposes the effective date + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "21 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Business event payload exposes breachEffectiveStartDate --- + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | 2026-01-06 | 500.0 | 500.0 | true | + And Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | 2026-01-06 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-20 | 20 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-21 | 2026-02-04 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "21 January 2026" + + @TestRailId:C110956 + Scenario: Verify breachEffectiveStartDate - UC7: PERCENTAGE breach amount happy path + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | PERCENTAGE | 2 | 3 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- PERCENTAGE breach amount with grace days --- + When Admin sets the business date to "19 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | 2026-01-04 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-18 | 18 | 180.00 | 180.00 | null | true | + | 2 | 2026-01-19 | 2026-02-02 | 15 | 180.00 | 180.00 | null | null | + And Working Capital loan balance has breach past due amount "180" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | 2026-01-04 | 2.0 | 180.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "180" past due amount + Then Admin closes the Working Capital loan with a full repayment on "19 January 2026" + + @TestRailId:C110957 + Scenario: Verify breachEffectiveStartDate - UC8: undo reset restores the effective start date + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Reset and immediate undo inside the grace window --- + When Admin sets the business date to "03 January 2026" + And Admin creates WC breach reset action with restart period from reset date + And Admin creates WC breach undo reset action + # --- Original first period breaches again after undo --- + When Admin sets the business date to "21 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | 2026-01-06 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-20 | 20 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-21 | 2026-02-04 | 15 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | 2026-01-06 | 500.0 | 500.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "21 January 2026" + + @TestRailId:CTBD + Scenario: Verify breachEffectiveStartDate - UC9: reschedule shortens the first period but keeps the grace window + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | breachStartType | + | 15 | DAYS | FLAT | 500 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Reschedule to a frequency shorter than the grace window, dated inside the first period --- + When Admin sets the business date to "04 January 2026" + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 2 | DAYS | + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | null | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-07 | 7 | 500.00 | 500.00 | null | null | + # --- The shortened first period breaches and still reports the grace window --- + When Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working capital loan account has the correct data: + | breachStartDate | breachEffectiveStartDate | + | 2026-01-01 | 2026-01-06 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-07 | 7 | 500.00 | 500.00 | null | true | + | 2 | 2026-01-08 | 2026-01-09 | 2 | 500.00 | 500.00 | null | null | + And Working Capital loan balance has breach past due amount "500" + And a Working Capital Loan Breach Schedule Changed business event is raised + Then a Working Capital Loan Breach Change business event is raised with breach data: + | breachStartDate | breachEffectiveStartDate | breachAmount | breachPastDueAmount | breachFlag | + | 2026-01-01 | 2026-01-06 | 500.0 | 500.0 | true | + And a Working Capital Loan Breach Past Due Change business event is raised with "500" past due amount + Then Admin closes the Working Capital loan with a full repayment on "08 January 2026" 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 f1426bc8ee6..f051503f2fc 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 @@ -286,8 +286,13 @@ private GetWorkingCapitalLoansLoanIdResponse() {} @Schema(description = "Breach start type: LOAN_CREATION or DISBURSEMENT") public StringEnumOptionData breachStartType; @Schema(example = "[2024, 1, 14]", description = "Start date of the loan's breach, i.e. the fromDate of the earliest breached " - + "breach schedule period (the breach grace days are already reflected in this date). Null when the loan is not in breach") + + "breach schedule period. Null when the loan is not in breach") public LocalDate breachStartDate; + @Schema(example = "[2024, 1, 19]", description = "Effective start date of the loan's breach, i.e. breachStartDate shifted " + + "forward by breachGraceDays (the cool off period). Only the first breach period carries the grace days, so this is " + + "null when the earliest breached period is not the first one, when no breach grace days are configured, and when the " + + "loan is not in breach") + public LocalDate breachEffectiveStartDate; @Schema(example = "[2024, 1, 14]", description = "Start date of the loan's delinquency, i.e. the fromDate of the earliest " + "delinquent range schedule period. Null when the loan is not delinquent") public LocalDate delinquencyStartDate; 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 5a806511f5e..6a9ae4ffd46 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 @@ -107,6 +107,8 @@ public class WorkingCapitalLoanData implements Serializable { // Only set for the first delinquency period, which is the only one the delinquency grace days shift. private LocalDate delinquencyEffectiveStartDate; private LocalDate breachStartDate; + // Only set for the first breach period, which is the only one the breach grace days shift. + private LocalDate breachEffectiveStartDate; private WorkingCapitalLoanCollectionData delinquent; private Boolean enableInstallmentLevelDelinquency; private WorkingCapitalLoanSummaryData summary; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java index d4150042770..790bc8915bf 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java @@ -67,14 +67,28 @@ public static LocalDate calculateToDate(final LocalDate fromDate, final Integer } /** - * End date a period gets when a reschedule re-dates it: the new frequency applied from the period start, extended - * by the recorded pauses that overlap it. Shared by the reschedule validator and the schedule service so the check + * End date a period gets from its own bounds: the frequency applied from the period start, extended by the breach + * grace days when the period is the first one. + * + * Only the first period carries the breach grace days, and every subsequent period is chained from its end date, so + * every path that dates or re-dates a period must agree on this or the whole schedule drifts by the grace days. + */ + public static LocalDate calculateNaturalToDate(final LocalDate fromDate, final Integer periodNumber, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType, final Integer breachGraceDays) { + final int graceDays = Integer.valueOf(1).equals(periodNumber) && breachGraceDays != null ? breachGraceDays : 0; + return calculateToDate(fromDate, frequency, frequencyType).plusDays(graceDays); + } + + /** + * End date a period gets when a reschedule re-dates it: its natural end date under the new frequency, extended by + * the recorded pauses that overlap it. Shared by the reschedule validator and the schedule service so the check * cannot drift from the mutation it guards. */ - public static LocalDate calculateRescheduledToDate(final LocalDate fromDate, final Integer frequency, - final WorkingCapitalLoanPeriodFrequencyType frequencyType, final List actions) { + public static LocalDate calculateRescheduledToDate(final LocalDate fromDate, final Integer periodNumber, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType, final Integer breachGraceDays, + final List actions) { return WorkingCapitalLoanBreachPauseUtils.extendToDateByRecordedPauses(fromDate, - calculateToDate(fromDate, frequency, frequencyType), actions); + calculateNaturalToDate(fromDate, periodNumber, frequency, frequencyType, breachGraceDays), actions); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java index 472af550ae7..e5aae577d57 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java @@ -80,6 +80,7 @@ public interface WorkingCapitalLoanMapper { @Mapping(target = "breachGraceDays", source = "loanProductRelatedDetails.breachGraceDays") @Mapping(target = "breachStartType", source = "loanProductRelatedDetails", qualifiedByName = "breachStartTypeData") @Mapping(target = "breachStartDate", ignore = true) + @Mapping(target = "breachEffectiveStartDate", ignore = true) @Mapping(target = "delinquencyStartDate", ignore = true) @Mapping(target = "delinquencyEffectiveStartDate", ignore = true) @Mapping(target = "delinquent", ignore = true) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java index 66f42325f08..3fe16adf313 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java @@ -66,6 +66,7 @@ public interface WorkingCapitalLoanAccountDataMapper { @Mapping(source = "breach.breachAmount", target = "breach.breachAmount") @Mapping(source = "breachGraceDays", target = "breach.breachGraceDays") @Mapping(source = "breachStartDate", target = "breach.breachStartDate") + @Mapping(source = "breachEffectiveStartDate", target = "breach.breachEffectiveStartDate") @Mapping(source = "nearBreach", target = "breach.nearBreach") @Mapping(target = "breach.breachSchedule", ignore = true) @Mapping(target = "breach.breachPastDueAmount", ignore = true) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java index e431a155123..bd9028d3557 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java @@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.api.ApiFacingEnum; import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.ApplicationCurrencyRepositoryWrapper; @@ -46,6 +47,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; @@ -232,11 +234,12 @@ private void enrichWithRateAndTerm(final WorkingCapitalLoan loan, final WorkingC } private void enrichWithStartDates(final WorkingCapitalLoan loan, final WorkingCapitalLoanData data) { - // breachStartDate: fromDate of the earliest breached period. The breach schedule already offsets its first - // period - // by breachGraceDays, so the grace period is implicitly reflected in the fromDate. - breachScheduleRepository.findTopByLoanIdAndBreachTrueOrderByFromDateAsc(loan.getId()) - .ifPresent(period -> data.setBreachStartDate(period.getFromDate())); + // breachStartDate: fromDate of the earliest breached period. The breach schedule bakes the breach grace days + // into the toDate of its first period, so the fromDate is the raw anchor date. + breachScheduleRepository.findTopByLoanIdAndBreachTrueOrderByFromDateAsc(loan.getId()).ifPresent(period -> { + data.setBreachStartDate(period.getFromDate()); + data.setBreachEffectiveStartDate(resolveBreachEffectiveStartDate(period, data.getBreachGraceDays())); + }); // delinquencyStartDate: fromDate of the earliest delinquent period. The delinquency range schedule bakes the // delinquency grace days into the toDate of its first period, so the fromDate is the raw anchor date. @@ -263,10 +266,38 @@ private void enrichWithStartDates(final WorkingCapitalLoan loan, final WorkingCa */ private LocalDate resolveDelinquencyEffectiveStartDate(final WorkingCapitalLoanDelinquencyRangeSchedule period, final Integer delinquencyGraceDays) { - if (!Integer.valueOf(1).equals(period.getPeriodNumber()) || delinquencyGraceDays == null || delinquencyGraceDays <= 0) { + return resolveEffectiveStartDate(period.getPeriodNumber(), period.getFromDate(), period.getToDate(), delinquencyGraceDays); + } + + /** + * Resolves the "effective" start of the breach, i.e. the date the breach clock starts ticking once the configured + * cool off period is taken into account. + * + * Only the first breach schedule period carries the breach grace days: the schedule generator extends that period's + * toDate by the grace days and every subsequent period is chained from it, so the grace is equivalent to shifting + * the first period forward. The effective start date makes that shift explicit for API consumers. + */ + private LocalDate resolveBreachEffectiveStartDate(final WorkingCapitalLoanBreachSchedule period, final Integer breachGraceDays) { + return resolveEffectiveStartDate(period.getPeriodNumber(), period.getFromDate(), period.getToDate(), breachGraceDays); + } + + /** + * The date the cool off period configured as grace days ends inside a schedule period, or {@code null} when the + * period has no cool off period to report. + * + * There is none for any period other than the first one, which is the only one the grace days extend, and none when + * no grace days are configured. There is none either when the cool off period would end after the period does: an + * operation that cuts the first period short, such as a reset that restarts the schedule from its own date, leaves + * a period the grace days never finished running through, so reporting a date past its end would describe a cool + * off period that never took place. + */ + private LocalDate resolveEffectiveStartDate(final Integer periodNumber, final LocalDate fromDate, final LocalDate toDate, + final Integer graceDays) { + if (!Integer.valueOf(1).equals(periodNumber) || graceDays == null || graceDays <= 0) { return null; } - return period.getFromDate().plusDays(delinquencyGraceDays); + final LocalDate effectiveStartDate = fromDate.plusDays(graceDays); + return DateUtils.isAfter(effectiveStartDate, toDate) ? null : effectiveStartDate; } private void enrichWithOriginators(final Long loanId, final WorkingCapitalLoanData data) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java index 5159efa5263..ae43639c25e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java @@ -44,12 +44,6 @@ public interface WorkingCapitalLoanBreachScheduleService { boolean evaluateBreach(WorkingCapitalLoan loan, LocalDate businessDate); - /** - * Recalculates the schedule from the effective reschedule parameters resolved from the persisted RESCHEDULE - * actions; a newly created reschedule action must therefore be saved before this is called. When {@code action} - * carries a frequency group, the current open period is also re-dated: its toDate is recalculated from its fromDate - * and the new frequency, extended by the recorded pauses that overlap the period. - */ /** * Replays the recorded breach actions over the schedule after {@code action} was recorded, re-dating the periods it * reaches and rewriting their demand. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java index 5be2dcb92be..83d3ef409ce 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java @@ -504,13 +504,14 @@ private Optional getBreachConfig(final WorkingCapitalLoan /** * Only the first period carries the breach grace days; every path that re-dates a period must agree on this or the - * undo restore drifts by the grace days. + * undo restore drifts by the grace days, which is why the rule itself lives in + * {@link WorkingCapitalLoanBreachScheduleEvaluationUtils#calculateNaturalToDate} and is shared with the reschedule + * validator. */ private LocalDate naturalToDate(final WorkingCapitalLoan loan, final int periodNumber, final LocalDate fromDate, final EffectiveBreachRescheduleParams params) { - final int graceDays = periodNumber == 1 ? getBreachGraceDays(loan) : 0; - return WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, params.frequency(), params.frequencyType()) - .plusDays(graceDays); + return WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateNaturalToDate(fromDate, periodNumber, params.frequency(), + params.frequencyType(), getBreachGraceDays(loan)); } /** diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java index 0bc282cc902..9adfa09e432 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java @@ -463,15 +463,19 @@ private void validateReschedule(final WorkingCapitalLoanBreachAction action, fin /** * Rejects a frequency change whose resulting period end date falls before the business date. The candidate end date - * is derived exactly as the re-date derives it: from the current open period fromDate, extended by the pauses. + * is derived exactly as the re-date derives it: from the current open period fromDate, carrying the breach grace + * days when that period is the first one, and extended by the pauses. */ private void validateFrequencyDoesNotEndBeforeBusinessDate(final WorkingCapitalLoanBreachAction action, final WorkingCapitalLoan workingCapitalLoan, final List existing, final DataValidatorBuilder dataValidator) { final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final WorkingCapitalLoanProductRelatedDetails details = workingCapitalLoan.getLoanProductRelatedDetails(); + final Integer breachGraceDays = details == null ? null : details.getBreachGraceDays(); final Optional candidateToDate = breachScheduleRepository.findCurrentOpenPeriod(workingCapitalLoan.getId(), businessDate) .map(currentPeriod -> WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateRescheduledToDate( - currentPeriod.getFromDate(), action.getFrequency(), action.getFrequencyType(), existing)); + currentPeriod.getFromDate(), currentPeriod.getPeriodNumber(), action.getFrequency(), action.getFrequencyType(), + breachGraceDays, existing)); if (candidateToDate.filter(toDate -> toDate.isBefore(businessDate)).isPresent()) { failGeneralValidation(dataValidator, "reschedule.frequency.results.endDate.before.businessDate", "Frequency change results a breach period endDate before current businessDate is not allowed"); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtilsGraceDaysTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtilsGraceDaysTest.java new file mode 100644 index 00000000000..2e8a72c1b56 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtilsGraceDaysTest.java @@ -0,0 +1,78 @@ +/** + * 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.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Covers the rule that only the first breach schedule period carries the breach grace days, on every path that dates or + * re-dates a period. + */ +class WorkingCapitalLoanBreachScheduleEvaluationUtilsGraceDaysTest { + + private static final LocalDate FROM_DATE = LocalDate.of(2026, 1, 1); + private static final int FREQUENCY_DAYS = 15; + private static final int GRACE_DAYS = 5; + + @Test + void naturalToDate_firstPeriod_carriesTheGraceDays() { + final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateNaturalToDate(FROM_DATE, 1, FREQUENCY_DAYS, + WorkingCapitalLoanPeriodFrequencyType.DAYS, GRACE_DAYS); + + // 15-day frequency -> [Jan 1 .. Jan 15], extended by the 5 grace days. + assertThat(toDate).isEqualTo(LocalDate.of(2026, 1, 20)); + } + + @Test + void naturalToDate_laterPeriod_ignoresTheGraceDays() { + final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateNaturalToDate(FROM_DATE, 2, FREQUENCY_DAYS, + WorkingCapitalLoanPeriodFrequencyType.DAYS, GRACE_DAYS); + + assertThat(toDate).isEqualTo(LocalDate.of(2026, 1, 15)); + } + + @Test + void naturalToDate_withoutGraceDaysConfigured_isTheFrequencyAlone() { + assertThat(WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateNaturalToDate(FROM_DATE, 1, FREQUENCY_DAYS, + WorkingCapitalLoanPeriodFrequencyType.DAYS, null)).isEqualTo(LocalDate.of(2026, 1, 15)); + assertThat(WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateNaturalToDate(FROM_DATE, 1, FREQUENCY_DAYS, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 0)).isEqualTo(LocalDate.of(2026, 1, 15)); + } + + @Test + void rescheduledToDate_firstPeriod_keepsTheGraceDaysUnderTheNewFrequency() { + final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateRescheduledToDate(FROM_DATE, 1, 2, + WorkingCapitalLoanPeriodFrequencyType.DAYS, GRACE_DAYS, List.of()); + + // A frequency shorter than the grace days would otherwise end the period before the grace is over. + assertThat(toDate).isEqualTo(LocalDate.of(2026, 1, 7)); + } + + @Test + void rescheduledToDate_laterPeriod_ignoresTheGraceDays() { + final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateRescheduledToDate(FROM_DATE, 3, 2, + WorkingCapitalLoanPeriodFrequencyType.DAYS, GRACE_DAYS, List.of()); + + assertThat(toDate).isEqualTo(LocalDate.of(2026, 1, 2)); + } +} diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java index 89d0609e0f5..996c4a63df4 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java @@ -255,6 +255,7 @@ void map_loanData_mapsBreachWithNearBreachFully() { // loan-level fields lifted into the breach record assertEquals(3, breach.getBreachGraceDays()); assertEquals("2024-01-06", breach.getBreachStartDate()); + assertEquals("2024-01-09", breach.getBreachEffectiveStartDate()); final WorkingCapitalNearBreachDataV1 nearBreach = breach.getNearBreach(); assertNotNull(nearBreach); @@ -283,6 +284,7 @@ void map_loanData_withoutBreachConfig_stillCreatesBreachRecordFromLoanFields() { assertNull(breach.getNearBreach()); assertEquals(3, breach.getBreachGraceDays()); assertEquals("2024-01-06", breach.getBreachStartDate()); + assertEquals("2024-01-09", breach.getBreachEffectiveStartDate()); } @Test @@ -579,11 +581,12 @@ private static WorkingCapitalLoanData fullLoanData() { .breachGraceDays(3).delinquencyGraceDays(7) .delinquencyStartType(stringEnum("1", "delinquencyStart.disbursement", "Disbursement")) .delinquencyStartDate(LocalDate.of(2024, 1, 5)).delinquencyEffectiveStartDate(LocalDate.of(2024, 1, 12)) - .breachStartDate(LocalDate.of(2024, 1, 6)).lastClosedBusinessDate(LocalDate.of(2024, 2, 1)) - .overpaidOnDate(LocalDate.of(2024, 2, 14)).chargedOff(Boolean.TRUE).enableInstallmentLevelDelinquency(Boolean.TRUE) - .currency(currency()).timeline(fullTimeline()).summary(fullSummary()).delinquent(fullCollection()).breach(fullBreach()) - .nearBreach(fullNearBreach()).charges(List.of(fullCharge())).disbursementDetails(List.of(fullDisbursement())) - .originators(List.of(fullOriginator())).periodPaymentRateHistory(List.of(fullRateChange())).build(); + .breachStartDate(LocalDate.of(2024, 1, 6)).breachEffectiveStartDate(LocalDate.of(2024, 1, 9)) + .lastClosedBusinessDate(LocalDate.of(2024, 2, 1)).overpaidOnDate(LocalDate.of(2024, 2, 14)).chargedOff(Boolean.TRUE) + .enableInstallmentLevelDelinquency(Boolean.TRUE).currency(currency()).timeline(fullTimeline()).summary(fullSummary()) + .delinquent(fullCollection()).breach(fullBreach()).nearBreach(fullNearBreach()).charges(List.of(fullCharge())) + .disbursementDetails(List.of(fullDisbursement())).originators(List.of(fullOriginator())) + .periodPaymentRateHistory(List.of(fullRateChange())).build(); } private static LoanStatusEnumData fullStatus() { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesTest.java index 72681805f75..ff0160039e7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesTest.java @@ -48,6 +48,7 @@ import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanbreach.WorkingCapitalBreachHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanbreach.WorkingCapitalLoanBreachActionHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; import org.junit.jupiter.api.Test; @@ -59,8 +60,11 @@ * /workingcapitalloans/{loanId}} response. * *
    - *
  • {@code breachStartDate} = fromDate of the earliest breached breach-schedule period. The breach schedule already - * offsets its first period by {@code breachGraceDays}, so the grace is reflected in the fromDate.
  • + *
  • {@code breachStartDate} = fromDate of the earliest breached breach-schedule period. The breach schedule bakes + * {@code breachGraceDays} into the toDate of its first period, so the fromDate is the raw anchor date.
  • + *
  • {@code breachEffectiveStartDate} = {@code breachStartDate} shifted by {@code breachGraceDays}, set only when the + * earliest breached period is the first one, grace days are configured, and the resulting date still falls inside that + * period (an operation that cuts the period short, such as a reset, leaves no cool off period to report).
  • *
  • {@code delinquencyStartDate} = fromDate of the earliest delinquent range-schedule period (minPaymentCriteriaMet = * false). The range schedule bakes {@code delinquencyGraceDays} into the toDate of its first period, so the fromDate is * the raw anchor date.
  • @@ -77,7 +81,7 @@ public class WorkingCapitalLoanStartDatesTest { private static final BigDecimal BREACH_AMOUNT = new BigDecimal("500"); private static final BigDecimal DELINQUENCY_MIN_PAYMENT_PERCENT = new BigDecimal("3"); - // Breach: 15-day frequency with a 5-day grace -> first period [D+5 .. D+19]. + // Breach: 15-day frequency with a 5-day grace baked into the end of the first period -> [D .. D+14+grace]. private static final int BREACH_FREQUENCY_DAYS = 15; private static final int BREACH_GRACE_DAYS = 5; // Delinquency: 20-day frequency, with the grace days baked into the first period -> first period [D .. D+19+grace]. @@ -95,10 +99,24 @@ public class WorkingCapitalLoanStartDatesTest { private static final int RESCHEDULED_FREQUENCY_DAYS = 2; // First period re-dated by that reschedule: [D .. D+1+grace] = [2026-01-01 .. 2026-01-05]. private static final LocalDate RESCHEDULED_FIRST_PERIOD_TO_DATE = LocalDate.of(2026, 1, 5); + // First breach period re-dated by that reschedule: [D .. D+1+breach grace] = [2026-01-01 .. 2026-01-07]. + private static final LocalDate RESCHEDULED_FIRST_BREACH_PERIOD_TO_DATE = LocalDate.of(2026, 1, 7); + // First breach period is [D .. D+14+grace] = [2026-01-01 .. 2026-01-20]; the effective start is D + grace. + private static final LocalDate BREACH_EFFECTIVE_START_DATE = LocalDate.of(2026, 1, 6); + // Second breach period starts the day after the first one ends and carries no grace days. + private static final LocalDate SECOND_BREACH_PERIOD_FROM_DATE = LocalDate.of(2026, 1, 21); // Submitted-on date intentionally earlier than the disbursement date so the two anchors can be told apart. private static final LocalDate SUBMITTED_ON_DATE = LocalDate.of(2025, 12, 20); // Effective start under the LOAN_CREATION anchor: submittedOnDate + delinquencyGraceDays. private static final LocalDate SUBMITTED_ON_EFFECTIVE_START_DATE = LocalDate.of(2025, 12, 23); + // Same shift applied to the submitted-on anchor: 2025-12-20 + 5 breach grace days. + private static final LocalDate BREACH_EFFECTIVE_START_DATE_FROM_CREATION = LocalDate.of(2025, 12, 25); + // Reset date deliberately inside the grace window [2026-01-01 .. 2026-01-06). + private static final String RESET_DATE = "2026-01-03"; + // The reset cuts the first period the day before it: [2026-01-01 .. 2026-01-02]. + private static final LocalDate RESET_FIRST_PERIOD_TO_DATE = LocalDate.of(2026, 1, 2); + // Natural end of the first period, restored when the reset is undone. + private static final LocalDate NATURAL_FIRST_PERIOD_TO_DATE = LocalDate.of(2026, 1, 20); @Test public void testStartDatesArePopulatedWhenLoanBreachesAndBecomesDelinquent() { @@ -121,6 +139,10 @@ public void testStartDatesArePopulatedWhenLoanBreachesAndBecomesDelinquent() { assertEquals(DISBURSEMENT_DATE, response.getBreachStartDate(), "breachStartDate should be the fromDate of the first breached period (disbursement)"); + // breachEffectiveStartDate = breachStartDate + breachGraceDays, since the breached period is the first one. + assertEquals(BREACH_EFFECTIVE_START_DATE, response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate should be the fromDate of the first breached period plus breachGraceDays"); + // delinquencyStartDate = fromDate of the first delinquent period (= disbursement) assertEquals(DISBURSEMENT_DATE, response.getDelinquencyStartDate(), "delinquencyStartDate should be the fromDate of the first delinquent period"); @@ -182,6 +204,8 @@ public void testBreachStartDateUsesLoanCreationDateWhenConfigured() { // breachStartDate must anchor on the loan submitted-on (creation) date, not the disbursement date. assertEquals(SUBMITTED_ON_DATE, response.getBreachStartDate(), "breachStartDate should anchor on submittedOnDate when breachStartType = LOAN_CREATION"); + assertEquals(BREACH_EFFECTIVE_START_DATE_FROM_CREATION, response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate should shift the submitted-on anchor by breachGraceDays"); }); } @@ -206,6 +230,8 @@ public void testBreachStartDateUsesDisbursementDateWhenConfigured() { assertEquals(DISBURSEMENT_DATE, response.getBreachStartDate(), "breachStartDate should anchor on disbursementDate when breachStartType = DISBURSEMENT"); + assertEquals(BREACH_EFFECTIVE_START_DATE, response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate should shift the disbursement anchor by breachGraceDays"); }); } @@ -224,6 +250,7 @@ public void testStartDatesAreNullForHealthyLoan() { final GetWorkingCapitalLoansLoanIdResponse response = loanHelper.retrieveLoan(loanId); assertNull(response.getBreachStartDate(), "breachStartDate must be null when the loan is not in breach"); + assertNull(response.getBreachEffectiveStartDate(), "breachEffectiveStartDate must be null when the loan is not in breach"); assertNull(response.getDelinquencyStartDate(), "delinquencyStartDate must be null when the loan is not delinquent"); assertNull(response.getDelinquencyEffectiveStartDate(), "delinquencyEffectiveStartDate must be null when the loan is not delinquent"); @@ -337,6 +364,168 @@ public void testFirstPeriodKeepsItsGraceDaysAfterAFrequencyReschedule() { }); } + @Test + public void testBreachEffectiveStartDateIsNullWhenNoGraceDaysConfigured() { + AtomicLong loanIdRef = new AtomicLong(); + + // given - the same setup but with breachGraceDays = 0, so there is no cool off period to expose + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoanWithBreachStartType(null, "DISBURSEMENT", 0)); + }); + + BusinessDateHelper.runAt("21 January 2026", () -> { + final Long loanId = loanIdRef.get(); + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + final GetWorkingCapitalLoansLoanIdResponse response = loanHelper.retrieveLoan(loanId); + + assertEquals(DISBURSEMENT_DATE, response.getBreachStartDate(), + "breachStartDate should be the fromDate of the first breached period"); + assertNull(response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate must be null when no breach grace days are configured"); + }); + } + + @Test + public void testBreachEffectiveStartDateIsNullWhenTheBreachedPeriodIsNotTheFirstOne() { + AtomicLong loanIdRef = new AtomicLong(); + + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoan()); + }); + + // Cover the minimum payment of the first breach period [2026-01-01 .. 2026-01-20] so it never breaches. + BusinessDateHelper.runAt("05 January 2026", () -> { + final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + loanHelper.makeRepaymentByLoanId(loanIdRef.get(), WorkingCapitalLoanDisbursementTestBuilder + .buildRepaymentRequest(LocalDate.of(2026, 1, 5), BREACH_AMOUNT, null, "repayment", 1, null)); + }); + + // The second period [2026-01-21 .. 2026-02-04] goes unpaid and is the earliest breached one. + BusinessDateHelper.runAt("05 February 2026", () -> { + final Long loanId = loanIdRef.get(); + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + final GetWorkingCapitalLoansLoanIdResponse response = loanHelper.retrieveLoan(loanId); + + assertEquals(SECOND_BREACH_PERIOD_FROM_DATE, response.getBreachStartDate(), + "breachStartDate should be the fromDate of the second period once the first one is covered"); + assertNull(response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate must be null when the breached period is not the first one, " + + "which is the only one the grace days shift"); + }); + } + + /** + * A reset that restarts the schedule cuts the first period short at the reset date, and the cut can land before the + * grace days are over. The effective start date then describes a cool off period that never took place, so there is + * none to report. + */ + @Test + public void testBreachEffectiveStartDateIsNullWhenAResetCutsTheFirstPeriodInsideTheGraceWindow() { + AtomicLong loanIdRef = new AtomicLong(); + + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoan()); + }); + + // The reset lands inside the grace window, so the first period ends before the cool off period would. + BusinessDateHelper.runAt("03 January 2026", () -> { + new WorkingCapitalLoanBreachActionHelper().resetRestartingFromResetDate(loanIdRef.get(), RESET_DATE); + }); + + BusinessDateHelper.runAt("04 January 2026", () -> { + final Long loanId = loanIdRef.get(); + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final WorkingCapitalLoanBreachActionHelper actionHelper = new WorkingCapitalLoanBreachActionHelper(); + assertEquals(RESET_FIRST_PERIOD_TO_DATE, firstBreachPeriodToDate(actionHelper, loanId), + "the reset should have cut the first period the day before the reset date"); + + final GetWorkingCapitalLoansLoanIdResponse response = new WorkingCapitalLoanHelper().retrieveLoan(loanId); + assertEquals(DISBURSEMENT_DATE, response.getBreachStartDate(), + "breachStartDate should still be the fromDate of the cut first period"); + assertNull(response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate must be null when the grace days outlast the period they belong to"); + }); + } + + @Test + public void testBreachEffectiveStartDateComesBackWhenTheResetIsUndone() { + AtomicLong loanIdRef = new AtomicLong(); + + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoan()); + }); + + BusinessDateHelper.runAt("03 January 2026", () -> { + final WorkingCapitalLoanBreachActionHelper actionHelper = new WorkingCapitalLoanBreachActionHelper(); + actionHelper.resetRestartingFromResetDate(loanIdRef.get(), RESET_DATE); + // The undo rebuilds the first period from its natural length, which carries the grace days again. + actionHelper.undoReset(loanIdRef.get(), RESET_DATE); + }); + + // The day after the restored period ends, it is breached again and the cool off period is reportable. + BusinessDateHelper.runAt("21 January 2026", () -> { + final Long loanId = loanIdRef.get(); + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final WorkingCapitalLoanBreachActionHelper actionHelper = new WorkingCapitalLoanBreachActionHelper(); + assertEquals(NATURAL_FIRST_PERIOD_TO_DATE, firstBreachPeriodToDate(actionHelper, loanId), + "the undo should have restored the natural end date of the first period"); + + final GetWorkingCapitalLoansLoanIdResponse response = new WorkingCapitalLoanHelper().retrieveLoan(loanId); + assertEquals(DISBURSEMENT_DATE, response.getBreachStartDate(), "breachStartDate should be the fromDate of the first period"); + assertEquals(BREACH_EFFECTIVE_START_DATE, response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate should be reportable again once the period carries its grace days"); + }); + } + + /** + * A reschedule re-dates the first period from its own fromDate, and the grace days belong to that period rather + * than to the frequency it was created with, so they survive the new frequency. + */ + @Test + public void testFirstBreachPeriodKeepsItsGraceDaysAfterAFrequencyReschedule() { + AtomicLong loanIdRef = new AtomicLong(); + + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoan()); + }); + + BusinessDateHelper.runAt("02 January 2026", () -> { + final Long loanId = loanIdRef.get(); + final WorkingCapitalLoanBreachActionHelper actionHelper = new WorkingCapitalLoanBreachActionHelper(); + actionHelper.rescheduleFrequency(loanId, "2026-01-02", RESCHEDULED_FREQUENCY_DAYS, "DAYS"); + + assertEquals(RESCHEDULED_FIRST_BREACH_PERIOD_TO_DATE, firstBreachPeriodToDate(actionHelper, loanId), + "the rescheduled first period must still carry the breach grace days"); + }); + + BusinessDateHelper.runAt("08 January 2026", () -> { + final Long loanId = loanIdRef.get(); + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final GetWorkingCapitalLoansLoanIdResponse response = new WorkingCapitalLoanHelper().retrieveLoan(loanId); + assertEquals(DISBURSEMENT_DATE, response.getBreachStartDate(), + "breachStartDate should still be the fromDate of the rescheduled first period"); + assertEquals(BREACH_EFFECTIVE_START_DATE, response.getBreachEffectiveStartDate(), + "breachEffectiveStartDate should be unchanged by the reschedule and fall inside the period"); + }); + } + + private LocalDate firstBreachPeriodToDate(final WorkingCapitalLoanBreachActionHelper actionHelper, final Long loanId) { + return actionHelper.retrieveBreachSchedule(loanId).stream().filter(period -> Integer.valueOf(1).equals(period.getPeriodNumber())) + .findFirst().orElseThrow().getToDate(); + } + private Long createDisbursedLoan() { // Default: submitted-on date left unset (defaults to the disbursement date) and no explicit // delinquencyStartType. @@ -400,6 +589,11 @@ private Long createDisbursedLoan(final LocalDate submittedOnDate, final String d * start-date-type anchor. */ private Long createDisbursedLoanWithBreachStartType(final LocalDate submittedOnDate, final String breachStartType) { + return createDisbursedLoanWithBreachStartType(submittedOnDate, breachStartType, BREACH_GRACE_DAYS); + } + + private Long createDisbursedLoanWithBreachStartType(final LocalDate submittedOnDate, final String breachStartType, + final int breachGraceDays) { final List rangeIds = createDelinquencyRanges(); final PostDelinquencyBucketResponse bucketResponse = WorkingCapitalLoanDelinquencyRangeScheduleHelper .createWorkingCapitalLoanDelinquencyBucket(rangeIds, DELINQUENCY_FREQUENCY_DAYS, 0, DELINQUENCY_MIN_PAYMENT_PERCENT, 1); @@ -419,7 +613,7 @@ private Long createDisbursedLoanWithBreachStartType(final LocalDate submittedOnD .withDelinquencyBucketId(bucketResponse.getResourceId()) // .withDelinquencyGraceDays(DELINQUENCY_GRACE_DAYS) // .withBreachId(breachId) // - .withBreachGraceDays(BREACH_GRACE_DAYS) // + .withBreachGraceDays(breachGraceDays) // .withBreachStartType(breachStartType) // .build()).getResourceId(); assertNotNull(productId); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanbreach/WorkingCapitalLoanBreachActionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanbreach/WorkingCapitalLoanBreachActionHelper.java index 47063dff073..fd8c90459da 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanbreach/WorkingCapitalLoanBreachActionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanbreach/WorkingCapitalLoanBreachActionHelper.java @@ -24,6 +24,7 @@ import org.apache.fineract.client.feign.util.FeignCalls; import org.apache.fineract.client.models.PostWorkingCapitalLoansBreachActionRequest; import org.apache.fineract.client.models.WorkingCapitalLoanBreachActionData; +import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; public class WorkingCapitalLoanBreachActionHelper { @@ -48,6 +49,38 @@ public List retrieveBreachActions(final Long return FeignCalls.ok(() -> api().retrieveBreachActions(loanId)); } + /** Resets the schedule, restarting the period geometry from the reset date. */ + public void resetRestartingFromResetDate(final Long loanId, final String startDate) { + final PostWorkingCapitalLoansBreachActionRequest request = baseRequest("reset", startDate); + request.setRestartPeriodFromResetDate(Boolean.TRUE); + FeignCalls.ok(() -> api().createBreachAction(loanId, request)); + } + + public void undoReset(final Long loanId, final String startDate) { + FeignCalls.ok(() -> api().createBreachAction(loanId, baseRequest("undo_reset", startDate))); + } + + public void rescheduleFrequency(final Long loanId, final String startDate, final int frequency, final String frequencyType) { + final PostWorkingCapitalLoansBreachActionRequest request = baseRequest("reschedule", startDate); + request.setFrequency(frequency); + request.setFrequencyType(frequencyType); + FeignCalls.ok(() -> api().createBreachAction(loanId, request)); + } + + public List retrieveBreachSchedule(final Long loanId) { + return FeignCalls.ok( + () -> FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanBreachSchedule().retrieveBreachSchedule(loanId)); + } + + private PostWorkingCapitalLoansBreachActionRequest baseRequest(final String action, final String startDate) { + final PostWorkingCapitalLoansBreachActionRequest request = new PostWorkingCapitalLoansBreachActionRequest(); + request.setAction(action); + request.setStartDate(startDate); + request.setDateFormat("yyyy-MM-dd"); + request.setLocale("en"); + return request; + } + private PostWorkingCapitalLoansBreachActionRequest pauseRequest(final String startDate, final String endDate) { final PostWorkingCapitalLoansBreachActionRequest request = new PostWorkingCapitalLoansBreachActionRequest(); request.setAction("pause");