From edf1733bafc248ef8a9a2fe369b38c62023028a4 Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Wed, 17 Jun 2026 10:03:54 -0500 Subject: [PATCH] FINERACT-2455: Working Capital loan details with start dates --- .../src/docs/en/chapters/features/index.adoc | 1 + .../working-capital-loan-start-dates.adoc | 184 +++++++++++++++ .../WorkingCapitalLoanApiResourceSwagger.java | 6 + .../data/WorkingCapitalLoanData.java | 2 + .../mapper/WorkingCapitalLoanMapper.java | 2 + ...ngCapitalLoanBreachScheduleRepository.java | 2 + ...oanDelinquencyRangeScheduleRepository.java | 2 + ...oanApplicationReadPlatformServiceImpl.java | 22 ++ ...anDelinquencyRangeScheduleServiceImpl.java | 29 ++- .../WorkingCapitalLoanStartDatesTest.java | 219 ++++++++++++++++++ 10 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 fineract-doc/src/docs/en/chapters/features/working-capital-loan-start-dates.adoc create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesTest.java diff --git a/fineract-doc/src/docs/en/chapters/features/index.adoc b/fineract-doc/src/docs/en/chapters/features/index.adoc index 57927ce2224..486f07be475 100644 --- a/fineract-doc/src/docs/en/chapters/features/index.adoc +++ b/fineract-doc/src/docs/en/chapters/features/index.adoc @@ -34,3 +34,4 @@ include::working-capital-breach-management.adoc[leveloffset=+1] include::working-capital-breach-grace-days.adoc[leveloffset=+1] include::working-capital-cash-accounting.adoc[leveloffset=+1] include::working-capital-eir-calculation-accounting.adoc[leveloffset=+1] +include::working-capital-loan-start-dates.adoc[leveloffset=+1] 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 new file mode 100644 index 00000000000..757ed734e1e --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-loan-start-dates.adoc @@ -0,0 +1,184 @@ += Working Capital Loan Start Dates (Breach and Delinquency) + +== Overview + +The Working Capital (WC) Loan retrieve response exposes two read-only derived fields, `breachStartDate` and `delinquencyStartDate`, that tell a consumer *when* a loan first entered breach and when it first became delinquent. Both 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` and `delinquencyStartDate`. + +=== 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 effective start date directly. + +=== Scope + +The scope of this document includes: + +* New read-only fields `breachStartDate` and `delinquencyStartDate` on the WC Loan retrieve response. +* Derivation logic in `WorkingCapitalLoanApplicationReadPlatformServiceImpl.enrichWithStartDates`. +* Two repository finder methods that locate the earliest breached / delinquent period. +* The differing grace-day handling between 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 business events are emitted for these fields. +* 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. +* `delinquencyStartDate` is meaningful only when the loan has a delinquency bucket assigned so that a delinquency-range schedule exists. +* 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 already offsets its first period by `breachGraceDays`, so the grace is *implicitly reflected* in this date — no further adjustment is applied. `null` when no period is in breach. + +*`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) *plus* `delinquencyGraceDays`. The delinquency-range schedule does **not** bake grace days into its period boundaries, so the grace is *added explicitly* here. `null` when no period is delinquent. + +*Breach period (`breach = true`):* A breach-schedule period whose `outstanding_amount > 0` after its `toDate` has passed and the WC COB has evaluated it. + +*Delinquent period (`min_payment_criteria_met = false`):* A delinquency-range-schedule period whose required minimum payment was not met by its `toDate` once evaluated by the WC COB. + +== Design Decisions and Considerations + +=== Asymmetric Grace-Day Handling + +The two start dates apply grace days differently, and this asymmetry is intentional — it mirrors how each schedule is generated: + +* *Breach schedule*: `BreachScheduleBusinessStep` shifts the first period's `fromDate` to `disbursementDate + breachGraceDays`. The grace is therefore *already embedded* in the period boundary, so `breachStartDate` is simply that `fromDate` with no extra arithmetic. +* *Delinquency-range schedule*: periods are generated anchored at the date selected by `delinquencyStartType` (the loan submitted-on date for `LOAN_CREATION`, the actual disbursement date for `DISBURSEMENT`/unset) *without* applying `delinquencyGraceDays`. Therefore `enrichWithStartDates` adds `delinquencyGraceDays` to the period `fromDate` to obtain the effective `delinquencyStartDate`. + +Treating both identically would either double-count the breach grace or omit the delinquency grace. See <>. + +=== Derived at Read Time, Not Persisted + +Both 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. + +=== Null Treated as Zero for Grace + +`enrichWithStartDates` defends against a `null` `delinquencyGraceDays`: + +[source,java] +---- +final int graceDays = data.getDelinquencyGraceDays() != null ? data.getDelinquencyGraceDays() : 0; +data.setDelinquencyStartDate(period.getFromDate().plusDays(graceDays)); +---- + +A `null` grace value is treated as `0`, so a loan with no configured delinquency grace reports the raw period `fromDate`. + +== Database Design + +=== Overview + +No tables or columns are introduced by this feature. Both 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_delinquency_range_schedule`*: read to find the earliest period flagged `min_payment_criteria_met = false`. The relevant columns are `wc_loan_id`, `from_date`, and `min_payment_criteria_met`. + +== API Design + +=== Endpoints + +==== Retrieve a Working Capital Loan + +Returns the full WC loan detail, including the derived start dates. + +[source] +---- +GET /v1/working-capital-loans/{loanId} +GET /v1/working-capital-loans/external-id/{loanExternalId} +---- + +**Response (relevant fields only):** + +[source,json] +---- +{ + "delinquencyGraceDays": 3, // product/loan delinquency grace days + "delinquencyStartType": { "id": 2, "code": "DISBURSEMENT", "value": "Disbursement" }, + "breachGraceDays": 5, // product/loan breach grace days + "breachStartDate": [2026, 1, 6], // fromDate of earliest breached period (grace already reflected) + "delinquencyStartDate": [2026, 1, 4] // fromDate of earliest delinquent period + delinquencyGraceDays +} +---- + +[NOTE] +==== +`breachStartDate` and `delinquencyStartDate` 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 Rules + +=== Breach Start Date + +* `breachStartDate` is the `fromDate` of the earliest `m_wc_loan_breach_schedule` row with `breach = true` (`findTopByLoanIdAndBreachTrueOrderByFromDateAsc`). +* The breach grace days are *not* re-added — they are already embedded in the period's `fromDate` by the breach schedule generator. +* When no breach period exists or none is flagged `breach = true`, `breachStartDate` 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`), shifted forward by `delinquencyGraceDays`. +* The delinquency schedule itself is anchored according to `delinquencyStartType`: `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. This anchor is resolved in `WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.generateInitialPeriod`. +* A `null` `delinquencyGraceDays` is treated as `0`. +* When no delinquent period exists, `delinquencyStartDate` is `null`. + +== Example Scenarios + +=== Scenario #1: Loan breaches and becomes delinquent on the same COB run + +**Setup:** + +* Disbursement date `D = 2026-01-01`. +* Breach configuration: 15-day frequency, `breachGraceDays = 5` → first breach period is `[D+5 .. D+19]` = `[2026-01-06 .. 2026-01-20]`. +* Delinquency bucket: 20-day frequency (no grace baked into the schedule), `delinquencyGraceDays = 3` → first delinquency period is `[D .. D+19]` = `[2026-01-01 .. 2026-01-20]`. +* The borrower makes no qualifying payment. + +**Action:** + +The business date is advanced to `D+19` (`2026-01-20`, the end of both first periods) and the `WC_LOAN_COB` job runs for the loan. A single COB run flags both the first breach period (`breach = true`) and the first delinquency period (`min_payment_criteria_met = false`). + +**Expected Behavior:** + +* `breachStartDate = 2026-01-06` — the `fromDate` of the first breached period (`D + breachGraceDays`); grace already reflected in the schedule. +* `delinquencyStartDate = 2026-01-04` — the `fromDate` of the first delinquent period (`D`) plus `delinquencyGraceDays` (`+3`). + +This is the exact behavior asserted by `WorkingCapitalLoanStartDatesIntegrationTest.testStartDatesArePopulatedWhenLoanBreachesAndBecomesDelinquent`. + +=== Scenario #2: Healthy loan — both start dates null + +**Setup:** + +* Same product/loan configuration as Scenario #1, disbursed on `D = 2026-01-01`. + +**Action:** + +The `WC_LOAN_COB` job runs on the disbursement date itself, before any schedule period has expired. No period is flagged. + +**Expected Behavior:** + +* `breachStartDate = null` — the loan is not in breach. +* `delinquencyStartDate = null` — the loan is not delinquent. + +This is asserted by `WorkingCapitalLoanStartDatesIntegrationTest.testStartDatesAreNullForHealthyLoan`. + +=== 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 delinquency bucket:* With no delinquency-range schedule generated, `delinquencyStartDate` stays `null`. +* *`null` delinquency grace days:* `delinquencyGraceDays` is treated as `0`, so `delinquencyStartDate` equals the raw period `fromDate`. +* *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 two derived, read-only anchor dates on the WC loan retrieve response. Key aspects include: + +* `breachStartDate` — the `fromDate` of the earliest breached breach-schedule period, with `breachGraceDays` already reflected by the schedule generator. +* `delinquencyStartDate` — the `fromDate` of the earliest delinquent range-schedule period, with `delinquencyGraceDays` added explicitly because the delinquency schedule does not bake grace into its boundaries. +* Both 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-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 439e406d568..4111d4faec6 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 @@ -219,6 +219,12 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public StringEnumOptionData delinquencyStartType; @Schema(example = "0", description = "Number of days to shift the start of the first breach schedule period after disbursement") public Integer breachGraceDays; + @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") + public LocalDate breachStartDate; + @Schema(example = "[2024, 1, 14]", description = "Start date of the loan's delinquency, i.e. the fromDate of the earliest " + + "delinquent range schedule period shifted by delinquencyGraceDays. Null when the loan is not delinquent") + public LocalDate delinquencyStartDate; @Schema(example = "[2024, 1, 14]", description = "Last closed business date (COB)") public LocalDate lastClosedBusinessDate; public List paymentAllocation; 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 7fa66cd9df7..088f064a06c 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 @@ -88,6 +88,8 @@ public class WorkingCapitalLoanData implements Serializable { private StringEnumOptionData delinquencyStartType; private Integer breachGraceDays; private BigDecimal totalPaymentVolume; + private LocalDate delinquencyStartDate; + private LocalDate breachStartDate; private WorkingCapitalLoanCollectionData collectionData; private WorkingCapitalLoanSummaryData summary; 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 7b4ef79faa5..9577af04d6d 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 @@ -73,6 +73,8 @@ public interface WorkingCapitalLoanMapper { @Mapping(target = "delinquencyGraceDays", source = "loanProductRelatedDetails.delinquencyGraceDays") @Mapping(target = "delinquencyStartType", source = "loanProductRelatedDetails", qualifiedByName = "delinquencyStartTypeData") @Mapping(target = "breachGraceDays", source = "loanProductRelatedDetails.breachGraceDays") + @Mapping(target = "breachStartDate", ignore = true) + @Mapping(target = "delinquencyStartDate", ignore = true) @Mapping(target = "collectionData", ignore = true) @Mapping(target = "totalNoPayments", ignore = true) @Mapping(target = "periodPaymentAmount", ignore = true) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java index e36098ea2e7..5cace9af755 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java @@ -34,4 +34,6 @@ public interface WorkingCapitalLoanBreachScheduleRepository extends JpaRepositor Optional findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(Long loanId, LocalDate transactionDate, LocalDate transactionDate1); + + Optional findTopByLoanIdAndBreachTrueOrderByFromDateAsc(Long loanId); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java index f6c72d08631..d864b508119 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java @@ -46,4 +46,6 @@ Optional findByLoanIdAndFromDateLess List findByLoanIdAndToDateLessThanEqualAndMinPaymentCriteriaMetIsNull(Long loanId, LocalDate businessDate); + Optional findTopByLoanIdAndMinPaymentCriteriaMetFalseOrderByFromDateAsc(Long loanId); + } 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 b047dccaa70..1e681d943fa 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 @@ -48,6 +48,8 @@ import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanMapper; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanSummaryMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloanbreach.data.WorkingCapitalBreachData; import org.apache.fineract.portfolio.workingcapitalloanbreach.service.WorkingCapitalBreachReadPlatformService; @@ -78,6 +80,8 @@ public class WorkingCapitalLoanApplicationReadPlatformServiceImpl implements Wor private final WorkingCapitalLoanDelinquencyReadPlatformService workingCapitalLoanDelinquencyReadPlatformService; private final WorkingCapitalNearBreachReadPlatformService nearBreachReadPlatformService; private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper; + private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; + private final WorkingCapitalLoanDelinquencyRangeScheduleRepository delinquencyRangeScheduleRepository; @Override public WorkingCapitalLoanTemplateData retrieveTemplate(final Long productId, final Long clientId) { @@ -169,6 +173,7 @@ public WorkingCapitalLoanData retrieveOne(final Long loanId) { ThreadLocalContextUtil.getBusinessDate()); data.setCollectionData(collectionData); enrichWithRateAndTerm(loan, data); + enrichWithStartDates(loan, data); return data; } @@ -191,6 +196,23 @@ 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())); + + // delinquencyStartDate: fromDate of the earliest delinquent period plus delinquencyGraceDays. The delinquency + // range + // schedule does not apply the grace days when generating periods, so they are added here. + delinquencyRangeScheduleRepository.findTopByLoanIdAndMinPaymentCriteriaMetFalseOrderByFromDateAsc(loan.getId()) + .ifPresent(period -> { + final int graceDays = data.getDelinquencyGraceDays() != null ? data.getDelinquencyGraceDays() : 0; + data.setDelinquencyStartDate(period.getFromDate().plusDays(graceDays)); + }); + } + @Override public Long getResolvedLoanId(final ExternalId externalId) { return this.repository.findByExternalId(externalId).map(WorkingCapitalLoan::getId).orElse(null); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index f5dc1da3d02..384a4310f30 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -45,7 +45,9 @@ import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleMapper; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.springframework.stereotype.Service; @RequiredArgsConstructor @@ -65,10 +67,9 @@ public void generateInitialPeriod(WorkingCapitalLoan loan) { return; } - LocalDate fromDate = loan.getDisbursementDetails().stream().map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate) - .filter(Objects::nonNull).findFirst().orElse(null); + LocalDate fromDate = resolveScheduleAnchorDate(loan); if (fromDate == null) { - log.warn("No actual disbursement date found for WC loan {}, skipping initial period generation", loan.getId()); + log.warn("No anchor date found for WC loan {}, skipping initial period generation", loan.getId()); return; } LocalDate toDate = calculateToDate(fromDate, rule.getFrequency(), rule.getFrequencyType()); @@ -197,6 +198,28 @@ public List retrieveRangeSchedul return capitalLoanDelinquencyRangeScheduleMapper.toDataList(periods); } + /** + * Resolves the date the delinquency clock starts ticking, based on the loan's configured + * {@link WorkingCapitalLoanDelinquencyStartType}. + * + *
    + *
  • {@code LOAN_CREATION}: the loan submitted-on date is used as the basis.
  • + *
  • {@code DISBURSEMENT} (or unset): the first actual disbursement date is used as the basis.
  • + *
+ * + * The configured {@code delinquencyGraceDays} are not applied here; they are added when the derived + * {@code delinquencyStartDate} is computed at read time. + */ + private LocalDate resolveScheduleAnchorDate(final WorkingCapitalLoan loan) { + final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); + final WorkingCapitalLoanDelinquencyStartType startType = details != null ? details.getDelinquencyStartType() : null; + if (WorkingCapitalLoanDelinquencyStartType.LOAN_CREATION.equals(startType)) { + return loan.getSubmittedOnDate(); + } + return loan.getDisbursementDetails().stream().map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate) + .filter(Objects::nonNull).findFirst().orElse(null); + } + private DelinquencyMinimumPaymentPeriodAndRule getMinimumPaymentRule(WorkingCapitalLoan loan) { WorkingCapitalLoanProduct product = loan.getLoanProduct(); if (product == null) { 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 new file mode 100644 index 00000000000..afb6d7304e1 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesTest.java @@ -0,0 +1,219 @@ +/** + * 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.integrationtests; + +import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.DelinquencyRangeRequest; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.InlineJobRequest; +import org.apache.fineract.client.models.PostDelinquencyBucketResponse; +import org.apache.fineract.client.models.PostDelinquencyRangeResponse; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.products.DelinquencyRangesHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDelinquencyRangeScheduleHelper; +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.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Validates the {@code breachStartDate} and {@code delinquencyStartDate} fields populated by + * {@code WorkingCapitalLoanApplicationReadPlatformServiceImpl.enrichWithStartDates} on the {@code GET + * /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 delinquencyStartDate} = fromDate of the earliest delinquent range-schedule period (minPaymentCriteriaMet = + * false) plus {@code delinquencyGraceDays} (the range schedule does not apply the grace days when generating + * periods).
  • + *
+ */ +@Slf4j +@ExtendWith(LoanTestLifecycleExtension.class) +public class WorkingCapitalLoanStartDatesTest { + + private static final BigDecimal PRINCIPAL = BigDecimal.valueOf(10000); + private static final BigDecimal TOTAL_PAYMENT_VOLUME = BigDecimal.valueOf(100000); + 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]. + private static final int BREACH_FREQUENCY_DAYS = 15; + private static final int BREACH_GRACE_DAYS = 5; + // Delinquency: 20-day frequency (no grace baked into the schedule) -> first period [D .. D+19]. + private static final int DELINQUENCY_FREQUENCY_DAYS = 20; + private static final int DELINQUENCY_GRACE_DAYS = 3; + + private static final LocalDate DISBURSEMENT_DATE = LocalDate.of(2026, 1, 1); + // 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); + + @Test + public void testStartDatesArePopulatedWhenLoanBreachesAndBecomesDelinquent() { + AtomicLong loanIdRef = new AtomicLong(); + + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoan()); + }); + + BusinessDateHelper.runAt("21 January 2026", () -> { + final Long loanId = loanIdRef.get(); + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + // then - both start dates are populated on the retrieveOne response + final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + final GetWorkingCapitalLoansLoanIdResponse response = loanHelper.retrieveLoan(loanId); + + // breachStartDate = fromDate of the first breached period = disbursement + breachGraceDays (grace already + // in schedule) + assertEquals(DISBURSEMENT_DATE.plusDays(BREACH_GRACE_DAYS), response.getBreachStartDate(), + "breachStartDate should be the fromDate of the first breached period (disbursement + breachGraceDays)"); + + // delinquencyStartDate = fromDate of the first delinquent period (= disbursement) + delinquencyGraceDays + assertEquals(DISBURSEMENT_DATE.plusDays(DELINQUENCY_GRACE_DAYS), response.getDelinquencyStartDate(), + "delinquencyStartDate should be the fromDate of the first delinquent period plus delinquencyGraceDays"); + }); + } + + @Test + public void testDelinquencyStartDateUsesLoanCreationDateWhenConfigured() { + AtomicLong loanIdRef = new AtomicLong(); + + // given - a WC loan submitted on 2025-12-20 but disbursed on 2026-01-01, with delinquencyStartType = + // LOAN_CREATION + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoan(SUBMITTED_ON_DATE, "LOAN_CREATION")); + }); + + 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); + + // delinquencyStartDate must anchor on the loan submitted-on date (creation), not the disbursement date, + // plus the delinquencyGraceDays. + assertEquals(SUBMITTED_ON_DATE.plusDays(DELINQUENCY_GRACE_DAYS), response.getDelinquencyStartDate(), + "delinquencyStartDate should anchor on submittedOnDate + delinquencyGraceDays when delinquencyStartType = LOAN_CREATION"); + }); + } + + @Test + public void testStartDatesAreNullForHealthyLoan() { + BusinessDateHelper.runAt("01 January 2026", () -> { + // given - a disbursed WC loan with breach + delinquency configuration + final Long loanId = createDisbursedLoan(); + + // when - run the WC COB on the disbursement date, before any period has expired + ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + // then - neither start date is set while the loan is healthy + final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + final GetWorkingCapitalLoansLoanIdResponse response = loanHelper.retrieveLoan(loanId); + + assertNull(response.getBreachStartDate(), "breachStartDate must be null when the loan is not in breach"); + assertNull(response.getDelinquencyStartDate(), "delinquencyStartDate must be null when the loan is not delinquent"); + }); + } + + private Long createDisbursedLoan() { + // Default: submitted-on date left unset (defaults to the disbursement date) and no explicit + // delinquencyStartType. + return createDisbursedLoan(null, null); + } + + private Long createDisbursedLoan(final LocalDate submittedOnDate, final String delinquencyStartType) { + // Delinquency bucket with a percentage minimum payment and a 20-day frequency. + final List rangeIds = createDelinquencyRanges(); + final PostDelinquencyBucketResponse bucketResponse = WorkingCapitalLoanDelinquencyRangeScheduleHelper + .createWorkingCapitalLoanDelinquencyBucket(rangeIds, DELINQUENCY_FREQUENCY_DAYS, 0, DELINQUENCY_MIN_PAYMENT_PERCENT, 1); + assertNotNull(bucketResponse); + + // Breach with a flat amount and a 15-day frequency. + final WorkingCapitalBreachHelper breachHelper = new WorkingCapitalBreachHelper(); + final Long breachId = breachHelper.create(breachHelper.createBreachRequest(Utils.uniqueRandomStringGenerator("WCL_Breach_", 6), + BREACH_FREQUENCY_DAYS, "DAYS", "FLAT", BREACH_AMOUNT)); + assertNotNull(breachId); + + // Product wiring breach + delinquency, with distinct grace days for each. + final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + final String uniqueName = "WCL Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder() // + .withName(uniqueName) // + .withShortName(uniqueShortName) // + .withDelinquencyBucketId(bucketResponse.getResourceId()) // + .withDelinquencyGraceDays(DELINQUENCY_GRACE_DAYS) // + .withDelinquencyStartType(delinquencyStartType) // + .withBreachId(breachId) // + .withBreachGraceDays(BREACH_GRACE_DAYS) // + .build()).getResourceId(); + assertNotNull(productId); + + // Client + loan application. + final Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + final Long loanId = loanHelper.submit(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(clientId) // + .withProductId(productId) // + .withPrincipal(PRINCIPAL) // + .withSubmittedOnDate(submittedOnDate) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .withTotalPaymentVolume(TOTAL_PAYMENT_VOLUME) // + .buildSubmitRequest()); + assertNotNull(loanId); + + // Approve and disburse on the same date so the schedules anchor on DISBURSEMENT_DATE. + loanHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveRequest(DISBURSEMENT_DATE, PRINCIPAL, null)); + loanHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildDisburseRequest(DISBURSEMENT_DATE, PRINCIPAL)); + log.info("Created disbursed WC loan {} for start-date validation", loanId); + return loanId; + } + + private List createDelinquencyRanges() { + final PostDelinquencyRangeResponse range1 = DelinquencyRangesHelper.createRange(new DelinquencyRangeRequest() + .classification(Utils.randomStringGenerator("DLQ_R_", 10)).minimumAgeDays(1).maximumAgeDays(30).locale("en")); + final PostDelinquencyRangeResponse range2 = DelinquencyRangesHelper.createRange(new DelinquencyRangeRequest() + .classification(Utils.randomStringGenerator("DLQ_R_", 10)).minimumAgeDays(31).maximumAgeDays(60).locale("en")); + return List.of(range1.getResourceId(), range2.getResourceId()); + } +}