From 7adf1231cd73ef2d8878e7f3b710aa45068a306c Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Wed, 1 Apr 2026 13:53:06 +0300 Subject: [PATCH 1/2] FINERACT-2455: WC delinquency RESCHEDULE action to change minimum payment amount and frequency --- ...ngCapitalDelinquencyRescheduleStepDef.java | 182 ++++++++++++++++++ .../WorkingCapitalLoanAccountStepDef.java | 14 ++ ...orkingCapitalDelinquencyReschedule.feature | 129 +++++++++++++ .../delinquency/domain/DelinquencyAction.java | 3 +- ...pitalLoanDelinquencyActionApiResource.java | 4 +- ...anDelinquencyActionApiResourceSwagger.java | 13 +- ...rkingCapitalLoanDelinquencyActionData.java | 5 + .../WorkingCapitalLoanDelinquencyAction.java | 14 +- ...apitalLoanDelinquencyActionRepository.java | 5 + ...lLoanDelinquencyActionReadServiceImpl.java | 3 +- ...LoanDelinquencyActionWriteServiceImpl.java | 7 +- ...alLoanDelinquencyRangeScheduleService.java | 3 + ...anDelinquencyRangeScheduleServiceImpl.java | 120 ++++++++++-- ...oanDelinquencyActionParseAndValidator.java | 144 +++++++++++--- .../module-changelog-master.xml | 1 + ..._wc_loan_delinquency_action_reschedule.xml | 48 +++++ ...ingCapitalLoanDelinquencyActionHelper.java | 15 ++ 17 files changed, 664 insertions(+), 46 deletions(-) create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0019_wc_loan_delinquency_action_reschedule.xml diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java new file mode 100644 index 00000000000..fc8da2dd37c --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java @@ -0,0 +1,182 @@ +/** + * 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.test.stepdef.loan; + +import static org.apache.fineract.client.feign.util.FeignCalls.fail; +import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.assertj.core.api.Assertions.assertThat; + +import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.DelinquencyBucketRequest; +import org.apache.fineract.client.models.MinimumPaymentPeriodAndRule; +import org.apache.fineract.client.models.PostAllowAttributeOverrides; +import org.apache.fineract.client.models.PostDelinquencyBucketResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoansDelinquencyActionRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansDelinquencyActionResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse; +import org.apache.fineract.client.models.WorkingCapitalLoanDelinquencyActionData; +import org.apache.fineract.client.models.WorkingCapitalLoanDelinquencyRangeScheduleData; +import org.apache.fineract.test.factory.WorkingCapitalRequestFactory; +import org.apache.fineract.test.helper.Utils; +import org.apache.fineract.test.stepdef.AbstractStepDef; +import org.apache.fineract.test.support.TestContext; +import org.apache.fineract.test.support.TestContextKey; + +@Slf4j +@RequiredArgsConstructor +public class WorkingCapitalDelinquencyRescheduleStepDef extends AbstractStepDef { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + + private final FineractFeignClient fineractFeignClient; + + private final WorkingCapitalRequestFactory workingCapitalRequestFactory; + + @When("Admin creates a new Working Capital Loan Product with delinquency bucket") + public void createProductWithDelinquencyBucket() { + final Long bucketId = TestContext.GLOBAL.get(TestContextKey.DELINQUENCY_BUCKET_ID); + assertThat(bucketId).isNotNull(); + + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest() + .name("WCLP-DLQ-" + Utils.randomStringGenerator(8)).delinquencyBucketId(bucketId) + .allowAttributeOverrides(new PostAllowAttributeOverrides().discountDefault(true)); + final PostWorkingCapitalLoanProductsResponse response = ok( + () -> fineractFeignClient.workingCapitalLoanProducts().createWorkingCapitalLoanProduct(request)); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + log.info("Created WC product id={} with delinquency bucket id={}", response.getResourceId(), bucketId); + } + + @When("Admin creates WC Delinquency Bucket with frequency {int} {word} and minimumPayment {int} {word}") + public void createWcDelinquencyBucket(final int frequency, final String frequencyType, final int minimumPayment, + final String minimumPaymentType) { + final DelinquencyBucketRequest request = new DelinquencyBucketRequest().name("DB-WCL-" + Utils.randomStringGenerator(12)) + .bucketType("WORKING_CAPITAL").ranges(List.of(1L)) + .minimumPaymentPeriodAndRule(new MinimumPaymentPeriodAndRule().frequency(frequency).frequencyType(frequencyType) + .minimumPayment(new BigDecimal(minimumPayment)).minimumPaymentType(minimumPaymentType)); + + final PostDelinquencyBucketResponse result = ok( + () -> fineractFeignClient.delinquencyRangeAndBucketsManagement().createBucket(request)); + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isNotNull(); + TestContext.GLOBAL.set(TestContextKey.DELINQUENCY_BUCKET_ID, result.getResourceId()); + log.info("Created WC delinquency bucket id={} with frequency={} {} minimumPayment={} {}", result.getResourceId(), frequency, + frequencyType, minimumPayment, minimumPaymentType); + } + + @When("Admin creates WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word}") + public void createRescheduleAction(final int minimumPayment, final int frequency, final String frequencyType) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), frequency, + frequencyType); + log.info("Creating RESCHEDULE action for WC loan {}: minimumPayment={}, frequency={} {}", loanId, minimumPayment, frequency, + frequencyType); + + final PostWorkingCapitalLoansDelinquencyActionResponse result = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isNotNull(); + log.info("RESCHEDULE action created with id={}", result.getResourceId()); + } + + @Then("Admin fails to create WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word}") + public void failToCreateRescheduleAction(final int minimumPayment, final int frequency, final String frequencyType) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), frequency, + frequencyType); + log.info("Attempting to create RESCHEDULE action for WC loan {} (expecting failure): minimumPayment={}, frequency={} {}", loanId, + minimumPayment, frequency, frequencyType); + + fail(() -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + } + + @Then("WC loan delinquency range schedule has the following periods:") + public void verifyPeriods(final DataTable table) { + final Long loanId = getLoanId(); + final List periods = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyRangeSchedule().retrieveDelinquencyRangeSchedule(loanId)); + + final List> expectedRows = table.asMaps(); + assertThat(periods).as("Number of periods").hasSize(expectedRows.size()); + + for (int i = 0; i < expectedRows.size(); i++) { + final Map expected = expectedRows.get(i); + final WorkingCapitalLoanDelinquencyRangeScheduleData actual = periods.get(i); + final String periodLabel = "Period " + (i + 1); + + assertThat(actual.getPeriodNumber()).as(periodLabel + " periodNumber") + .isEqualTo(Integer.parseInt(expected.get("periodNumber"))); + assertThat(actual.getFromDate()).as(periodLabel + " fromDate") + .isEqualTo(LocalDate.parse(expected.get("fromDate"), DATE_FORMAT)); + assertThat(actual.getToDate()).as(periodLabel + " toDate").isEqualTo(LocalDate.parse(expected.get("toDate"), DATE_FORMAT)); + assertThat(actual.getExpectedAmount()).as(periodLabel + " expectedAmount") + .isEqualByComparingTo(new BigDecimal(expected.get("expectedAmount"))); + assertThat(actual.getPaidAmount()).as(periodLabel + " paidAmount") + .isEqualByComparingTo(new BigDecimal(expected.get("paidAmount"))); + assertThat(actual.getOutstandingAmount()).as(periodLabel + " outstandingAmount") + .isEqualByComparingTo(new BigDecimal(expected.get("outstandingAmount"))); + + final String criteriaMetStr = expected.get("minPaymentCriteriaMet"); + if (criteriaMetStr == null || criteriaMetStr.isBlank()) { + assertThat(actual.getMinPaymentCriteriaMet()).as(periodLabel + " minPaymentCriteriaMet").isNull(); + } else { + assertThat(actual.getMinPaymentCriteriaMet()).as(periodLabel + " minPaymentCriteriaMet") + .isEqualTo(Boolean.parseBoolean(criteriaMetStr)); + } + } + } + + @Then("WC loan delinquency actions contain {int} action(s)") + public void verifyActionCount(final int count) { + final Long loanId = getLoanId(); + final List actions = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().retrieveDelinquencyActions(loanId)); + + assertThat(actions).hasSize(count); + } + + private Long getLoanId() { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + assertThat(loanResponse).isNotNull(); + return loanResponse.getLoanId(); + } + + private PostWorkingCapitalLoansDelinquencyActionRequest buildRescheduleRequest(final BigDecimal minimumPayment, final int frequency, + final String frequencyType) { + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setMinimumPayment(minimumPayment); + request.setFrequency(frequency); + request.setFrequencyType(frequencyType); + request.setLocale("en"); + return request; + } +} 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 ac4ca72b3f7..0adc6ca5d31 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 @@ -757,9 +757,18 @@ private void createWorkingCapitalLoanAccount(final List loanData) { final PostWorkingCapitalLoansResponse response = ok( () -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(loansRequest)); testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + trackLoanIdIfEnabled(response.getLoanId()); log.info("Working Capital Loan created with ID: {}", response.getLoanId()); } + @SuppressWarnings("unchecked") + private void trackLoanIdIfEnabled(final Long loanId) { + final List trackedIds = testContext().get(TestContextKey.WC_LOAN_IDS); + if (trackedIds != null) { + trackedIds.add(loanId); + } + } + private void modifyWorkingCapitalLoanAccount(final List loanData) { final PutWorkingCapitalLoansLoanIdRequest modifyRequest = buildModifyLoanRequest(loanData); @@ -839,6 +848,11 @@ private Long extractClientId() { } private Long resolveLoanProductId(final String loanProductName) { + if ("WCLP_DELINQUENCY".equals(loanProductName)) { + final PostWorkingCapitalLoanProductsResponse response = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + return response.getResourceId(); + } final DefaultWorkingCapitalLoanProduct product = DefaultWorkingCapitalLoanProduct.valueOf(loanProductName); return workingCapitalLoanProductResolver.resolve(product); } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature new file mode 100644 index 00000000000..da7ccdff9df --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -0,0 +1,129 @@ +@WCCOBFeature +Feature: Working Capital Delinquency Reschedule Action + + Scenario: Reschedule changes minimumPayment only + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 300 | 0 | 300 | | + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 100 | 0 | 100 | false | + | 7 | 30 June 2026 | 29 July 2026 | 100 | 0 | 100 | false | + | 8 | 30 July 2026 | 28 August 2026 | 100 | 0 | 100 | | + + Scenario: Reschedule changes frequency only + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 3 and frequency 15 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 300 | 0 | 300 | false | + | 7 | 30 June 2026 | 14 July 2026 | 300 | 0 | 300 | false | + | 8 | 15 July 2026 | 29 July 2026 | 300 | 0 | 300 | false | + | 9 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | + | 10 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | + + Scenario: Reschedule changes minimumPayment and frequency + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 15 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | false | + | 7 | 30 June 2026 | 14 July 2026 | 200 | 0 | 200 | false | + | 8 | 15 July 2026 | 29 July 2026 | 200 | 0 | 200 | false | + | 9 | 30 July 2026 | 13 August 2026 | 200 | 0 | 200 | false | + | 10 | 14 August 2026 | 28 August 2026 | 200 | 0 | 200 | | + + Scenario: Multiple reschedules - last one wins + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 30 DAYS + When Admin creates WC delinquency reschedule action with minimumPayment 5 and frequency 30 DAYS + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 500 | 0 | 500 | | + Then WC loan delinquency actions contain 2 actions + + Scenario: Reschedule on non-active loan and validation errors are rejected + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + Then Admin fails to create WC delinquency reschedule action with minimumPayment 0 and frequency 30 DAYS + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 0 DAYS + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 INVALID diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java index 40cea6647ee..b2192bf8410 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java @@ -20,5 +20,6 @@ public enum DelinquencyAction { PAUSE, // - RESUME // + RESUME, // + RESCHEDULE // } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java index 3b7e734f93e..cf17b5724be 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java @@ -66,7 +66,7 @@ public class WorkingCapitalLoanDelinquencyActionApiResource { @Path("{loanId}/delinquency-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Create Delinquency Pause Action", description = "Creates a delinquency pause action for a Working Capital loan, extending the active delinquency range period and shifting future periods by the pause duration.") + @Operation(summary = "Create Delinquency Action", description = "Creates a delinquency action (pause or reschedule) for a Working Capital loan.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionResponse.class))), @@ -86,7 +86,7 @@ public CommandProcessingResult createDelinquencyAction(@PathParam("loanId") @Par @Path("external-id/{loanExternalId}/delinquency-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "createDelinquencyActionByExternalId", summary = "Create Delinquency Pause Action by external id", description = "Creates a delinquency pause action for a Working Capital loan identified by external id, extending the active delinquency range period and shifting future periods by the pause duration.") + @Operation(operationId = "createDelinquencyActionByExternalId", summary = "Create Delinquency Action by external id", description = "Creates a delinquency action (pause or reschedule) for a Working Capital loan identified by external id.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionResponse.class))), diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java index 043d8fc5209..e740c81768f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.workingcapitalloan.api; import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; public final class WorkingCapitalLoanDelinquencyActionApiResourceSwagger { @@ -29,12 +30,18 @@ public static final class PostWorkingCapitalLoansDelinquencyActionRequest { private PostWorkingCapitalLoansDelinquencyActionRequest() {} - @Schema(example = "pause") + @Schema(example = "pause", description = "Delinquency action type: pause or reschedule") public String action; - @Schema(example = "2026-03-05") + @Schema(example = "2026-03-05", description = "Start date of the pause period (required for pause)") public String startDate; - @Schema(example = "2026-03-12") + @Schema(example = "2026-03-12", description = "End date of the pause period (required for pause)") public String endDate; + @Schema(example = "2", description = "Minimum payment percentage (required for reschedule)") + public BigDecimal minimumPayment; + @Schema(example = "30", description = "Frequency value (required for reschedule)") + public Integer frequency; + @Schema(example = "DAYS", description = "Frequency type: DAYS, WEEKS, MONTHS, YEARS (required for reschedule)") + public String frequencyType; @Schema(example = "yyyy-MM-dd") public String dateFormat; @Schema(example = "en") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java index ce42f68a453..ed1a5974ae4 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java @@ -18,11 +18,13 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.data; +import java.math.BigDecimal; import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; @AllArgsConstructor @Getter @@ -33,5 +35,8 @@ public class WorkingCapitalLoanDelinquencyActionData { private DelinquencyAction action; private LocalDate startDate; private LocalDate endDate; + private BigDecimal minimumPayment; + private Integer frequency; + private DelinquencyFrequencyType frequencyType; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java index 9ff9bf5f70a..c3658847fff 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java @@ -26,12 +26,14 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.math.BigDecimal; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; @Getter @Setter @@ -51,7 +53,17 @@ public class WorkingCapitalLoanDelinquencyAction extends AbstractAuditableWithUT @Column(name = "start_date", nullable = false) private LocalDate startDate; - @Column(name = "end_date", nullable = false) + @Column(name = "end_date") private LocalDate endDate; + @Column(name = "minimum_payment", scale = 6, precision = 19) + private BigDecimal minimumPayment; + + @Column(name = "frequency") + private Integer frequency; + + @Enumerated(EnumType.STRING) + @Column(name = "frequency_type") + private DelinquencyFrequencyType frequencyType; + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java index 271518d908a..aab24c89763 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java @@ -19,6 +19,8 @@ package org.apache.fineract.portfolio.workingcapitalloan.repository; import java.util.List; +import java.util.Optional; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -28,4 +30,7 @@ public interface WorkingCapitalLoanDelinquencyActionRepository extends JpaReposi List findByWorkingCapitalLoanIdOrderById(Long workingCapitalLoanId); + Optional findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, + DelinquencyAction action); + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java index 6458a295f1e..f1783bd3f60 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java @@ -39,7 +39,8 @@ public List retrieveDelinquencyActions( } private WorkingCapitalLoanDelinquencyActionData toData(final WorkingCapitalLoanDelinquencyAction action) { - return new WorkingCapitalLoanDelinquencyActionData(action.getId(), action.getAction(), action.getStartDate(), action.getEndDate()); + return new WorkingCapitalLoanDelinquencyActionData(action.getId(), action.getAction(), action.getStartDate(), action.getEndDate(), + action.getMinimumPayment(), action.getFrequency(), action.getFrequencyType()); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java index 95692391512..ff28ac29980 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java @@ -24,6 +24,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; @@ -58,7 +59,11 @@ public CommandProcessingResult createDelinquencyAction(final Long workingCapital final WorkingCapitalLoanDelinquencyAction saved = actionRepository.saveAndFlush(action); log.debug("Created WC loan delinquency action {} for loan {}", action.getAction(), workingCapitalLoanId); - rangeScheduleService.extendPeriodsForPause(workingCapitalLoan, action.getStartDate(), action.getEndDate()); + if (DelinquencyAction.PAUSE.equals(action.getAction())) { + rangeScheduleService.extendPeriodsForPause(workingCapitalLoan, action.getStartDate(), action.getEndDate()); + } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { + rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); + } return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java index bdb819cbb21..600eb716968 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java @@ -23,6 +23,7 @@ import java.util.List; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; public interface WorkingCapitalLoanDelinquencyRangeScheduleService { @@ -40,4 +41,6 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleService { void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, LocalDate pauseEnd); + void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction rescheduleAction); + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index 6e23ecab3d6..9742a28360a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -21,21 +21,27 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentPeriodAndRule; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentType; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.springframework.stereotype.Service; @@ -46,6 +52,7 @@ public class WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl implements WorkingCapitalLoanDelinquencyRangeScheduleService { private final WorkingCapitalLoanDelinquencyRangeScheduleRepository repository; + private final WorkingCapitalLoanDelinquencyActionRepository actionRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleMapper mapper; @Override @@ -62,7 +69,7 @@ public void generateInitialPeriod(WorkingCapitalLoan loan) { return; } LocalDate toDate = calculateToDate(fromDate, rule.getFrequency(), rule.getFrequencyType()); - BigDecimal expectedAmount = calculateExpectedAmount(loan, rule); + BigDecimal expectedAmount = calculateExpectedAmount(loan, rule, null); WorkingCapitalLoanDelinquencyRangeSchedule period = new WorkingCapitalLoanDelinquencyRangeSchedule(); period.setLoan(loan); @@ -85,24 +92,30 @@ public boolean hasSchedule(Long loanId) { @Override public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate businessDate) { - DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); + final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); if (rule == null) { return; } - Optional latestPeriodOpt = repository + final Optional latestPeriodOpt = repository .findTopByLoanIdOrderByPeriodNumberDesc(loan.getId()); if (latestPeriodOpt.isEmpty()) { return; } + final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); + final Integer effectiveFrequency = latestReschedule.map(WorkingCapitalLoanDelinquencyAction::getFrequency) + .orElse(rule.getFrequency()); + final DelinquencyFrequencyType effectiveFreqType = latestReschedule.map(WorkingCapitalLoanDelinquencyAction::getFrequencyType) + .orElse(rule.getFrequencyType()); + WorkingCapitalLoanDelinquencyRangeSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { - LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); - LocalDate newToDate = calculateToDate(newFromDate, rule.getFrequency(), rule.getFrequencyType()); - BigDecimal expectedAmount = calculateExpectedAmount(loan, rule); + final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); + final LocalDate newToDate = calculateToDate(newFromDate, effectiveFrequency, effectiveFreqType); + final BigDecimal expectedAmount = calculateExpectedAmount(loan, rule, latestReschedule.orElse(null)); - WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = new WorkingCapitalLoanDelinquencyRangeSchedule(); + final WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = new WorkingCapitalLoanDelinquencyRangeSchedule(); nextPeriod.setLoan(loan); nextPeriod.setPeriodNumber(latestPeriod.getPeriodNumber() + 1); nextPeriod.setFromDate(newFromDate); @@ -175,26 +188,103 @@ private LocalDate calculateToDate(LocalDate fromDate, Integer frequency, Delinqu }; } - private BigDecimal calculateExpectedAmount(WorkingCapitalLoan loan, DelinquencyMinimumPaymentPeriodAndRule rule) { - BigDecimal minimumPayment = rule.getMinimumPayment(); + private BigDecimal calculateExpectedAmount(final WorkingCapitalLoan loan, final DelinquencyMinimumPaymentPeriodAndRule rule, + final WorkingCapitalLoanDelinquencyAction rescheduleOverride) { + final BigDecimal principal = loan.getApprovedPrincipal(); + if (principal == null) { + return BigDecimal.ZERO; + } + if (rescheduleOverride != null) { + return MathUtil.percentageOf(principal, rescheduleOverride.getMinimumPayment(), MathContext.DECIMAL128); + } + final BigDecimal minimumPayment = rule.getMinimumPayment(); if (minimumPayment == null) { return BigDecimal.ZERO; } if (DelinquencyMinimumPaymentType.FLAT.equals(rule.getMinimumPaymentType())) { return minimumPayment; } - BigDecimal principal = loan.getApprovedPrincipal(); - if (principal == null) { - return BigDecimal.ZERO; - } BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() : null; BigDecimal base = discount != null ? principal.add(discount) : principal; return MathUtil.percentageOf(base, minimumPayment, MathContext.DECIMAL128); } + private Optional findLatestRescheduleAction(final Long loanId) { + return actionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); + } + + @Override + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction rescheduleAction) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, null, rescheduleAction); + final Integer newFrequency = rescheduleAction.getFrequency(); + final DelinquencyFrequencyType newFreqType = rescheduleAction.getFrequencyType(); + + final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + + WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod = null; + final List futurePeriods = new ArrayList<>(); + + for (final WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { + if (period.getMinPaymentCriteriaMet() != null) { + continue; + } + final boolean isCurrent = !period.getFromDate().isAfter(businessDate) && !period.getToDate().isBefore(businessDate); + final boolean isFuture = period.getFromDate().isAfter(businessDate); + + if (isCurrent) { + currentPeriod = period; + period.setExpectedAmount(newExpectedAmount); + period.setOutstandingAmount(newExpectedAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); + } else if (isFuture) { + futurePeriods.add(period); + } + } + + repository.deleteAll(futurePeriods); + repository.flush(); + + if (currentPeriod != null) { + repository.saveAndFlush(currentPeriod); + regenerateFuturePeriods(loan, currentPeriod, newExpectedAmount, newFrequency, newFreqType); + } + + evaluateExpiredPeriods(loan, businessDate); + + log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={}%, frequency={} {}", loan.getId(), + rescheduleAction.getMinimumPayment(), newFrequency, newFreqType); + } + + private void regenerateFuturePeriods(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod, + final BigDecimal expectedAmount, final Integer frequency, final DelinquencyFrequencyType frequencyType) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + int periodNumber = currentPeriod.getPeriodNumber(); + LocalDate fromDate = currentPeriod.getToDate().plusDays(1); + + while (!fromDate.isAfter(businessDate)) { + final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType); + periodNumber++; + + final WorkingCapitalLoanDelinquencyRangeSchedule period = new WorkingCapitalLoanDelinquencyRangeSchedule(); + period.setLoan(loan); + period.setPeriodNumber(periodNumber); + period.setFromDate(fromDate); + period.setToDate(toDate); + period.setExpectedAmount(expectedAmount); + period.setPaidAmount(BigDecimal.ZERO); + period.setOutstandingAmount(expectedAmount); + period.setMinPaymentCriteriaMet(null); + + repository.saveAndFlush(period); + log.debug("Regenerated delinquency range schedule period {} for WC loan {}", periodNumber, loan.getId()); + + fromDate = toDate.plusDays(1); + } + } + @Override - public void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, LocalDate pauseEnd) { - long pauseDays = java.time.temporal.ChronoUnit.DAYS.between(pauseStart, pauseEnd); + public void extendPeriodsForPause(final WorkingCapitalLoan loan, final LocalDate pauseStart, final LocalDate pauseEnd) { + final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd); List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); for (WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { if (period.getMinPaymentCriteriaMet() != null) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java index efdd52e1382..e8a49284fd8 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java @@ -25,6 +25,7 @@ import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.START_DATE; import com.google.gson.JsonElement; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import java.util.Objects; @@ -35,8 +36,10 @@ import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.validator.ParseAndValidator; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; @@ -48,49 +51,110 @@ @Component public class WorkingCapitalLoanDelinquencyActionParseAndValidator extends ParseAndValidator { + private static final String MINIMUM_PAYMENT = "minimumPayment"; + private static final String FREQUENCY = "frequency"; + private static final String FREQUENCY_TYPE = "frequencyType"; + private final FromJsonHelper jsonHelper; private final WorkingCapitalLoanDelinquencyRangeScheduleRepository rangeScheduleRepository; public WorkingCapitalLoanDelinquencyAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, final List existing) { - WorkingCapitalLoanDelinquencyAction parsedAction = parseCommand(command); + final WorkingCapitalLoanDelinquencyAction parsedAction = parseCommand(command); validateLoanIsActive(workingCapitalLoan); - validateBothDatesProvided(parsedAction); - validateStartBeforeEnd(parsedAction); - validateNotBeforeDisbursement(parsedAction, workingCapitalLoan); - validateNotInEvaluatedPeriod(parsedAction, workingCapitalLoan); - validateNoOverlap(parsedAction, existing); + + if (DelinquencyAction.PAUSE.equals(parsedAction.getAction())) { + validatePause(parsedAction, workingCapitalLoan, existing); + } else if (DelinquencyAction.RESCHEDULE.equals(parsedAction.getAction())) { + validateReschedule(parsedAction, workingCapitalLoan); + } + return parsedAction; } + private void validatePause(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan, + final List existing) { + validateBothDatesProvided(action); + validateStartBeforeEnd(action); + validateNotBeforeDisbursement(action, workingCapitalLoan); + validateNotInEvaluatedPeriod(action, workingCapitalLoan); + validateNoOverlap(action, existing); + } + + private void validateReschedule(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan) { + validateLoanIsDisbursed(workingCapitalLoan); + validateScheduleExists(workingCapitalLoan); + validateMinimumPaymentProvided(action); + validateFrequencyProvided(action); + } + private WorkingCapitalLoanDelinquencyAction parseCommand(final JsonCommand command) { - JsonElement json = command.parsedJson(); - WorkingCapitalLoanDelinquencyAction action = new WorkingCapitalLoanDelinquencyAction(); + final JsonElement json = command.parsedJson(); + final WorkingCapitalLoanDelinquencyAction action = new WorkingCapitalLoanDelinquencyAction(); action.setAction(extractAction(json)); - action.setStartDate(extractDate(json, START_DATE)); - action.setEndDate(extractDate(json, END_DATE)); + + if (DelinquencyAction.PAUSE.equals(action.getAction())) { + action.setStartDate(extractDate(json, START_DATE)); + action.setEndDate(extractDate(json, END_DATE)); + } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { + action.setStartDate(DateUtils.getBusinessLocalDate()); + action.setMinimumPayment(extractBigDecimal(json, MINIMUM_PAYMENT)); + action.setFrequency(extractInteger(json, FREQUENCY)); + action.setFrequencyType(extractFrequencyType(json)); + } + return action; } private DelinquencyAction extractAction(final JsonElement json) { - String actionString = jsonHelper.extractStringNamed(ACTION, json); + final String actionString = jsonHelper.extractStringNamed(ACTION, json); if (StringUtils.isEmpty(actionString)) { raiseValidationError("wc-loan-delinquency-action-missing-action", "Delinquency Action must not be null or empty", ACTION); } - if (!"pause".equalsIgnoreCase(actionString)) { - throw new PlatformApiDataValidationException( - List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-action", - "Only PAUSE action is supported. Invalid action: " + actionString, ACTION))); + if ("pause".equalsIgnoreCase(actionString)) { + return DelinquencyAction.PAUSE; + } else if ("reschedule".equalsIgnoreCase(actionString)) { + return DelinquencyAction.RESCHEDULE; } - return DelinquencyAction.PAUSE; + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-action", + "Invalid Delinquency Action: " + actionString + ". Supported actions: pause, reschedule", ACTION))); } private LocalDate extractDate(final JsonElement json, final String paramName) { - String dateFormat = jsonHelper.extractStringNamed(DATE_FORMAT, json); - String locale = jsonHelper.extractStringNamed(LOCALE, json); + final String dateFormat = jsonHelper.extractStringNamed(DATE_FORMAT, json); + final String locale = jsonHelper.extractStringNamed(LOCALE, json); return jsonHelper.extractLocalDateNamed(paramName, json, dateFormat, JsonParserHelper.localeFromString(locale)); } + private BigDecimal extractBigDecimal(final JsonElement json, final String paramName) { + if (json.getAsJsonObject().has(paramName)) { + return jsonHelper.extractBigDecimalWithLocaleNamed(paramName, json); + } + return null; + } + + private Integer extractInteger(final JsonElement json, final String paramName) { + if (json.getAsJsonObject().has(paramName)) { + return jsonHelper.extractIntegerWithLocaleNamed(paramName, json); + } + return null; + } + + private DelinquencyFrequencyType extractFrequencyType(final JsonElement json) { + final String value = jsonHelper.extractStringNamed(FREQUENCY_TYPE, json); + if (StringUtils.isEmpty(value)) { + return null; + } + try { + return DelinquencyFrequencyType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PlatformApiDataValidationException( + List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-frequency-type", + "Invalid frequency type: " + value + ". Supported: DAYS, WEEKS, MONTHS, YEARS", FREQUENCY_TYPE)), + e); + } + } + private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan) { if (!workingCapitalLoan.getLoanStatus().isActive()) { raiseValidationError("wc-loan-delinquency-action-invalid-loan-state", @@ -98,6 +162,41 @@ private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan) { } } + private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan) { + final boolean isDisbursed = workingCapitalLoan.getDisbursementDetails().stream() + .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); + if (!isDisbursed) { + raiseValidationError("wc-loan-delinquency-action-loan-not-disbursed", "Reschedule action requires the loan to be disbursed."); + } + } + + private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan) { + final List periods = rangeScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); + if (periods.isEmpty()) { + raiseValidationError("wc-loan-delinquency-action-no-schedule", + "Reschedule action requires an existing delinquency range schedule."); + } + } + + private void validateMinimumPaymentProvided(final WorkingCapitalLoanDelinquencyAction action) { + if (action.getMinimumPayment() == null || action.getMinimumPayment().compareTo(BigDecimal.ZERO) <= 0) { + raiseValidationError("wc-loan-delinquency-action-invalid-minimum-payment", + "The parameter `minimumPayment` must be greater than 0", MINIMUM_PAYMENT); + } + } + + private void validateFrequencyProvided(final WorkingCapitalLoanDelinquencyAction action) { + if (action.getFrequency() == null || action.getFrequency() <= 0) { + raiseValidationError("wc-loan-delinquency-action-invalid-frequency", "The parameter `frequency` must be greater than 0", + FREQUENCY); + } + if (action.getFrequencyType() == null) { + raiseValidationError("wc-loan-delinquency-action-missing-frequency-type", + "The parameter `frequencyType` is mandatory for reschedule action", FREQUENCY_TYPE); + } + } + private void validateBothDatesProvided(final WorkingCapitalLoanDelinquencyAction action) { if (action.getStartDate() == null) { raiseValidationError("wc-loan-delinquency-action-pause-startDate-cannot-be-blank", "The parameter `startDate` is mandatory", @@ -121,7 +220,7 @@ private void validateNotBeforeDisbursement(final WorkingCapitalLoanDelinquencyAc if (action.getStartDate() == null) { return; } - LocalDate firstDisbursementDate = workingCapitalLoan.getDisbursementDetails().stream() + final LocalDate firstDisbursementDate = workingCapitalLoan.getDisbursementDetails().stream() .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).filter(Objects::nonNull).findFirst().orElse(null); if (firstDisbursementDate != null && firstDisbursementDate.isAfter(action.getStartDate())) { raiseValidationError("wc-loan-delinquency-action-invalid-start-date", @@ -134,9 +233,9 @@ private void validateNotInEvaluatedPeriod(final WorkingCapitalLoanDelinquencyAct if (action.getStartDate() == null) { return; } - List periods = rangeScheduleRepository + final List periods = rangeScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); - boolean startsInEvaluatedPeriod = periods.stream().filter(p -> p.getMinPaymentCriteriaMet() != null) + final boolean startsInEvaluatedPeriod = periods.stream().filter(p -> p.getMinPaymentCriteriaMet() != null) .anyMatch(p -> !action.getStartDate().isAfter(p.getToDate())); if (startsInEvaluatedPeriod) { raiseValidationError("wc-loan-delinquency-action-pause-in-evaluated-period", @@ -149,7 +248,8 @@ private void validateNoOverlap(final WorkingCapitalLoanDelinquencyAction parsed, if (parsed.getStartDate() == null || parsed.getEndDate() == null) { return; } - boolean overlaps = existing.stream().anyMatch(e -> isOverlapping(parsed, e)); + final boolean overlaps = existing.stream().filter(e -> DelinquencyAction.PAUSE.equals(e.getAction())) + .anyMatch(e -> isOverlapping(parsed, e)); if (overlaps) { raiseValidationError("wc-loan-delinquency-action-overlapping", "Delinquency pause period cannot overlap with another pause period"); 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 1ec95ddda4f..ade3fd4182c 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 @@ -40,4 +40,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0019_wc_loan_delinquency_action_reschedule.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0019_wc_loan_delinquency_action_reschedule.xml new file mode 100644 index 00000000000..67d590a25d2 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0019_wc_loan_delinquency_action_reschedule.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java index 408609455b4..3369edb0852 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java @@ -20,6 +20,7 @@ import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; @@ -36,6 +37,20 @@ public final class WorkingCapitalLoanDelinquencyActionHelper { private WorkingCapitalLoanDelinquencyActionHelper() {} + public static PostWorkingCapitalLoansDelinquencyActionResponse createRescheduleAction(final Long loanId, + final BigDecimal minimumPayment, final int frequency, final String frequencyType) { + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setMinimumPayment(minimumPayment); + request.setFrequency(frequency); + request.setFrequencyType(frequencyType); + request.setLocale("en"); + log.info("Creating RESCHEDULE delinquency action for loan {} minimumPayment={} frequency={} {}", loanId, minimumPayment, frequency, + frequencyType); + return ok(() -> FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanDelinquencyActions() + .createDelinquencyAction(loanId, request)); + } + public static PostWorkingCapitalLoansDelinquencyActionResponse createDelinquencyAction(final Long loanId, final String action, final LocalDate startDate, final LocalDate endDate) { final PostWorkingCapitalLoansDelinquencyActionRequest request = buildActionRequest(action, startDate, endDate); From 3e6829d60ffc4d635cbde872849c8a9b2953d0aa Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Fri, 3 Apr 2026 18:07:29 +0200 Subject: [PATCH 2/2] FINERACT-2455: Added e2e tests covering WC delinquency RESCHEDULE action to change minimum payment amount and frequency --- ...ngCapitalDelinquencyRescheduleStepDef.java | 158 ++++++-- ...orkingCapitalDelinquencyReschedule.feature | 347 +++++++++++++++++- 2 files changed, 457 insertions(+), 48 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java index fc8da2dd37c..4bc622b6245 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java @@ -30,9 +30,13 @@ import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.DelinquencyBucketRequest; import org.apache.fineract.client.models.MinimumPaymentPeriodAndRule; import org.apache.fineract.client.models.PostAllowAttributeOverrides; @@ -94,17 +98,7 @@ public void createWcDelinquencyBucket(final int frequency, final String frequenc @When("Admin creates WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word}") public void createRescheduleAction(final int minimumPayment, final int frequency, final String frequencyType) { - final Long loanId = getLoanId(); - final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), frequency, - frequencyType); - log.info("Creating RESCHEDULE action for WC loan {}: minimumPayment={}, frequency={} {}", loanId, minimumPayment, frequency, - frequencyType); - - final PostWorkingCapitalLoansDelinquencyActionResponse result = ok( - () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); - assertThat(result).isNotNull(); - assertThat(result.getResourceId()).isNotNull(); - log.info("RESCHEDULE action created with id={}", result.getResourceId()); + createRescheduleActionInternal(new BigDecimal(minimumPayment), frequency, frequencyType); } @Then("Admin fails to create WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word}") @@ -118,6 +112,22 @@ public void failToCreateRescheduleAction(final int minimumPayment, final int fre fail(() -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); } + @Then("Admin fails to create WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word} with error containing {string}") + public void failToCreateRescheduleActionWithMessage(final int minimumPayment, final int frequency, final String frequencyType, + final String expectedMessage) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), frequency, + frequencyType); + log.info( + "Attempting to create RESCHEDULE action for WC loan {} (expecting HTTP 400 and message '{}'): minimumPayment={}, frequency={} {}", + loanId, expectedMessage, minimumPayment, frequency, frequencyType); + + final CallFailedRuntimeException exception = fail( + () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + assertThat(exception.getStatus()).as("HTTP status code").isEqualTo(400); + assertThat(exception.getDeveloperMessage()).as("Developer message").contains(expectedMessage); + } + @Then("WC loan delinquency range schedule has the following periods:") public void verifyPeriods(final DataTable table) { final Long loanId = getLoanId(); @@ -128,39 +138,115 @@ public void verifyPeriods(final DataTable table) { assertThat(periods).as("Number of periods").hasSize(expectedRows.size()); for (int i = 0; i < expectedRows.size(); i++) { - final Map expected = expectedRows.get(i); final WorkingCapitalLoanDelinquencyRangeScheduleData actual = periods.get(i); - final String periodLabel = "Period " + (i + 1); - - assertThat(actual.getPeriodNumber()).as(periodLabel + " periodNumber") - .isEqualTo(Integer.parseInt(expected.get("periodNumber"))); - assertThat(actual.getFromDate()).as(periodLabel + " fromDate") - .isEqualTo(LocalDate.parse(expected.get("fromDate"), DATE_FORMAT)); - assertThat(actual.getToDate()).as(periodLabel + " toDate").isEqualTo(LocalDate.parse(expected.get("toDate"), DATE_FORMAT)); - assertThat(actual.getExpectedAmount()).as(periodLabel + " expectedAmount") - .isEqualByComparingTo(new BigDecimal(expected.get("expectedAmount"))); - assertThat(actual.getPaidAmount()).as(periodLabel + " paidAmount") - .isEqualByComparingTo(new BigDecimal(expected.get("paidAmount"))); - assertThat(actual.getOutstandingAmount()).as(periodLabel + " outstandingAmount") - .isEqualByComparingTo(new BigDecimal(expected.get("outstandingAmount"))); - - final String criteriaMetStr = expected.get("minPaymentCriteriaMet"); - if (criteriaMetStr == null || criteriaMetStr.isBlank()) { - assertThat(actual.getMinPaymentCriteriaMet()).as(periodLabel + " minPaymentCriteriaMet").isNull(); - } else { - assertThat(actual.getMinPaymentCriteriaMet()).as(periodLabel + " minPaymentCriteriaMet") - .isEqualTo(Boolean.parseBoolean(criteriaMetStr)); - } + final int periodNumber = i + 1; + expectedRows.get(i).forEach((field, value) -> verifyFullScheduleField(actual, field, value, periodNumber)); } } @Then("WC loan delinquency actions contain {int} action(s)") public void verifyActionCount(final int count) { final Long loanId = getLoanId(); - final List actions = ok( - () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().retrieveDelinquencyActions(loanId)); + assertThat(retrieveDelinquencyActions(loanId)).hasSize(count); + } + + @Then("WC loan has both PAUSE and RESCHEDULE delinquency actions") + public void verifyBothPauseAndRescheduleActions() { + final Long loanId = getLoanId(); + final List actions = retrieveDelinquencyActions(loanId); + assertThat(actions.stream().map(a -> a.getAction().name()).toList()).as("Should contain both PAUSE and RESCHEDULE") + .contains("PAUSE", "RESCHEDULE"); + } + + @Then("WC loan last delinquency action has the following data:") + public void verifyLastActionContent(final DataTable table) { + final Long loanId = getLoanId(); + final List actions = retrieveDelinquencyActions(loanId); + assertThat(actions).as("Actions should not be empty").isNotEmpty(); + + final WorkingCapitalLoanDelinquencyActionData last = actions.get(actions.size() - 1); + final List> rows = table.asMaps(); + assertThat(rows).as("Expected exactly 1 data row").hasSize(1); + rows.get(0).forEach((field, value) -> verifyActionField(last, field, value)); + } + + @Then("WC loan delinquency range schedule periods have specific data:") + public void verifySpecificPeriods(final DataTable table) { + final Long loanId = getLoanId(); + final List periods = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyRangeSchedule().retrieveDelinquencyRangeSchedule(loanId)); + + for (final Map expected : table.asMaps()) { + final int periodNumber = Integer.parseInt(expected.get("periodNumber")); + final WorkingCapitalLoanDelinquencyRangeScheduleData actual = periods.stream() + .filter(p -> p.getPeriodNumber().equals(periodNumber)).findFirst().orElse(null); + assertThat(actual).as("Period %d should exist", periodNumber).isNotNull(); + expected.forEach((field, value) -> verifyFullScheduleField(actual, field, value, periodNumber)); + } + } + + @When("Admin creates WC delinquency reschedule action with decimal minimumPayment {string} and frequency {int} {word}") + public void createRescheduleActionWithDecimal(final String minimumPayment, final int frequency, final String frequencyType) { + createRescheduleActionInternal(new BigDecimal(minimumPayment), frequency, frequencyType); + } + + private void createRescheduleActionInternal(final BigDecimal minimumPayment, final int frequency, final String frequencyType) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(minimumPayment, frequency, frequencyType); + log.info("Creating RESCHEDULE action for WC loan {}: minimumPayment={}, frequency={} {}", loanId, minimumPayment, frequency, + frequencyType); + + final PostWorkingCapitalLoansDelinquencyActionResponse result = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isNotNull(); + log.info("RESCHEDULE action created with id={}", result.getResourceId()); + } + + private List retrieveDelinquencyActions(final Long loanId) { + return ok(() -> fineractFeignClient.workingCapitalLoanDelinquencyActions().retrieveDelinquencyActions(loanId)); + } + + private void verifyActionField(final WorkingCapitalLoanDelinquencyActionData actual, final String field, final String expected) { + switch (field) { + case "action" -> assertThat(actual.getAction().name()).as("action").isEqualTo(expected); + case "startDate" -> assertThat(actual.getStartDate()).as("startDate").isEqualTo(LocalDate.parse(expected, DATE_FORMAT)); + case "endDate" -> + verifyOptionalField(expected, v -> assertThat(actual.getEndDate()).as("endDate").isEqualTo(LocalDate.parse(v, DATE_FORMAT)), + () -> assertThat(actual.getEndDate()).as("endDate").isNull()); + case "minimumPayment" -> + assertThat(actual.getMinimumPayment()).as("minimumPayment").isEqualByComparingTo(new BigDecimal(expected)); + case "frequency" -> assertThat(actual.getFrequency()).as("frequency").isEqualTo(Integer.parseInt(expected)); + case "frequencyType" -> assertThat(actual.getFrequencyType().name()).as("frequencyType").isEqualTo(expected); + default -> throw new IllegalArgumentException("Unknown action field: " + field); + } + } + + private void verifyFullScheduleField(final WorkingCapitalLoanDelinquencyRangeScheduleData actual, final String field, + final String expected, final int periodNumber) { + final String label = "Period " + periodNumber + " " + field; + switch (field) { + case "periodNumber" -> assertThat(actual.getPeriodNumber()).as(label).isEqualTo(Integer.parseInt(expected)); + case "fromDate" -> assertThat(actual.getFromDate()).as(label).isEqualTo(LocalDate.parse(expected, DATE_FORMAT)); + case "toDate" -> assertThat(actual.getToDate()).as(label).isEqualTo(LocalDate.parse(expected, DATE_FORMAT)); + case "expectedAmount" -> assertThat(actual.getExpectedAmount()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "paidAmount" -> assertThat(actual.getPaidAmount()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "outstandingAmount" -> assertThat(actual.getOutstandingAmount()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "minPaymentCriteriaMet" -> verifyOptionalField(expected, + v -> assertThat(actual.getMinPaymentCriteriaMet()).as(label).isEqualTo(Boolean.parseBoolean(v)), + () -> assertThat(actual.getMinPaymentCriteriaMet()).as(label).isNull()); + case "delinquentDays" -> + verifyOptionalField(expected, v -> assertThat(actual.getDelinquentDays()).as(label).isEqualTo(Long.parseLong(v)), + () -> assertThat(actual.getDelinquentDays()).as(label).isNull()); + case "delinquentAmount" -> verifyOptionalField(expected, + v -> assertThat(actual.getDelinquentAmount()).as(label).isEqualByComparingTo(new BigDecimal(v)), + () -> assertThat(actual.getDelinquentAmount()).as(label).isNull()); + default -> throw new IllegalArgumentException("Unknown schedule field: " + field); + } + } - assertThat(actions).hasSize(count); + private void verifyOptionalField(final String expected, final Consumer whenPresent, final Runnable whenAbsent) { + Optional.ofNullable(expected).filter(Predicate.not(String::isBlank)).ifPresentOrElse(whenPresent, whenAbsent); } private Long getLoanId() { diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature index da7ccdff9df..92a563bd51d 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -1,7 +1,8 @@ -@WCCOBFeature -Feature: Working Capital Delinquency Reschedule Action +@WorkingCapitalDelinquencyRescheduleActionFeature @WCCOBFeature +Feature: Working Capital Delinquency Reschedule Action - Scenario: Reschedule changes minimumPayment only + @TestRailId:C74495 + Scenario: Verify that reschedule changes minimumPayment only When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE @@ -36,7 +37,8 @@ Feature: Working Capital Delinquency Reschedule Action | 7 | 30 June 2026 | 29 July 2026 | 100 | 0 | 100 | false | | 8 | 30 July 2026 | 28 August 2026 | 100 | 0 | 100 | | - Scenario: Reschedule changes frequency only + @TestRailId:C74496 + Scenario: Verify that reschedule changes frequency only When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE @@ -65,7 +67,8 @@ Feature: Working Capital Delinquency Reschedule Action | 9 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | | 10 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | - Scenario: Reschedule changes minimumPayment and frequency + @TestRailId:C74497 + Scenario: Verify that reschedule changes minimumPayment and frequency When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE @@ -94,7 +97,8 @@ Feature: Working Capital Delinquency Reschedule Action | 9 | 30 July 2026 | 13 August 2026 | 200 | 0 | 200 | false | | 10 | 14 August 2026 | 28 August 2026 | 200 | 0 | 200 | | - Scenario: Multiple reschedules - last one wins + @TestRailId:C74498 + Scenario: Verify that the latest reschedule action wins When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE @@ -107,12 +111,167 @@ Feature: Working Capital Delinquency Reschedule Action When Admin runs inline COB job for Working Capital Loan When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 30 DAYS When Admin creates WC delinquency reschedule action with minimumPayment 5 and frequency 30 DAYS + When Admin sets the business date to "15 April 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 500 | 0 | 500 | false | + | 2 | 31 January 2026 | 01 March 2026 | 500 | 0 | 500 | false | + | 3 | 02 March 2026 | 31 March 2026 | 500 | 0 | 500 | false | + | 4 | 01 April 2026 | 30 April 2026 | 500 | 0 | 500 | | + Then WC loan delinquency actions contain 2 actions + + @TestRailId:C74499 + Scenario: Verify that reschedule on non-active loan and validation errors are rejected + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS with error containing "only for active Working Capital loans" + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS with error containing "existing delinquency range schedule" + When Admin runs inline COB job for Working Capital Loan + Then Admin fails to create WC delinquency reschedule action with minimumPayment 0 and frequency 30 DAYS with error containing "`minimumPayment` must be greater than 0" + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 0 DAYS with error containing "`frequency` must be greater than 0" + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 INVALID with error containing "Invalid frequency type: INVALID" + + @TestRailId:C74500 + Scenario: Verify that reschedule after a PAUSE extends rescheduled periods correctly + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 March 2026" + When Admin runs inline COB job for Working Capital Loan + And Admin initiate a Working Capital loan delinquency pause with startDate "01 March 2026" and endDate "15 March 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency actions contain 2 actions + Then WC loan has both PAUSE and RESCHEDULE delinquency actions + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 15 March 2026 | 300 | 0 | 300 | false | + | 3 | 16 March 2026 | 14 April 2026 | 300 | 0 | 300 | false | + | 4 | 15 April 2026 | 14 May 2026 | 300 | 0 | 300 | false | + | 5 | 15 May 2026 | 13 June 2026 | 100 | 0 | 100 | false | + | 6 | 14 June 2026 | 13 July 2026 | 100 | 0 | 100 | false | + | 7 | 14 July 2026 | 12 August 2026 | 100 | 0 | 100 | false | + | 8 | 13 August 2026 | 11 September 2026 | 100 | 0 | 100 | | + + @TestRailId:C74501 + Scenario: Verify that PAUSE after RESCHEDULE preserves rescheduled parameters + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 March 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 30 DAYS + When Admin sets the business date to "01 April 2026" + When Admin runs inline COB job for Working Capital Loan + And Admin initiate a Working Capital loan delinquency pause with startDate "01 April 2026" and endDate "15 April 2026" + When Admin sets the business date to "15 July 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency actions contain 2 actions + Then WC loan has both PAUSE and RESCHEDULE delinquency actions + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | + | 3 | 02 March 2026 | 31 March 2026 | 200 | 0 | 200 | false | + | 4 | 01 April 2026 | 14 May 2026 | 200 | 0 | 200 | false | + | 5 | 15 May 2026 | 13 June 2026 | 200 | 0 | 200 | false | + | 6 | 14 June 2026 | 13 July 2026 | 200 | 0 | 200 | false | + | 7 | 14 July 2026 | 12 August 2026 | 200 | 0 | 200 | | + + @TestRailId:C74502 + Scenario: Verify that reschedule spot-check reflects evaluated vs rescheduled period amounts + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | expectedAmount | outstandingAmount | delinquentDays | delinquentAmount | + | 1 | 300 | 300 | | | + | 5 | 300 | 300 | | | + | 6 | 100 | 100 | | | + | 8 | 100 | 100 | | | + + @TestRailId:C74503 + Scenario: Verify that reschedule on disbursement date creates a single period + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 5 and frequency 30 DAYS Then WC loan delinquency range schedule has the following periods: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | | 1 | 01 January 2026 | 30 January 2026 | 500 | 0 | 500 | | - Then WC loan delinquency actions contain 2 actions - Scenario: Reschedule on non-active loan and validation errors are rejected + @TestRailId:C74504 + Scenario: Verify that reschedule on the first day of a new period updates subsequent periods + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "31 January 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin sets the business date to "15 April 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | false | + | 3 | 02 March 2026 | 31 March 2026 | 100 | 0 | 100 | false | + | 4 | 01 April 2026 | 30 April 2026 | 100 | 0 | 100 | | + + @TestRailId:C74505 + Scenario: Verify that retrieving delinquency actions returns RESCHEDULE action details When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE @@ -120,10 +279,174 @@ Feature: Working Capital Delinquency Reschedule Action When Admin creates a working capital loan with the following data: | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | - Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount When Admin runs inline COB job for Working Capital Loan - Then Admin fails to create WC delinquency reschedule action with minimumPayment 0 and frequency 30 DAYS - Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 0 DAYS - Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 INVALID + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 15 DAYS + Then WC loan delinquency actions contain 1 action + Then WC loan last delinquency action has the following data: + | action | startDate | minimumPayment | frequency | frequencyType | + | RESCHEDULE | 01 January 2026 | 2 | 15 | DAYS | + + @TestRailId:C74506 + Scenario: Verify that COB generates new periods using rescheduled parameters over time + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 15 DAYS + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 100 | 0 | 100 | | + When Admin sets the business date to "01 March 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 100 | 0 | 100 | false | + | 2 | 31 January 2026 | 14 February 2026 | 100 | 0 | 100 | false | + | 3 | 15 February 2026 | 01 March 2026 | 100 | 0 | 100 | | + + @TestRailId:C74507 + Scenario: Verify that reschedule supports WEEKS frequency type + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 2 WEEKS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | false | + | 7 | 30 June 2026 | 13 July 2026 | 200 | 0 | 200 | false | + | 8 | 14 July 2026 | 27 July 2026 | 200 | 0 | 200 | false | + | 9 | 28 July 2026 | 10 August 2026 | 200 | 0 | 200 | false | + | 10 | 11 August 2026 | 24 August 2026 | 200 | 0 | 200 | | + + @TestRailId:C74508 + Scenario: Verify that reschedule supports MONTHS frequency type + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 1 MONTHS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | false | + | 7 | 30 June 2026 | 29 July 2026 | 200 | 0 | 200 | false | + | 8 | 30 July 2026 | 29 August 2026 | 200 | 0 | 200 | | + + @TestRailId:C74509 + Scenario: Verify that reschedule with negative minimumPayment is rejected + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + Then Admin fails to create WC delinquency reschedule action with minimumPayment -5 and frequency 30 DAYS with error containing "`minimumPayment` must be greater than 0" + + @TestRailId:C74510 + Scenario: Verify that reschedule with negative frequency is rejected + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + Then Admin fails to create WC delinquency reschedule action with minimumPayment 3 and frequency -1 DAYS with error containing "`frequency` must be greater than 0" + + @TestRailId:C74511 + Scenario: Verify that reschedule supports minimumPayment over 100 percent + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 200 and frequency 30 DAYS + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 20000 | 0 | 20000 | | + + @TestRailId:C74512 + Scenario: Verify that reschedule supports decimal minimumPayment + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with decimal minimumPayment "2.5" and frequency 30 DAYS + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 250 | 0 | 250 | | + + @TestRailId:C74513 + Scenario: Verify that two identical reschedules produce the same schedule as a single reschedule + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 100 | 0 | 100 | | + Then WC loan delinquency actions contain 2 actions