Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<GetPaymentAllocation> paymentAllocation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ public interface WorkingCapitalLoanBreachScheduleRepository extends JpaRepositor

Optional<WorkingCapitalLoanBreachSchedule> findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(Long loanId,
LocalDate transactionDate, LocalDate transactionDate1);

Optional<WorkingCapitalLoanBreachSchedule> findTopByLoanIdAndBreachTrueOrderByFromDateAsc(Long loanId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ Optional<WorkingCapitalLoanDelinquencyRangeSchedule> findByLoanIdAndFromDateLess
List<WorkingCapitalLoanDelinquencyRangeSchedule> findByLoanIdAndToDateLessThanEqualAndMinPaymentCriteriaMetIsNull(Long loanId,
LocalDate businessDate);

Optional<WorkingCapitalLoanDelinquencyRangeSchedule> findTopByLoanIdAndMinPaymentCriteriaMetFalseOrderByFromDateAsc(Long loanId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -169,6 +173,7 @@ public WorkingCapitalLoanData retrieveOne(final Long loanId) {
ThreadLocalContextUtil.getBusinessDate());
data.setCollectionData(collectionData);
enrichWithRateAndTerm(loan, data);
enrichWithStartDates(loan, data);
return data;
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <ul>
* <li>{@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.</li>
* <li>{@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).</li>
* </ul>
*/
@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<Long> 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<Long> 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());
}
}
Loading