From 7772bd0cc1929e0e9350b94c0273d57467144f91 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Tue, 14 Jul 2026 11:06:05 +0200 Subject: [PATCH 1/2] FINERACT-2455: working capital breach start date type --- .../helper/WorkingCapitalLoanTestHelper.java | 3 +- ...kingCapitalLoanChargeAfterMaturity.feature | 10 +- .../WorkingCapitalLoanApiResourceSwagger.java | 7 ++ .../data/WorkingCapitalLoanData.java | 1 + .../data/WorkingCapitalLoanTemplateData.java | 1 + .../mapper/WorkingCapitalLoanMapper.java | 6 ++ ...ngCapitalLoanBreachScheduleRepository.java | 5 + ...ngCapitalLoanApplicationDataValidator.java | 34 ++++++- ...oanApplicationReadPlatformServiceImpl.java | 4 + .../WorkingCapitalLoanAssemblerImpl.java | 29 +++++- ...gCapitalLoanBreachScheduleServiceImpl.java | 48 +++++---- ...italLoanBreachActionParseAndValidator.java | 32 ++++-- .../WorkingCapitalLoanProductConstants.java | 1 + ...gCapitalLoanProductApiResourceSwagger.java | 7 ++ .../data/WorkingCapitalLoanProductData.java | 3 + .../WorkingCapitalLoanBreachStartType.java | 63 ++++++++++++ ...orkingCapitalLoanProductRelatedDetail.java | 4 + ...rkingCapitalLoanProductRelatedDetails.java | 4 + .../WorkingCapitalLoanProductMapper.java | 8 ++ ...orkingCapitalLoanProductDataValidator.java | 18 ++++ ...talLoanProductReadPlatformServiceImpl.java | 4 + .../WorkingCapitalLoanProductUpdateUtil.java | 11 +++ ...alLoanProductWritePlatformServiceImpl.java | 8 +- .../module-changelog-master.xml | 1 + .../parts/0058_wc_breach_start_type.xml | 58 +++++++++++ ...gCapitalLoanApplicationValidationTest.java | 17 ++++ .../WorkingCapitalLoanProductCRUDTest.java | 96 ++++++++++++++++++ .../WorkingCapitalLoanStartDatesTest.java | 97 +++++++++++++++++++ ...kingCapitalLoanApplicationTestBuilder.java | 9 ++ .../WorkingCapitalLoanProductTestBuilder.java | 8 ++ 30 files changed, 554 insertions(+), 43 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanBreachStartType.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0058_wc_breach_start_type.xml diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java index 2bbe7de14cb..66b6a03fb7d 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java @@ -81,7 +81,8 @@ public Long insertLoan(final LoanStatus status, final LocalDate lastClosedBusine .addValue("principal", DEFAULT_PRINCIPAL)// .addValue("principal_amount_proposed", DEFAULT_PRINCIPAL)// .addValue("approved_principal", DEFAULT_PRINCIPAL)// - .addValue("total_payment_volume", DEFAULT_PRINCIPAL); + .addValue("total_payment_volume", DEFAULT_PRINCIPAL)// + .addValue("breach_start_type", "DISBURSEMENT"); final Number key = wcLoanInsert.executeAndReturnKey(params); return Objects.requireNonNull(key, "Generated key must not be null").longValue(); } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature index e2ee13db867..e5ba9f0bb0d 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAfterMaturity.feature @@ -152,13 +152,13 @@ Feature: Working Capital Loan Charge After Maturity | balance.principalOutstanding | 1000.0 | | timeline.actualMaturityDate | 2026-10-01 | | delinquencyStartDate | present | - | breachStartDate | null | + | breachStartDate | 2026-01-01 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-02-28 | 59 | 12.30 | 12.30 | null | null | - | 2 | 2026-03-01 | 2026-04-30 | 61 | 12.30 | 12.30 | null | null | - | 3 | 2026-05-01 | 2026-06-30 | 61 | 12.30 | 12.30 | null | null | - | 4 | 2026-07-01 | 2026-08-31 | 62 | 12.30 | 12.30 | null | null | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 12.30 | 12.30 | null | true | + | 2 | 2026-03-01 | 2026-04-30 | 61 | 12.30 | 12.30 | null | true | + | 3 | 2026-05-01 | 2026-06-30 | 61 | 12.30 | 12.30 | null | true | + | 4 | 2026-07-01 | 2026-08-31 | 62 | 12.30 | 12.30 | null | true | | 5 | 2026-09-01 | 2026-10-31 | 61 | 12.30 | 12.30 | null | null | Then Admin closes the Working Capital loan with a full repayment on "01 October 2026" And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" 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 2afe5507804..7f238367d38 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 @@ -50,6 +50,7 @@ private GetWorkingCapitalLoansTemplateResponse() {} public Collection delinquencyBucketOptions; public List periodFrequencyTypeOptions; public List delinquencyStartTypeOptions; + public List breachStartTypeOptions; public List delinquencyMinimumPaymentTypeOptions; public List breachOptions; } @@ -250,6 +251,8 @@ 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(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") public LocalDate breachStartDate; @@ -500,6 +503,8 @@ private PostWorkingCapitalLoansRequest() {} public String 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 = "DISBURSEMENT", description = "Breach start type: LOAN_CREATION or DISBURSEMENT") + public String breachStartType; public List paymentAllocation; @Schema(description = """ Optional array of originators to associate with this loan. \ @@ -636,6 +641,8 @@ private PutWorkingCapitalLoansLoanIdRequest() {} public String 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 = "DISBURSEMENT", description = "Breach start type: LOAN_CREATION or DISBURSEMENT") + public String breachStartType; public List paymentAllocation; @Schema(example = "en_GB") 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 32baf054762..a8b0a3313c8 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 @@ -97,6 +97,7 @@ public class WorkingCapitalLoanData implements Serializable { private Integer delinquencyGraceDays; private StringEnumOptionData delinquencyStartType; private Integer breachGraceDays; + private StringEnumOptionData breachStartType; private BigDecimal totalPaymentVolume; private LocalDate delinquencyStartDate; private LocalDate breachStartDate; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTemplateData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTemplateData.java index 28505f22dbd..a7e11d25cbb 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTemplateData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTemplateData.java @@ -49,6 +49,7 @@ public class WorkingCapitalLoanTemplateData { private Collection delinquencyBucketOptions; private List periodFrequencyTypeOptions; private List delinquencyStartTypeOptions; + private List breachStartTypeOptions; private List breachOptions; private List delinquencyMinimumPaymentTypeOptions; private List nearBreachOptions; 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 d7a011ed0b3..da5892f5f38 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 @@ -76,6 +76,7 @@ public interface WorkingCapitalLoanMapper { @Mapping(target = "delinquencyGraceDays", source = "loanProductRelatedDetails.delinquencyGraceDays") @Mapping(target = "delinquencyStartType", source = "loanProductRelatedDetails", qualifiedByName = "delinquencyStartTypeData") @Mapping(target = "breachGraceDays", source = "loanProductRelatedDetails.breachGraceDays") + @Mapping(target = "breachStartType", source = "loanProductRelatedDetails", qualifiedByName = "breachStartTypeData") @Mapping(target = "breachStartDate", ignore = true) @Mapping(target = "delinquencyStartDate", ignore = true) @Mapping(target = "delinquent", ignore = true) @@ -121,6 +122,11 @@ default StringEnumOptionData delinquencyStartTypeData(final WorkingCapitalLoanPr : null; } + @Named("breachStartTypeData") + default StringEnumOptionData breachStartTypeData(final WorkingCapitalLoanProductRelatedDetails detail) { + return (detail != null && detail.getBreachStartType() != null) ? detail.getBreachStartType().toStringEnumOptionData() : null; + } + @Named("amortizationTypeData") default StringEnumOptionData amortizationTypeData(final WorkingCapitalLoanProductRelatedDetails detail) { return (detail != null && detail.getAmortizationType() != null) ? detail.getAmortizationType().getValueAsStringEnumOptionData() 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 5cace9af755..4361f8ff56d 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 @@ -28,10 +28,15 @@ public interface WorkingCapitalLoanBreachScheduleRepository extends JpaRepositor List findByLoanIdOrderByPeriodNumberAsc(Long loanId); + List findByLoanIdAndBreachIsNullAndToDateLessThanEqualOrderByPeriodNumberAsc(Long loanId, + LocalDate businessDate); + boolean existsByLoanId(Long loanId); Optional findTopByLoanIdOrderByPeriodNumberDesc(Long loanId); + Optional findTopByLoanIdOrderByPeriodNumberAsc(Long loanId); + Optional findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(Long loanId, LocalDate transactionDate, LocalDate transactionDate1); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java index 770806a436d..d661138bb1f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java @@ -63,6 +63,7 @@ import org.apache.fineract.portfolio.workingcapitalloannearbreach.domain.WorkingCapitalNearBreach; import org.apache.fineract.portfolio.workingcapitalloannearbreach.validator.WorkingCapitalNearBreachParseAndValidator; import org.apache.fineract.portfolio.workingcapitalloanproduct.WorkingCapitalLoanProductConstants; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; @@ -94,7 +95,7 @@ public class WorkingCapitalLoanApplicationDataValidator { WorkingCapitalLoanProductConstants.breachIdParamName, WorkingCapitalLoanProductConstants.allowAttributeOverridesParamName, WorkingCapitalLoanProductConstants.paymentAllocationParamName, WorkingCapitalLoanProductConstants.delinquencyGraceDaysParamName, WorkingCapitalLoanProductConstants.delinquencyStartTypeParamName, WorkingCapitalLoanProductConstants.nearBreachIdParamName, - WorkingCapitalLoanProductConstants.breachGraceDaysParamName)); + WorkingCapitalLoanProductConstants.breachGraceDaysParamName, WorkingCapitalLoanProductConstants.breachStartTypeParamName)); private final FromJsonHelper fromApiJsonHelper; private final WorkingCapitalPaymentAllocationDataValidator paymentAllocationDataValidator; @@ -240,6 +241,21 @@ public void validateForCreate(final JsonCommand command) { } } + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element)) { + final String breachStartTypeValue = this.fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.breachStartTypeParamName).value(breachStartTypeValue) + .ignoreIfNull().notBlank(); + if (breachStartTypeValue != null && !breachStartTypeValue.isBlank()) { + final WorkingCapitalLoanBreachStartType breachStartType = WorkingCapitalLoanBreachStartType + .fromString(breachStartTypeValue); + if (breachStartType == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.breachStartTypeParamName) + .failWithCode("invalid.breach.start.type"); + } + } + } + // Submitted-on date rules (product range, client activation, not future, not after expected disbursement) validateSubmittedOnDate(element, product, client, expectedDisbursementDate, null); @@ -496,6 +512,22 @@ private void validateForUpdate(final JsonCommand command, final Long existingPro } } + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element)) { + final String breachStartTypeValue = this.fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.breachStartTypeParamName).value(breachStartTypeValue) + .ignoreIfNull().notBlank(); + if (breachStartTypeValue != null && !breachStartTypeValue.isBlank()) { + atLeastOneParameterPassedForUpdate = true; + final WorkingCapitalLoanBreachStartType breachStartType = WorkingCapitalLoanBreachStartType + .fromString(breachStartTypeValue); + if (breachStartType == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.breachStartTypeParamName) + .failWithCode("invalid.breach.start.type"); + } + } + } + if (!atLeastOneParameterPassedForUpdate) { baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.idParameterName).value(null).anyOfNotNull(); } 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 39ca9a5aad5..809aaf6cfe7 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 @@ -60,6 +60,7 @@ import org.apache.fineract.portfolio.workingcapitalloannearbreach.data.WorkingCapitalNearBreachData; import org.apache.fineract.portfolio.workingcapitalloannearbreach.service.WorkingCapitalNearBreachReadPlatformService; import org.apache.fineract.portfolio.workingcapitalloanproduct.data.WorkingCapitalLoanProductData; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.service.WorkingCapitalLoanProductReadPlatformService; import org.apache.fineract.useradministration.domain.AppUserRepository; @@ -104,6 +105,8 @@ public WorkingCapitalLoanTemplateData retrieveTemplate(final Long productId, fin final List nearBreachOptions = nearBreachReadPlatformService.retrieveAll(); final List delinquencyStartTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanDelinquencyStartType.class); + final List breachStartTypeOptions = ApiFacingEnum + .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanBreachStartType.class); final List delinquencyMinimumPaymentTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(DelinquencyMinimumPaymentType.class); final WorkingCapitalLoanData.WorkingCapitalLoanDataBuilder builder = WorkingCapitalLoanData.builder(); @@ -138,6 +141,7 @@ public WorkingCapitalLoanTemplateData retrieveTemplate(final Long productId, fin .breachOptions(breachOptions)// .nearBreachOptions(nearBreachOptions)// .delinquencyStartTypeOptions(delinquencyStartTypeOptions)// + .breachStartTypeOptions(breachStartTypeOptions)// .delinquencyMinimumPaymentTypeOptions(delinquencyMinimumPaymentTypeOptions).build(); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java index 605a16793b2..b8ba4a1785d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java @@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.WorkingCapitalLoanProductConstants; import org.apache.fineract.portfolio.workingcapitalloanproduct.data.WorkingCapitalPaymentAllocationData; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAdvancedPaymentAllocationsJsonParser; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductPaymentAllocationRule; @@ -218,10 +219,18 @@ private WorkingCapitalLoanProductRelatedDetails buildLoanProductRelatedDetails(f ? fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanProductConstants.delinquencyStartTypeParamName, element) : null; if (delinquencyStartTypeValue != null) { - detail.setDelinquencyStartType(WorkingCapitalLoanDelinquencyStartType.valueOf(delinquencyStartTypeValue)); + detail.setDelinquencyStartType(WorkingCapitalLoanDelinquencyStartType.fromString(delinquencyStartTypeValue)); } else { detail.setDelinquencyStartType(productDetail.getDelinquencyStartType()); } + final String breachStartTypeValue = fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.breachStartTypeParamName, + element) ? fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element) + : null; + if (breachStartTypeValue != null) { + detail.setBreachStartType(WorkingCapitalLoanBreachStartType.fromString(breachStartTypeValue)); + } else { + detail.setBreachStartType(productDetail.getBreachStartType()); + } if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.delinquencyBucketIdParamName, element)) { final Long bucketId = fromApiJsonHelper.extractLongNamed(WorkingCapitalLoanProductConstants.delinquencyBucketIdParamName, @@ -438,7 +447,7 @@ public Map updateFrom(final JsonCommand command, final WorkingCa .extractStringNamed(WorkingCapitalLoanProductConstants.delinquencyStartTypeParamName, element); if (delinquencyStartTypeValue != null) { final WorkingCapitalLoanDelinquencyStartType type = WorkingCapitalLoanDelinquencyStartType - .valueOf(delinquencyStartTypeValue); + .fromString(delinquencyStartTypeValue); detail.setDelinquencyStartType(type); changes.put(WorkingCapitalLoanProductConstants.delinquencyStartTypeParamName, type.getCode()); } else { @@ -447,6 +456,22 @@ public Map updateFrom(final JsonCommand command, final WorkingCa } } } + if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element)) { + final String existingValue = detail.getBreachStartType() != null ? detail.getBreachStartType().name() : null; + if (command.isChangeInStringParameterNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, existingValue)) { + final String breachStartTypeValue = fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element); + if (breachStartTypeValue != null) { + final WorkingCapitalLoanBreachStartType type = WorkingCapitalLoanBreachStartType.fromString(breachStartTypeValue); + detail.setBreachStartType(type); + changes.put(WorkingCapitalLoanProductConstants.breachStartTypeParamName, type.getCode()); + } else { + detail.setBreachStartType(WorkingCapitalLoanBreachStartType.DISBURSEMENT); + changes.put(WorkingCapitalLoanProductConstants.breachStartTypeParamName, + WorkingCapitalLoanBreachStartType.DISBURSEMENT.getCode()); + } + } + } } if (command.arrayOfParameterNamed(WorkingCapitalLoanProductConstants.paymentAllocationParamName) != null) { 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 a6f7c1f58b0..7fdeff50289 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 @@ -49,6 +49,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloanbreach.domain.WorkingCapitalBreach; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.springframework.stereotype.Service; @@ -71,13 +72,13 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) { return; } - final Optional disbursementDateOptional = loanRepository.findFirstActualDisbursementDate(loan.getId()); - if (disbursementDateOptional.isEmpty()) { - log.warn("No actual disbursement date found for WC loan {}, skipping initial breach schedule generation", loan.getId()); + final Optional anchorDateOptional = resolveBreachAnchorDate(loan); + if (anchorDateOptional.isEmpty()) { + log.warn("No breach schedule anchor date found for WC loan {}, skipping initial breach schedule generation", loan.getId()); return; } - final LocalDate fromDate = disbursementDateOptional.get().plusDays(getBreachGraceDays(loan)); + final LocalDate fromDate = anchorDateOptional.get().plusDays(getBreachGraceDays(loan)); final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); final LocalDate toDate = calculateToDate(fromDate, params.frequency(), params.frequencyType()); final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, params); @@ -196,22 +197,10 @@ private void applyRepaymentUndo(final WorkingCapitalLoanBreachSchedule period, f @Override public void evaluateBreach(final WorkingCapitalLoan loan, final LocalDate businessDate) { - final Optional relevantPeriod = repository - .findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(loan.getId(), businessDate, businessDate); - if (relevantPeriod.isEmpty()) { - return; - } - final WorkingCapitalLoanBreachSchedule period = relevantPeriod.get(); - if (period.getBreach() != null) { - return; - } - if (isBreachEvaluationDisabled(loan.getId(), businessDate)) { - log.debug("Skipping breach evaluation for WC loan {} - breach evaluation is disabled as of {}", loan.getId(), businessDate); - return; - } - if (evaluateBreachOnDate(period, businessDate)) { - repository.saveAndFlush(period); - } + // Sweep every expired period, not just the one covering the business date: with a LOAN_CREATION anchor the + // first period can expire before the first COB touches the loan, so a single-period lookup would leave it + // unbreached. + evaluateExpiredBreaches(loan, businessDate); } @Override @@ -423,6 +412,17 @@ private Integer getBreachGraceDays(final WorkingCapitalLoan loan) { return (details == null || details.getBreachGraceDays() == null) ? 0 : details.getBreachGraceDays(); } + private Optional resolveBreachAnchorDate(final WorkingCapitalLoan loan) { + final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); + final WorkingCapitalLoanBreachStartType breachStartType = (details == null || details.getBreachStartType() == null) + ? WorkingCapitalLoanBreachStartType.DISBURSEMENT + : details.getBreachStartType(); + if (WorkingCapitalLoanBreachStartType.LOAN_CREATION.equals(breachStartType)) { + return Optional.ofNullable(loan.getSubmittedOnDate()); + } + return loanRepository.findFirstActualDisbursementDate(loan.getId()); + } + private LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency, final WorkingCapitalLoanPeriodFrequencyType frequencyType) { return switch (frequencyType) { @@ -472,12 +472,10 @@ private void evaluateExpiredBreaches(final WorkingCapitalLoan loan, final LocalD if (isBreachEvaluationDisabled(loan.getId(), businessDate)) { return; } - final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + final List periods = repository + .findByLoanIdAndBreachIsNullAndToDateLessThanEqualOrderByPeriodNumberAsc(loan.getId(), businessDate); for (final WorkingCapitalLoanBreachSchedule period : periods) { - if (period.getBreach() != null) { - continue; - } - if (!period.getToDate().isAfter(businessDate) && evaluateBreachOnDate(period, businessDate)) { + if (evaluateBreachOnDate(period, businessDate)) { repository.saveAndFlush(period); } } 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 a3fde9d012f..07ab5af2091 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 @@ -52,9 +52,9 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; -import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanActiveBreachResetResolver; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.springframework.stereotype.Component; @@ -71,7 +71,6 @@ public class WorkingCapitalLoanBreachActionParseAndValidator extends ParseAndVal private static final String ENABLE_ACTION = "enable"; private final FromJsonHelper jsonHelper; - private final WorkingCapitalLoanRepository loanRepository; private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; private final WorkingCapitalLoanActiveBreachResetResolver activeBreachResetResolver; private final WorkingCapitalLoanBreachActionRepository breachActionRepository; @@ -349,18 +348,35 @@ private void validateStartBeforeEnd(final DataValidatorBuilder dataValidator, fi private void validateNotBeforeScheduleStart(final DataValidatorBuilder dataValidator, final LocalDate startDate, final WorkingCapitalLoan workingCapitalLoan) { - loanRepository.findFirstActualDisbursementDate(workingCapitalLoan.getId()) - .map(disbursementDate -> disbursementDate.plusDays(getBreachGraceDays(workingCapitalLoan))) + breachScheduleRepository.findTopByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()) + .map(WorkingCapitalLoanBreachSchedule::getFromDate).or(() -> resolveExpectedScheduleStart(workingCapitalLoan)) .ifPresent(scheduleStartDate -> dataValidator.reset().parameter(START_DATE).value(startDate) .validateDateAfterOrEqual(scheduleStartDate)); } + /** + * Mirrors the anchor resolution of the breach schedule generation for loans whose schedule has not been generated + * yet (e.g. a pause placed between disbursement and the first COB run). + */ + private Optional resolveExpectedScheduleStart(final WorkingCapitalLoan workingCapitalLoan) { + final WorkingCapitalLoanProductRelatedDetails details = workingCapitalLoan.getLoanProductRelatedDetails(); + final WorkingCapitalLoanBreachStartType breachStartType = (details == null || details.getBreachStartType() == null) + ? WorkingCapitalLoanBreachStartType.DISBURSEMENT + : details.getBreachStartType(); + final Optional anchorDate = WorkingCapitalLoanBreachStartType.LOAN_CREATION.equals(breachStartType) + ? Optional.ofNullable(workingCapitalLoan.getSubmittedOnDate()) + : firstActualDisbursementDate(workingCapitalLoan); + return anchorDate.map(anchor -> anchor.plusDays(getBreachGraceDays(workingCapitalLoan))); + } + + private Optional firstActualDisbursementDate(final WorkingCapitalLoan workingCapitalLoan) { + return workingCapitalLoan.getDisbursementDetails().stream().map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate) + .filter(Objects::nonNull).min(LocalDate::compareTo); + } + private int getBreachGraceDays(final WorkingCapitalLoan workingCapitalLoan) { final WorkingCapitalLoanProductRelatedDetails details = workingCapitalLoan.getLoanProductRelatedDetails(); - if (details == null || details.getBreachGraceDays() == null) { - return 0; - } - return details.getBreachGraceDays(); + return (details == null || details.getBreachGraceDays() == null) ? 0 : details.getBreachGraceDays(); } private void validateNoOverlap(final DataValidatorBuilder dataValidator, final LocalDate startDate, final LocalDate endDate, diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java index 7408e7d907c..8642f20b6c7 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java @@ -71,6 +71,7 @@ private WorkingCapitalLoanProductConstants() { // Breach grace public static final String breachGraceDaysParamName = "breachGraceDays"; + public static final String breachStartTypeParamName = "breachStartType"; // Accounting public static final String accountingRuleParamName = "accountingRule"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java index 57996813749..d0cb0e7a78a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java @@ -129,6 +129,8 @@ private PostWorkingCapitalLoanProductsRequest() {} public Long nearBreachId; @Schema(example = "0", description = "Number of days to shift the start of the first breach schedule period after disbursement") public Integer breachGraceDays; + @Schema(example = "DISBURSEMENT", description = "Breach start type: LOAN_CREATION or DISBURSEMENT") + public String breachStartType; // Configurable attributes public PostAllowAttributeOverrides allowAttributeOverrides; @@ -333,6 +335,7 @@ private GetWorkingCapitalLoanProductsResponse() {} 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; + public StringEnumOptionData breachStartType; // Configurable attributes public GetConfigurableAttributes allowAttributeOverrides; @@ -447,6 +450,7 @@ private GetWorkingCapitalLoanProductsTemplateResponse() {} public List nearBreachOptions; public List advancedPaymentAllocationTypes; public List delinquencyStartTypeOptions; + public List breachStartTypeOptions; public List delinquencyMinimumPaymentTypeOptions; public List advancedPaymentAllocationTransactionTypes; public List delinquencyBucketOptions; @@ -518,6 +522,7 @@ private GetWorkingCapitalLoanProductsProductIdResponse() {} public GetWorkingCapitalLoanNearBreach nearBreach; @Schema(example = "0", description = "Number of days to shift the start of the first breach schedule period after disbursement") public Integer breachGraceDays; + public StringEnumOptionData breachStartType; // Configurable attributes public GetWorkingCapitalLoanProductsResponse.GetConfigurableAttributes allowAttributeOverrides; @@ -601,6 +606,8 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public Long nearBreachId; @Schema(example = "0", description = "Number of days to shift the start of the first breach schedule period after disbursement") public Integer breachGraceDays; + @Schema(example = "DISBURSEMENT", description = "Breach start type: LOAN_CREATION or DISBURSEMENT") + public String breachStartType; // Configurable attributes public PostWorkingCapitalLoanProductsRequest.PostAllowAttributeOverrides allowAttributeOverrides; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java index a50c1eeefb3..e21df489d27 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java @@ -89,6 +89,7 @@ public class WorkingCapitalLoanProductData implements Serializable { private Integer delinquencyGraceDays; private StringEnumOptionData delinquencyStartType; private Integer breachGraceDays; + private StringEnumOptionData breachStartType; // Configurable attributes (allowAttributeOverrides) private WorkingCapitalLoanProductConfigurableAttributesData allowAttributeOverrides; @@ -112,6 +113,7 @@ public class WorkingCapitalLoanProductData implements Serializable { private List periodFrequencyTypeOptions; private List advancedPaymentAllocationTypes; private List delinquencyStartTypeOptions; + private List breachStartTypeOptions; private List delinquencyMinimumPaymentTypeOptions; private List advancedPaymentAllocationTransactionTypes; private Collection delinquencyBucketOptions; @@ -132,6 +134,7 @@ public WorkingCapitalLoanProductData applyTemplate(final WorkingCapitalLoanProdu setDelinquencyBucketOptions(productTemplate.getDelinquencyBucketOptions()); setBreachOptions(productTemplate.getBreachOptions()); setDelinquencyStartTypeOptions(productTemplate.getDelinquencyStartTypeOptions()); + setBreachStartTypeOptions(productTemplate.getBreachStartTypeOptions()); setAccountingRuleOptions(productTemplate.getAccountingRuleOptions()); setAccountingMappingOptions(productTemplate.getAccountingMappingOptions()); setPaymentTypeOptions(productTemplate.getPaymentTypeOptions()); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanBreachStartType.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanBreachStartType.java new file mode 100644 index 00000000000..4266d966452 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanBreachStartType.java @@ -0,0 +1,63 @@ +/** + * 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.workingcapitalloanproduct.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.ApiFacingEnum; +import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; +import org.springframework.util.StringUtils; + +/** + * Breach start types for Working Capital Loan Product. + */ +@Getter +@RequiredArgsConstructor +public enum WorkingCapitalLoanBreachStartType implements ApiFacingEnum { + + LOAN_CREATION(1, "LOAN_CREATION", "Loan Creation"), // + DISBURSEMENT(2, "DISBURSEMENT", "Disbursement") // + ; + + private final Integer value; + private final String code; + private final String humanReadableName; + + /** + * Resolve enum from its string name/code (e.g. "LOAN_CREATION", "DISBURSEMENT"). + */ + public static WorkingCapitalLoanBreachStartType fromString(final String breachStartTypeValue) { + if (!StringUtils.hasText(breachStartTypeValue)) { + return null; + } + + if (breachStartTypeValue.trim().equalsIgnoreCase(LOAN_CREATION.name())) { + return LOAN_CREATION; + } + if (breachStartTypeValue.trim().equalsIgnoreCase(DISBURSEMENT.name())) { + return DISBURSEMENT; + } + + return null; + } + + public StringEnumOptionData toStringEnumOptionData() { + return new StringEnumOptionData(getValue().toString(), getCode(), getHumanReadableName()); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java index 81a5260963b..64dfb11d770 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java @@ -74,4 +74,8 @@ public class WorkingCapitalLoanProductRelatedDetail { @Column(name = "breach_grace_days", nullable = true) private Integer breachGraceDays; + + @Enumerated(EnumType.STRING) + @Column(name = "breach_start_type", nullable = false) + private WorkingCapitalLoanBreachStartType breachStartType; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java index b8560720604..0e9c3ac3014 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java @@ -98,4 +98,8 @@ public class WorkingCapitalLoanProductRelatedDetails { @Column(name = "breach_grace_days", nullable = true) private Integer breachGraceDays; + + @Enumerated(EnumType.STRING) + @Column(name = "breach_start_type", nullable = false) + private WorkingCapitalLoanBreachStartType breachStartType; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java index 34c4ada78e1..b8e2d6f8939 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java @@ -37,6 +37,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.data.WorkingCapitalPaymentAllocationData; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAccountingRuleType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; @@ -73,6 +74,7 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "delinquencyGraceDays", source = "relatedDetail.delinquencyGraceDays") @Mapping(target = "delinquencyStartType", source = "relatedDetail.delinquencyStartType", qualifiedByName = "delinquencyStartTypeToStringEnumOptionData") @Mapping(target = "breachGraceDays", source = "relatedDetail.breachGraceDays") + @Mapping(target = "breachStartType", source = "relatedDetail.breachStartType", qualifiedByName = "breachStartTypeToStringEnumOptionData") @Mapping(target = "accountingRule", source = "accountingRule", qualifiedByName = "accountingRuleToStringEnumOptionData") @Mapping(target = "accountingMappings", ignore = true) @Mapping(target = "paymentChannelToFundSourceMappings", ignore = true) @@ -95,6 +97,7 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "applyTemplate", ignore = true) @Mapping(target = "delinquencyBucketOptions", ignore = true) @Mapping(target = "delinquencyStartTypeOptions", ignore = true) + @Mapping(target = "breachStartTypeOptions", ignore = true) @Mapping(target = "delinquencyMinimumPaymentTypeOptions", ignore = true) @Mapping(target = "nearBreachOptions", ignore = true) @Mapping(target = "chargeOffReasonOptions", ignore = true) @@ -138,6 +141,11 @@ default StringEnumOptionData delinquencyStartTypeToStringEnumOptionData( return delinquencyStartType != null ? delinquencyStartType.getValueAsStringEnumOptionData() : null; } + @Named("breachStartTypeToStringEnumOptionData") + default StringEnumOptionData breachStartTypeToStringEnumOptionData(final WorkingCapitalLoanBreachStartType breachStartType) { + return breachStartType != null ? breachStartType.getValueAsStringEnumOptionData() : null; + } + @Named("paymentAllocationRulesToData") default List paymentAllocationRulesToData( final List rules) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java index 325694f336c..c55c8ab9807 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java @@ -49,6 +49,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAccountingRuleType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAdvancedPaymentAllocationsJsonParser; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateExternalIdException; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateNameException; @@ -100,6 +101,7 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.delinquencyGraceDaysParamName, // WorkingCapitalLoanProductConstants.delinquencyStartTypeParamName, // WorkingCapitalLoanProductConstants.breachGraceDaysParamName, // + WorkingCapitalLoanProductConstants.breachStartTypeParamName, // WorkingCapitalLoanProductConstants.accountingRuleParamName, // WorkingCapitalLoanProductConstants.fundSourceAccountIdParamName, // WorkingCapitalLoanProductConstants.loanPortfolioAccountIdParamName, // @@ -407,6 +409,22 @@ private void validateSettingsFields(final JsonElement element, final DataValidat } } } + + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element)) { + final String breachStartTypeValue = this.fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.breachStartTypeParamName).value(breachStartTypeValue) + .ignoreIfNull(); + + if (breachStartTypeValue != null) { + final WorkingCapitalLoanBreachStartType breachStartType = WorkingCapitalLoanBreachStartType + .fromString(breachStartTypeValue); + if (breachStartType == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.breachStartTypeParamName) + .failWithCode("invalid.breach.start.type"); + } + } + } } private BigDecimal validateTermFields(final JsonElement element, final DataValidatorBuilder baseDataValidator, final boolean required) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java index 7e972845bad..09121e1a459 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java @@ -50,6 +50,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.data.WorkingCapitalLoanProductData; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAccountingRuleType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType; @@ -122,6 +123,8 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails .getValuesAsStringEnumOptionDataList(WorkingCapitalPaymentAllocationType.class); final List delinquencyStartTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanDelinquencyStartType.class); + final List breachStartTypeOptions = ApiFacingEnum + .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanBreachStartType.class); final List delinquencyMinimumPaymentTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(DelinquencyMinimumPaymentType.class); final List advancedPaymentAllocationTransactionTypes = PaymentAllocationTransactionType @@ -148,6 +151,7 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails .advancedPaymentAllocationTypes(advancedPaymentAllocationTypes) // .advancedPaymentAllocationTransactionTypes(advancedPaymentAllocationTransactionTypes) // .delinquencyStartTypeOptions(delinquencyStartTypeOptions) // + .breachStartTypeOptions(breachStartTypeOptions) // .delinquencyMinimumPaymentTypeOptions(delinquencyMinimumPaymentTypeOptions) // .delinquencyBucketOptions( delinquencyBucketOptions != null && !delinquencyBucketOptions.isEmpty() ? delinquencyBucketOptions : null) // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java index 6a886c1bba9..45c0d2fb19b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java @@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.WorkingCapitalLoanProductConstants; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; @@ -142,6 +143,16 @@ public Map updateRelatedDetail(final WorkingCapitalLoanProductRe changes.put(WorkingCapitalLoanProductConstants.delinquencyStartTypeParamName, newValue); relatedDetail.setDelinquencyStartType(WorkingCapitalLoanDelinquencyStartType.fromString(newValue)); } + final String currentBreachStartType = (relatedDetail.getBreachStartType() != null) ? relatedDetail.getBreachStartType().name() + : null; + if (command.isChangeInStringParameterNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName, currentBreachStartType)) { + final String newValue = command.stringValueOfParameterNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName); + final WorkingCapitalLoanBreachStartType parsedBreachStartType = WorkingCapitalLoanBreachStartType.fromString(newValue); + final WorkingCapitalLoanBreachStartType newBreachStartType = parsedBreachStartType != null ? parsedBreachStartType + : WorkingCapitalLoanBreachStartType.DISBURSEMENT; + changes.put(WorkingCapitalLoanProductConstants.breachStartTypeParamName, newBreachStartType.getCode()); + relatedDetail.setBreachStartType(newBreachStartType); + } return changes; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java index 806cd7c15e3..313a91d6849 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java @@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAccountingRuleType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAdvancedPaymentAllocationsJsonParser; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; @@ -404,10 +405,15 @@ private WorkingCapitalLoanProduct createProductFromCommand(final Fund fund, fina final Integer breachGraceDaysValue = command .integerValueOfParameterNamed(WorkingCapitalLoanProductConstants.breachGraceDaysParamName); final Integer breachGraceDays = breachGraceDaysValue != null ? breachGraceDaysValue : Integer.valueOf(0); + final String breachStartTypeValue = command + .stringValueOfParameterNamed(WorkingCapitalLoanProductConstants.breachStartTypeParamName); + final WorkingCapitalLoanBreachStartType parsedBreachStartType = WorkingCapitalLoanBreachStartType.fromString(breachStartTypeValue); + final WorkingCapitalLoanBreachStartType breachStartType = parsedBreachStartType != null ? parsedBreachStartType + : WorkingCapitalLoanBreachStartType.DISBURSEMENT; final WorkingCapitalLoanProductRelatedDetail relatedDetail = new WorkingCapitalLoanProductRelatedDetail(amortizationType, npvDayCount, principal, periodPaymentRate, repaymentEvery, repaymentFrequencyType, discount, delinquencyGraceDays, - delinquencyStartType, breachGraceDays); + delinquencyStartType, breachGraceDays, breachStartType); // Min/max constraints final BigDecimal minPrincipal = command.parameterExists(WorkingCapitalLoanProductConstants.minPrincipalParamName) diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index eef83d8525e..015b71c3ad8 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -79,4 +79,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0058_wc_breach_start_type.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0058_wc_breach_start_type.xml new file mode 100644 index 00000000000..2382cb31b83 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0058_wc_breach_start_type.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationValidationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationValidationTest.java index 6eba7e753f9..b57c2cac887 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationValidationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationValidationTest.java @@ -152,6 +152,23 @@ public void testSubmitWithMissingExpectedDisbursementDate() { productHelper.deleteWorkingCapitalLoanProductById(productId); } + @Test + public void testSubmitWithInvalidBreachStartType() { + final Long productId = createProduct(); + final Long clientId = createClient(); + final PostWorkingCapitalLoansRequest request = new WorkingCapitalLoanApplicationTestBuilder().withClientId(clientId) + .withProductId(productId).withPrincipal(BigDecimal.valueOf(5000)) + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) + .withTotalPaymentVolume(BigDecimal.valueOf(5500)).withBreachStartType("FOO").buildSubmitRequest(); + + final CallFailedRuntimeException ex = applicationHelper.runSubmitExpectingFailure(request); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("invalid.breach.start.type"), + "Expected invalid.breach.start.type error: " + ex.getDeveloperMessage()); + productHelper.deleteWorkingCapitalLoanProductById(productId); + } + @Test public void testSubmitWithNegativePrincipal() { final Long productId = createProduct(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java index 5c69c5cdd11..d988cb61b4b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java @@ -52,6 +52,7 @@ import org.apache.fineract.integrationtests.common.workingcapitalloannearbreach.WorkingCapitalNearBreachHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -618,6 +619,101 @@ public void testNegativeCreateWorkingCapitalLoanProductWithNearBreach() { assertThat(exception.getDeveloperMessage()).contains("near.breach.frequency.must.be.lower.than.breach.frequency"); } + @Test + public void testCreateAndRetrieveBreachStartType() { + final String uniqueName = "Test wcl Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final PostWorkingCapitalLoanProductsRequest request = new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withBreachStartType(WorkingCapitalLoanBreachStartType.LOAN_CREATION.getCode()).build(); + + // When + final Long productId = wclProductHelper.createWorkingCapitalLoanProduct(request).getResourceId(); + assertNotNull(productId); + + // Then - retrieve must round-trip the anchor + final GetWorkingCapitalLoanProductsProductIdResponse retrieved = wclProductHelper.retrieveWorkingCapitalLoanProductById(productId); + assertNotNull(retrieved.getBreachStartType(), "breachStartType should be present on the retrieved product"); + assertEquals("LOAN_CREATION", retrieved.getBreachStartType().getCode(), + "retrieved breachStartType.code should equal the configured LOAN_CREATION anchor"); + + wclProductHelper.deleteWorkingCapitalLoanProductById(productId); + } + + @Test + public void testBreachStartTypeDefaultsToDisbursementWhenNotProvided() { + final String uniqueName = "Test wcl Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + // No breachStartType provided + final PostWorkingCapitalLoanProductsRequest request = new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).build(); + + // When + final Long productId = wclProductHelper.createWorkingCapitalLoanProduct(request).getResourceId(); + assertNotNull(productId); + + // Then - the stored value defaults to DISBURSEMENT rather than null + final GetWorkingCapitalLoanProductsProductIdResponse retrieved = wclProductHelper.retrieveWorkingCapitalLoanProductById(productId); + assertNotNull(retrieved.getBreachStartType(), "breachStartType should default to a value when not provided"); + assertEquals("DISBURSEMENT", retrieved.getBreachStartType().getCode(), + "breachStartType should default to DISBURSEMENT when not provided"); + + wclProductHelper.deleteWorkingCapitalLoanProductById(productId); + } + + @Test + public void testUpdateBreachStartType() { + final String uniqueName = "Test wcl Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final PostWorkingCapitalLoanProductsRequest createRequest = new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).build(); + final Long productId = wclProductHelper.createWorkingCapitalLoanProduct(createRequest).getResourceId(); + + // When - update the breach start-date-type anchor to DISBURSEMENT + final PutWorkingCapitalLoanProductsProductIdRequest updateRequest = new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withBreachStartType(WorkingCapitalLoanBreachStartType.DISBURSEMENT.getCode()) + .buildUpdateRequest(); + final PutWorkingCapitalLoanProductsProductIdResponse updateResponse = wclProductHelper + .updateWorkingCapitalLoanProductById(productId, updateRequest); + + // Then - update succeeds and the new anchor round-trips on retrieve + assertNotNull(updateResponse); + assertEquals(productId, updateResponse.getResourceId()); + final GetWorkingCapitalLoanProductsProductIdResponse retrieved = wclProductHelper.retrieveWorkingCapitalLoanProductById(productId); + assertNotNull(retrieved.getBreachStartType(), "breachStartType should be present after update"); + assertEquals("DISBURSEMENT", retrieved.getBreachStartType().getCode(), + "retrieved breachStartType.code should equal the updated DISBURSEMENT anchor"); + + wclProductHelper.deleteWorkingCapitalLoanProductById(productId); + } + + @Test + public void testInvalidBreachStartTypeRejected() { + final String uniqueName = "Test wcl Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final PostWorkingCapitalLoanProductsRequest request = new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withBreachStartType("FOO").build(); + + // When + final CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, + () -> wclProductHelper.createWorkingCapitalLoanProduct(request)); + + // Then + assertThat(exception.getStatus()).isEqualTo(400); + assertThat(exception.getDeveloperMessage()).contains("invalid.breach.start.type"); + } + + @Test + public void testTemplateExposesBreachStartTypeOptions() { + // When + final GetWorkingCapitalLoanProductsTemplateResponse response = wclProductHelper.retrieveTemplate(); + + // Then + assertNotNull(response.getBreachStartTypeOptions(), "template should expose a breachStartTypeOptions list"); + final List optionCodes = response.getBreachStartTypeOptions().stream().map(StringEnumOptionData::getCode).toList(); + assertTrue(optionCodes.containsAll(List.of("LOAN_CREATION", "DISBURSEMENT")), + "breachStartTypeOptions should contain LOAN_CREATION and DISBURSEMENT; actual: " + optionCodes); + } + private WorkingCapitalBreachData getBreachData(final GetWorkingCapitalLoanProductsTemplateResponse template) { WorkingCapitalBreachData breach = null; if (template.getBreachOptions() != null && !template.getBreachOptions().isEmpty()) { 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 afb6d7304e1..5b109915a77 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 @@ -136,6 +136,56 @@ public void testDelinquencyStartDateUsesLoanCreationDateWhenConfigured() { }); } + // Breach anchor = LOAN_CREATION -> breachStartDate must be submittedOnDate (2025-12-20) + breachGraceDays (5) = + // 2025-12-25 (rather than anchoring on the disbursement date 2026-01-01). + @Test + public void testBreachStartDateUsesLoanCreationDateWhenConfigured() { + AtomicLong loanIdRef = new AtomicLong(); + + // given - a WC loan submitted on 2025-12-20 but disbursed on 2026-01-01, with breachStartType = LOAN_CREATION + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoanWithBreachStartType(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); + + // breachStartDate must anchor on the loan submitted-on (creation) date, not the disbursement date, plus the + // breachGraceDays. + assertEquals(SUBMITTED_ON_DATE.plusDays(BREACH_GRACE_DAYS), response.getBreachStartDate(), + "breachStartDate should anchor on submittedOnDate + breachGraceDays when breachStartType = LOAN_CREATION"); + }); + } + + // Breach anchor = DISBURSEMENT (explicit) -> breachStartDate must be disbursementDate (2026-01-01) + + // breachGraceDays + // (5) = 2026-01-06. + @Test + public void testBreachStartDateUsesDisbursementDateWhenConfigured() { + AtomicLong loanIdRef = new AtomicLong(); + + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdRef.set(createDisbursedLoanWithBreachStartType(null, "DISBURSEMENT")); + }); + + 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.plusDays(BREACH_GRACE_DAYS), response.getBreachStartDate(), + "breachStartDate should anchor on disbursementDate + breachGraceDays when breachStartType = DISBURSEMENT"); + }); + } + @Test public void testStartDatesAreNullForHealthyLoan() { BusinessDateHelper.runAt("01 January 2026", () -> { @@ -209,6 +259,53 @@ private Long createDisbursedLoan(final LocalDate submittedOnDate, final String d return loanId; } + /** + * Mirrors {@link #createDisbursedLoan(LocalDate, String)} but configures the product with an explicit breach + * start-date-type anchor. + */ + private Long createDisbursedLoanWithBreachStartType(final LocalDate submittedOnDate, final String breachStartType) { + final List rangeIds = createDelinquencyRanges(); + final PostDelinquencyBucketResponse bucketResponse = WorkingCapitalLoanDelinquencyRangeScheduleHelper + .createWorkingCapitalLoanDelinquencyBucket(rangeIds, DELINQUENCY_FREQUENCY_DAYS, 0, DELINQUENCY_MIN_PAYMENT_PERCENT, 1); + assertNotNull(bucketResponse); + + 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); + + 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) // + .withBreachStartType(breachStartType) // + .build()).getResourceId(); + assertNotNull(productId); + + 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); + + loanHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveRequest(DISBURSEMENT_DATE, PRINCIPAL, null)); + loanHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildDisburseRequest(DISBURSEMENT_DATE, PRINCIPAL)); + log.info("Created disbursed WC loan {} with breachStartType={} for start-date validation", loanId, breachStartType); + return loanId; + } + private List createDelinquencyRanges() { final PostDelinquencyRangeResponse range1 = DelinquencyRangesHelper.createRange(new DelinquencyRangeRequest() .classification(Utils.randomStringGenerator("DLQ_R_", 10)).minimumAgeDays(1).maximumAgeDays(30).locale("en")); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java index 3b629ee639d..ef90c77b237 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java @@ -60,6 +60,7 @@ public class WorkingCapitalLoanApplicationTestBuilder { private Integer delinquencyGraceDays; private String delinquencyStartType; private Integer breachGraceDays; + private String breachStartType; private List originators; public WorkingCapitalLoanApplicationTestBuilder withClientId(final Long clientId) { @@ -162,6 +163,11 @@ public WorkingCapitalLoanApplicationTestBuilder withBreachGraceDays(final Intege return this; } + public WorkingCapitalLoanApplicationTestBuilder withBreachStartType(final String breachStartType) { + this.breachStartType = breachStartType; + return this; + } + public WorkingCapitalLoanApplicationTestBuilder withPaymentAllocationTypes(final List paymentAllocationTypes) { this.paymentAllocationTypes = paymentAllocationTypes; return this; @@ -222,6 +228,9 @@ private PostWorkingCapitalLoansRequest populateSubmitRequest(final PostWorkingCa if (breachGraceDays != null) { request.breachGraceDays(breachGraceDays); } + if (breachStartType != null) { + request.breachStartType(breachStartType); + } if (breachId != null) { request.breachId(breachId); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java index 26a4a950cb3..2c0d95cecdf 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java @@ -80,6 +80,7 @@ public class WorkingCapitalLoanProductTestBuilder { private Integer delinquencyGraceDays; private String delinquencyStartType; private Integer breachGraceDays; + private String breachStartType; private AccountingRuleEnum accountingRule = DEFAULT_ACCOUNTING_RULE; private Long nearBreachId; @@ -243,6 +244,11 @@ public WorkingCapitalLoanProductTestBuilder withBreachGraceDays(final Integer br return this; } + public WorkingCapitalLoanProductTestBuilder withBreachStartType(final String breachStartType) { + this.breachStartType = breachStartType; + return this; + } + public WorkingCapitalLoanProductTestBuilder withAccountingRule(final AccountingRuleEnum accountingRule) { this.accountingRule = accountingRule; return this; @@ -353,6 +359,7 @@ private void populateCommonFields(final PostWorkingCapitalLoanProductsRequest re request.setDelinquencyGraceDays(this.delinquencyGraceDays); request.setDelinquencyStartType(this.delinquencyStartType); request.setBreachGraceDays(this.breachGraceDays); + request.setBreachStartType(this.breachStartType); request.setBreachId(this.breachId); request.setAccountingRule(this.accountingRule); request.setNearBreachId(this.nearBreachId); @@ -400,6 +407,7 @@ private void populateCommonFields(final PutWorkingCapitalLoanProductsProductIdRe request.setDelinquencyGraceDays(this.delinquencyGraceDays); request.setDelinquencyStartType(this.delinquencyStartType); request.setBreachGraceDays(this.breachGraceDays); + request.setBreachStartType(this.breachStartType); request.setBreachId(this.breachId); if (this.accountingRule != null) { request.setAccountingRule(PutWorkingCapitalLoanProductsProductIdRequest.AccountingRuleEnum.valueOf(this.accountingRule.name())); From eee05b389ff9355fddbc8b7de51b267bcc003527 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Wed, 22 Jul 2026 13:08:46 +0200 Subject: [PATCH 2/2] FINERACT-2455: added e2e tests for validating working capital breach start date type --- .../WorkingCapitalLoanAccountStepDef.java | 14 +- .../stepdef/loan/WorkingCapitalStepDef.java | 11 +- .../WorkingCapitalBreachStartDateType.feature | 167 ++++++++++++++++++ 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature 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 70269a9ad64..ff84da379f4 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 @@ -206,15 +206,20 @@ public void createWorkingCapitalLoanWithFund(final DataTable table) { @When("Admin creates a working capital loan using created product with the following data:") public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) { - submitLoanUsingCreatedProduct(table, null); + submitLoanUsingCreatedProduct(table, null, null); } @When("Admin creates a working capital loan using created product with breachGraceDays {int} and the following data:") public void createWorkingCapitalLoanUsingCreatedProductWithBreachGraceDays(final int breachGraceDays, final DataTable table) { - submitLoanUsingCreatedProduct(table, breachGraceDays); + submitLoanUsingCreatedProduct(table, breachGraceDays, null); } - private void submitLoanUsingCreatedProduct(final DataTable table, final Integer breachGraceDays) { + @When("Admin creates a working capital loan using created product with breachStartType {string} and the following data:") + public void createWorkingCapitalLoanUsingCreatedProductWithBreachStartType(final String breachStartType, final DataTable table) { + submitLoanUsingCreatedProduct(table, null, breachStartType); + } + + private void submitLoanUsingCreatedProduct(final DataTable table, final Integer breachGraceDays, final String breachStartType) { final List> data = table.asLists(); final List rawData = data.get(1); final Long clientId = extractClientId(); @@ -237,6 +242,9 @@ private void submitLoanUsingCreatedProduct(final DataTable table, final Integer if (breachGraceDays != null) { loansRequest.breachGraceDays(breachGraceDays); } + if (breachStartType != null) { + loansRequest.breachStartType(breachStartType); + } testContext().set(TestContextKey.LOAN_CREATE_REQUEST, loansRequest); final PostWorkingCapitalLoansResponse response = ok( diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java index 047e23e01b2..62abe8c4538 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java @@ -122,6 +122,7 @@ public class WorkingCapitalStepDef extends AbstractStepDef { public static final String DELINQUENCY_START_TYPE_FIELD_NAME = "delinquencyStartType"; public static final String BREACH_GRACE_DAYS_FIELD_NAME = "breachGraceDays"; public static final String BREACH_ID_FIELD_NAME = "breachId"; + public static final String BREACH_START_TYPE_FIELD_NAME = "breachStartType"; public static final String NEAR_BREACH_ID_FIELD_NAME = "nearBreachId"; public static final String LOCALE_FIELD_NAME = "locale"; @@ -343,6 +344,8 @@ public void createWorkingCapitalLoanProductWithCustomBreachConfig(final DataTabl final String breachGraceDaysStr = data.get(BREACH_GRACE_DAYS_FIELD_NAME); final Integer breachGraceDays = breachGraceDaysStr != null && !breachGraceDaysStr.isEmpty() ? Integer.valueOf(breachGraceDaysStr) : null; + final String breachStartTypeStr = data.get(BREACH_START_TYPE_FIELD_NAME); + final String breachStartType = breachStartTypeStr != null && !breachStartTypeStr.isEmpty() ? breachStartTypeStr : null; final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory @@ -350,7 +353,8 @@ public void createWorkingCapitalLoanProductWithCustomBreachConfig(final DataTabl .name(name) // .breachId(breachId) // .delinquencyGraceDays(graceDays) // - .breachGraceDays(breachGraceDays); + .breachGraceDays(breachGraceDays) // + .breachStartType(breachStartType); final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); @@ -387,6 +391,8 @@ public void createWorkingCapitalLoanProductWithBreachAndNearBreachConfig(final D final String breachGraceDaysStr = data.get(BREACH_GRACE_DAYS_FIELD_NAME); final Integer breachGraceDays = breachGraceDaysStr != null && !breachGraceDaysStr.isEmpty() ? Integer.valueOf(breachGraceDaysStr) : null; + final String breachStartTypeStr = data.get(BREACH_START_TYPE_FIELD_NAME); + final String breachStartType = breachStartTypeStr != null && !breachStartTypeStr.isEmpty() ? breachStartTypeStr : null; final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory @@ -395,7 +401,8 @@ public void createWorkingCapitalLoanProductWithBreachAndNearBreachConfig(final D .breachId(breachId) // .nearBreachId(nearBreachId) // .delinquencyGraceDays(graceDays) // - .breachGraceDays(breachGraceDays); + .breachGraceDays(breachGraceDays) // + .breachStartType(breachStartType); final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature new file mode 100644 index 00000000000..7bfb13f904d --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature @@ -0,0 +1,167 @@ +@WorkingCapital +@WorkingCapitalBreachStartDateTypeFeature +Feature: Working Capital Breach Start Date Type + + @TestRailId:C89771 + Scenario: Verify breach start date type - UC1: LOAN_CREATION anchor with same-day disbursement matches the DISBURSEMENT baseline + 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 | + | 3 | DAYS | FLAT | 100 | 0 | 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 | 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 + Then Working capital loan details has the following field values: + | breachStartType.code | LOAN_CREATION | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-03 | 3 | 100 | 100 | null | null | + + @TestRailId:C89772 + Scenario: Verify breach start date type - UC2: LOAN_CREATION anchor with late disbursement backfills and breaches pre-disbursement periods + 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 | + | 3 | DAYS | FLAT | 100 | 0 | 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 | 10 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 "10 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin successfully disburse the Working Capital loan on "10 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-03 | 3 | 100 | 100 | null | true | + | 2 | 2026-01-04 | 2026-01-06 | 3 | 100 | 100 | null | true | + | 3 | 2026-01-07 | 2026-01-09 | 3 | 100 | 100 | null | true | + | 4 | 2026-01-10 | 2026-01-12 | 3 | 100 | 100 | null | null | + And Working capital loan account has the correct data: + | breachStartDate | + | 2026-01-01 | + And Working capital loan details has the following field values: + | breachStartType.code | LOAN_CREATION | + + @TestRailId:C89773 + Scenario: Verify breach start date type - UC6: submitted but never disbursed LOAN_CREATION loan gets no breach schedule and never breaches + 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 | + | 3 | DAYS | FLAT | 100 | 0 | 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 | 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 sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has no data + + @TestRailId:C89774 + Scenario: Verify breach start date type - UC7: pause inside the pre-disbursement breach window is accepted when the schedule covers those dates + 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 | + | 3 | DAYS | FLAT | 100 | 0 | 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 | 10 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 "10 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin successfully disburse the Working Capital loan on "10 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-03 | 3 | 100 | 100 | null | true | + | 2 | 2026-01-04 | 2026-01-06 | 3 | 100 | 100 | null | true | + | 3 | 2026-01-07 | 2026-01-09 | 3 | 100 | 100 | null | true | + | 4 | 2026-01-10 | 2026-01-12 | 3 | 100 | 100 | null | null | + And Admin initiate a Working Capital loan breach pause with startDate "05 January 2026" and endDate "07 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-05 | 2026-01-07 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-03 | 3 | 100 | 100 | null | true | + | 2 | 2026-01-04 | 2026-01-09 | 6 | 100 | 100 | null | true | + | 3 | 2026-01-10 | 2026-01-12 | 3 | 100 | 100 | null | null | + | 4 | 2026-01-13 | 2026-01-15 | 3 | 100 | 100 | null | null | + + @TestRailId:C89775 + Scenario: Verify breach start date type - UC3: breachGraceDays shift the LOAN_CREATION anchor, not the disbursement 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 | + | 3 | DAYS | FLAT | 100 | 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 | 10 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 "10 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin successfully disburse the Working Capital loan on "10 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # Schedule starts at submittedOnDate 01 Jan + 5 grace days = 06 Jan (DISBURSEMENT anchor would start it on 15 Jan) + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-06 | 2026-01-08 | 3 | 100 | 100 | null | true | + | 2 | 2026-01-09 | 2026-01-11 | 3 | 100 | 100 | null | null | + And Working capital loan account has the correct data: + | breachStartDate | + | 2026-01-06 | + + @TestRailId:C89776 + Scenario: Verify breach start date type - UC4: loan-level DISBURSEMENT override wins over LOAN_CREATION product default + 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 | + | 3 | DAYS | FLAT | 100 | 0 | LOAN_CREATION | + And Admin creates a working capital loan using created product with breachStartType "DISBURSEMENT" and the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 10 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 "10 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin successfully disburse the Working Capital loan on "10 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # The loan-level DISBURSEMENT override anchors the schedule on 10 Jan - no retroactive periods, nothing breached + Then Working capital loan details has the following field values: + | breachStartType.code | DISBURSEMENT | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-10 | 2026-01-12 | 3 | 100 | 100 | null | null | + And Working capital loan account has the correct data: + | breachStartDate | + | null | + + @TestRailId:C89777 + Scenario: Verify breach start date type - UC5: loan-level LOAN_CREATION override wins over explicit DISBURSEMENT product default + 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 | + | 3 | DAYS | FLAT | 100 | 0 | DISBURSEMENT | + And Admin creates a working capital loan using created product with breachStartType "LOAN_CREATION" and the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 10 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 "10 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin successfully disburse the Working Capital loan on "10 January 2026" with "9000" EUR transaction amount + 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 breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-03 | 3 | 100 | 100 | null | true | + | 2 | 2026-01-04 | 2026-01-06 | 3 | 100 | 100 | null | true | + | 3 | 2026-01-07 | 2026-01-09 | 3 | 100 | 100 | null | true | + | 4 | 2026-01-10 | 2026-01-12 | 3 | 100 | 100 | null | null | + And Working capital loan account has the correct data: + | breachStartDate | + | 2026-01-01 |