From 7911cbd92ca4e8a4303508330284b6ec2465ddd7 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 --- .../WorkingCapitalLoanApiResourceSwagger.java | 6 + .../data/WorkingCapitalLoanData.java | 2 + .../mapper/WorkingCapitalLoanMapper.java | 2 + ...ngCapitalLoanBreachScheduleRepository.java | 2 + ...oanDelinquencyRangeScheduleRepository.java | 2 + ...oanApplicationReadPlatformServiceImpl.java | 22 +++ ...gCapitalLoanStartDatesIntegrationTest.java | 184 ++++++++++++++++++ 7 files changed, 220 insertions(+) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesIntegrationTest.java 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/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesIntegrationTest.java new file mode 100644 index 00000000000..45d1ac59c18 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanStartDatesIntegrationTest.java @@ -0,0 +1,184 @@ +/** + * 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 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.infrastructure.businessdate.domain.BusinessDateType; +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 WorkingCapitalLoanStartDatesIntegrationTest { + + 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); + // Both the first breach period and the first delinquency period end on D+19, so a single COB run flags both. + private static final LocalDate PERIOD_END_DATE = DISBURSEMENT_DATE.plusDays(19); + + @Test + public void testStartDatesArePopulatedWhenLoanBreachesAndBecomesDelinquent() { + BusinessDateHelper.runAt("01 January 2026", () -> { + // given - a disbursed WC loan with breach + delinquency configuration + final Long loanId = createDisbursedLoan(); + + // when - advance the business date past the end of the first period and run the WC COB + BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, PERIOD_END_DATE); + 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 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() { + // 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) // + .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) // + .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()); + } +}