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..0421acb71a2 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java @@ -0,0 +1,309 @@ +/** + * 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 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; +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 the following parameters:") + public void createRescheduleAction(final DataTable table) { + final Map params = table.asMaps().get(0); + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setLocale("en"); + Optional.ofNullable(params.get("minimumPayment")).ifPresent(v -> request.setMinimumPayment(new BigDecimal(v))); + Optional.ofNullable(params.get("minimumPaymentType")).ifPresent(request::setMinimumPaymentType); + Optional.ofNullable(params.get("frequency")).ifPresent(v -> request.setFrequency(Integer.parseInt(v))); + Optional.ofNullable(params.get("frequencyType")).ifPresent(request::setFrequencyType); + executeRescheduleAction(request); + } + + @Then("Admin fails to create WC delinquency reschedule action with minimumPayment {int} {word} and frequency {int} {word}") + public void failToCreateRescheduleAction(final int minimumPayment, final String minimumPaymentType, final int frequency, + final String frequencyType) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), + minimumPaymentType, frequency, frequencyType); + log.info("Attempting to create RESCHEDULE action for WC loan {} (expecting failure): minimumPayment={} {}, frequency={} {}", loanId, + minimumPayment, minimumPaymentType, frequency, frequencyType); + + fail(() -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + } + + @Then("Admin fails to create WC delinquency reschedule action with minimumPayment {int} {word} and frequency {int} {word} with error containing {string}") + public void failToCreateRescheduleActionWithMessage(final int minimumPayment, final String minimumPaymentType, final int frequency, + final String frequencyType, final String expectedMessage) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), + minimumPaymentType, frequency, frequencyType); + log.info( + "Attempting to create RESCHEDULE action for WC loan {} (expecting HTTP 400 and message '{}'): minimumPayment={} {}, frequency={} {}", + loanId, expectedMessage, minimumPayment, minimumPaymentType, 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("Admin fails to create WC delinquency reschedule action with error containing {string} and the following parameters:") + public void failToCreateRescheduleActionWithTableAndMessage(final String expectedMessage, final DataTable table) { + final Long loanId = getLoanId(); + final Map params = table.asMaps().get(0); + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setLocale("en"); + Optional.ofNullable(params.get("minimumPayment")).ifPresent(v -> request.setMinimumPayment(new BigDecimal(v))); + Optional.ofNullable(params.get("minimumPaymentType")).ifPresent(request::setMinimumPaymentType); + Optional.ofNullable(params.get("frequency")).ifPresent(v -> request.setFrequency(Integer.parseInt(v))); + Optional.ofNullable(params.get("frequencyType")).ifPresent(request::setFrequencyType); + log.info("Attempting to create RESCHEDULE action for WC loan {} (expecting HTTP 400 and message '{}'): {}", loanId, expectedMessage, + params); + + 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("Admin fails to create WC delinquency reschedule action with no parameters with error containing {string}") + public void failToCreateEmptyRescheduleAction(final String expectedMessage) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setLocale("en"); + log.info("Attempting to create empty RESCHEDULE action for WC loan {} (expecting HTTP 400 and message '{}')", loanId, + expectedMessage); + + 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(); + 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 WorkingCapitalLoanDelinquencyRangeScheduleData actual = periods.get(i); + 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(); + 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)); + } + } + + private void executeRescheduleAction(final PostWorkingCapitalLoansDelinquencyActionRequest request) { + final Long loanId = getLoanId(); + log.info("Creating RESCHEDULE action for WC loan {}: {}", loanId, request); + + 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 "minimumPaymentType" -> + verifyOptionalField(expected, v -> assertThat(actual.getMinimumPaymentType().name()).as("minimumPaymentType").isEqualTo(v), + () -> assertThat(actual.getMinimumPaymentType()).as("minimumPaymentType").isNull()); + 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); + } + } + + 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() { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + assertThat(loanResponse).isNotNull(); + return loanResponse.getLoanId(); + } + + private PostWorkingCapitalLoansDelinquencyActionRequest buildRescheduleRequest(final BigDecimal minimumPayment, + final String minimumPaymentType, final int frequency, final String frequencyType) { + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setMinimumPayment(minimumPayment); + request.setMinimumPaymentType(minimumPaymentType); + 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 38a7bb1abee..67ed65c8b3a 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 @@ -863,9 +863,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); @@ -945,6 +954,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..0f01fa38050 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -0,0 +1,650 @@ +@WorkingCapitalDelinquencyRescheduleActionFeature @WCCOBFeature +Feature: Working Capital Delinquency Reschedule Action + + @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 + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 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 | | + + @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 + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 3 | PERCENTAGE | 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 | | + + @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 + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 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 | | + + @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 + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 30 | DAYS | + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 5 | PERCENTAGE | 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 PERCENTAGE 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 PERCENTAGE 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 PERCENTAGE 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 PERCENTAGE 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 PERCENTAGE 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 5 | PERCENTAGE | 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 | | + + @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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 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 + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 15 | DAYS | + Then WC loan delinquency actions contain 1 action + Then WC loan last delinquency action has the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 01 January 2026 | 2 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 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 PERCENTAGE 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 PERCENTAGE 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 200 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2.5 | PERCENTAGE | 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 30 | DAYS | + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 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 + + Scenario: Verify that reschedule with no parameters 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 no parameters with error containing "At least one of payment" + + Scenario: Verify that reschedule with minimumPayment but without minimumPaymentType 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 error containing "`minimumPaymentType` is mandatory when `minimumPayment` is provided" and the following parameters: + | minimumPayment | + | 5 | + + Scenario: Verify that reschedule with frequency but without frequencyType 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 error containing "`frequencyType` is mandatory when `frequency` is provided" and the following parameters: + | frequency | + | 30 | + + Scenario: Verify that reschedule with invalid minimumPaymentType 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 error containing "Invalid minimum payment type: INVALID" and the following parameters: + | minimumPayment | minimumPaymentType | + | 5 | INVALID | + + Scenario: Verify that FLAT reschedule with COB generates periods with flat amount + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 150 | FLAT | 30 | DAYS | + When Admin sets the business date to "01 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 | 150 | 0 | 150 | false | + | 2 | 31 January 2026 | 01 March 2026 | 150 | 0 | 150 | false | + | 3 | 02 March 2026 | 31 March 2026 | 150 | 0 | 150 | false | + | 4 | 01 April 2026 | 30 April 2026 | 150 | 0 | 150 | | + + Scenario: Verify that reschedule with FLAT minimumPaymentType uses flat amount + 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 the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 150 | FLAT | 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 | 150 | 0 | 150 | | + Then WC loan last delinquency action has the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 01 January 2026 | 150 | FLAT | 30 | DAYS | + + Scenario: Verify that reschedule with payment group only keeps original 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 the following parameters: + | minimumPayment | minimumPaymentType | + | 1 | PERCENTAGE | + 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: Verify that reschedule with frequency group only keeps original payment + 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 the following parameters: + | frequency | frequencyType | + | 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 | | 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..c8eb7f6b499 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,20 @@ 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 value (required together with minimumPaymentType)") + public BigDecimal minimumPayment; + @Schema(example = "PERCENTAGE", description = "Minimum payment type: PERCENTAGE, FLAT (required together with minimumPayment)") + public String minimumPaymentType; + @Schema(example = "30", description = "Frequency value (required together with frequencyType)") + public Integer frequency; + @Schema(example = "DAYS", description = "Frequency type: DAYS, WEEKS, MONTHS, YEARS (required together with frequency)") + 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..3a348f4e3a6 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,14 @@ */ 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; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentType; @AllArgsConstructor @Getter @@ -33,5 +36,9 @@ public class WorkingCapitalLoanDelinquencyActionData { private DelinquencyAction action; private LocalDate startDate; private LocalDate endDate; + private BigDecimal minimumPayment; + private DelinquencyMinimumPaymentType minimumPaymentType; + 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..c9eea2cdbbd 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,15 @@ 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; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentType; @Getter @Setter @@ -51,7 +54,21 @@ 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; + + @Enumerated(EnumType.STRING) + @Column(name = "minimum_payment_type") + private DelinquencyMinimumPaymentType minimumPaymentType; + + @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/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java index a1513d878e5..72a6d70cd92 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java @@ -25,6 +25,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; import java.math.BigDecimal; import java.time.LocalDate; import lombok.Getter; @@ -40,6 +41,10 @@ @UniqueConstraint(columnNames = { "wc_loan_id", "period_number" }, name = "uc_wc_delinquency_range_schedule_loan_period") }) public class WorkingCapitalLoanDelinquencyRangeSchedule extends AbstractAuditableWithUTCDateTimeCustom { + @Version + @Column(name = "version") + private Integer version; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "wc_loan_id", nullable = false) private WorkingCapitalLoan loan; 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..15885f78597 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.getMinimumPaymentType(), 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..4909b72bb1b 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 @@ -19,23 +19,30 @@ package org.apache.fineract.portfolio.workingcapitalloan.service; 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.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +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; @@ -45,8 +52,9 @@ @Service public class WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl implements WorkingCapitalLoanDelinquencyRangeScheduleService { - private final WorkingCapitalLoanDelinquencyRangeScheduleRepository repository; - private final WorkingCapitalLoanDelinquencyRangeScheduleMapper mapper; + private final WorkingCapitalLoanDelinquencyRangeScheduleRepository loanDelinquencyRangeScheduleRepository; + private final WorkingCapitalLoanDelinquencyActionRepository loanDelinquencyActionRepository; + private final WorkingCapitalLoanDelinquencyRangeScheduleMapper capitalLoanDelinquencyRangeScheduleMapper; @Override public void generateInitialPeriod(WorkingCapitalLoan loan) { @@ -62,7 +70,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); @@ -74,35 +82,41 @@ public void generateInitialPeriod(WorkingCapitalLoan loan) { period.setOutstandingAmount(expectedAmount); period.setMinPaymentCriteriaMet(null); - repository.saveAndFlush(period); + loanDelinquencyRangeScheduleRepository.saveAndFlush(period); log.debug("Generated initial delinquency range schedule period for WC loan {}", loan.getId()); } @Override public boolean hasSchedule(Long loanId) { - return repository.findTopByLoanIdOrderByPeriodNumberDesc(loanId).isPresent(); + return loanDelinquencyRangeScheduleRepository.findTopByLoanIdOrderByPeriodNumberDesc(loanId).isPresent(); } @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 = loanDelinquencyRangeScheduleRepository .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); @@ -112,14 +126,14 @@ public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate busine nextPeriod.setOutstandingAmount(expectedAmount); nextPeriod.setMinPaymentCriteriaMet(null); - latestPeriod = repository.saveAndFlush(nextPeriod); + latestPeriod = loanDelinquencyRangeScheduleRepository.saveAndFlush(nextPeriod); log.debug("Generated next delinquency range schedule period {} for WC loan {}", nextPeriod.getPeriodNumber(), loan.getId()); } } @Override public void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal amount) { - Optional periodOpt = repository + Optional periodOpt = loanDelinquencyRangeScheduleRepository .findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(loanId, transactionDate, transactionDate); if (periodOpt.isPresent()) { WorkingCapitalLoanDelinquencyRangeSchedule period = periodOpt.get(); @@ -129,7 +143,7 @@ public void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal am if (newPaidAmount.compareTo(period.getExpectedAmount()) >= 0) { period.setMinPaymentCriteriaMet(true); } - repository.saveAndFlush(period); + loanDelinquencyRangeScheduleRepository.saveAndFlush(period); log.debug("Applied repayment of {} to delinquency range schedule period {} for WC loan {}", amount, period.getPeriodNumber(), loanId); } @@ -137,12 +151,12 @@ public void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal am @Override public void evaluateExpiredPeriods(WorkingCapitalLoan loan, LocalDate businessDate) { - List unevaluatedPeriods = repository + List unevaluatedPeriods = loanDelinquencyRangeScheduleRepository .findByLoanIdAndToDateLessThanEqualAndMinPaymentCriteriaMetIsNull(loan.getId(), businessDate); for (WorkingCapitalLoanDelinquencyRangeSchedule period : unevaluatedPeriods) { boolean criteriaMet = period.getPaidAmount().compareTo(period.getExpectedAmount()) >= 0; period.setMinPaymentCriteriaMet(criteriaMet); - repository.saveAndFlush(period); + loanDelinquencyRangeScheduleRepository.saveAndFlush(period); log.debug("Evaluated delinquency range schedule period {} for WC loan {}: criteriaMet={}", period.getPeriodNumber(), loan.getId(), criteriaMet); } @@ -150,8 +164,9 @@ public void evaluateExpiredPeriods(WorkingCapitalLoan loan, LocalDate businessDa @Override public List retrieveRangeSchedule(Long loanId) { - List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loanId); - return mapper.toDataList(periods); + List periods = loanDelinquencyRangeScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(loanId); + return capitalLoanDelinquencyRangeScheduleMapper.toDataList(periods); } private DelinquencyMinimumPaymentPeriodAndRule getMinimumPaymentRule(WorkingCapitalLoan loan) { @@ -175,27 +190,126 @@ private LocalDate calculateToDate(LocalDate fromDate, Integer frequency, Delinqu }; } - private BigDecimal calculateExpectedAmount(WorkingCapitalLoan loan, DelinquencyMinimumPaymentPeriodAndRule rule) { - BigDecimal minimumPayment = rule.getMinimumPayment(); - if (minimumPayment == null) { + private BigDecimal calculateExpectedAmount(final WorkingCapitalLoan loan, final DelinquencyMinimumPaymentPeriodAndRule rule, + final WorkingCapitalLoanDelinquencyAction rescheduleOverride) { + final BigDecimal principal = loan.getApprovedPrincipal(); + if (principal == null) { return BigDecimal.ZERO; } - if (DelinquencyMinimumPaymentType.FLAT.equals(rule.getMinimumPaymentType())) { - return minimumPayment; - } - BigDecimal principal = loan.getApprovedPrincipal(); - if (principal == null) { + + final BigDecimal effectiveMinimumPayment = resolveMinimumPayment(rescheduleOverride, rule); + final DelinquencyMinimumPaymentType effectivePaymentType = resolveMinimumPaymentType(rescheduleOverride, rule); + if (effectiveMinimumPayment == 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); + + final BigDecimal rawAmount; + if (DelinquencyMinimumPaymentType.FLAT.equals(effectivePaymentType)) { + rawAmount = effectiveMinimumPayment; + } else { + final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() + : null; + final BigDecimal base = discount != null ? principal.add(discount) : principal; + rawAmount = MathUtil.percentageOf(base, effectiveMinimumPayment, MoneyHelper.getMathContext()); + } + return Money.of(loan.getLoanProductRelatedDetails().getCurrency(), rawAmount).getAmount(); + } + + private BigDecimal resolveMinimumPayment(final WorkingCapitalLoanDelinquencyAction rescheduleOverride, + final DelinquencyMinimumPaymentPeriodAndRule rule) { + if (rescheduleOverride != null && rescheduleOverride.getMinimumPayment() != null) { + return rescheduleOverride.getMinimumPayment(); + } + return rule != null ? rule.getMinimumPayment() : null; + } + + private DelinquencyMinimumPaymentType resolveMinimumPaymentType(final WorkingCapitalLoanDelinquencyAction rescheduleOverride, + final DelinquencyMinimumPaymentPeriodAndRule rule) { + if (rescheduleOverride != null && rescheduleOverride.getMinimumPaymentType() != null) { + return rescheduleOverride.getMinimumPaymentType(); + } + return rule != null && rule.getMinimumPaymentType() != null ? rule.getMinimumPaymentType() + : DelinquencyMinimumPaymentType.PERCENTAGE; + } + + private Optional findLatestRescheduleAction(final Long loanId) { + return loanDelinquencyActionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); + } + + @Override + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction rescheduleAction) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); + if (rule == null) { + log.warn("No minimum payment rule found for WC loan {}, skipping reschedule", loan.getId()); + return; + } + final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, rule, rescheduleAction); + final Integer newFrequency = rescheduleAction.getFrequency() != null ? rescheduleAction.getFrequency() : rule.getFrequency(); + final DelinquencyFrequencyType newFreqType = rescheduleAction.getFrequencyType() != null ? rescheduleAction.getFrequencyType() + : rule.getFrequencyType(); + + final List periods = loanDelinquencyRangeScheduleRepository + .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); + } + } + + if (currentPeriod != null) { + loanDelinquencyRangeScheduleRepository.saveAndFlush(currentPeriod); + updateFuturePeriods(currentPeriod, futurePeriods, 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 updateFuturePeriods(final WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod, + final List existingFuturePeriods, final BigDecimal expectedAmount, + final Integer frequency, final DelinquencyFrequencyType frequencyType) { + int periodNumber = currentPeriod.getPeriodNumber(); + LocalDate fromDate = currentPeriod.getToDate().plusDays(1); + + for (final WorkingCapitalLoanDelinquencyRangeSchedule period : existingFuturePeriods) { + final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType); + periodNumber++; + + period.setPeriodNumber(periodNumber); + period.setFromDate(fromDate); + period.setToDate(toDate); + period.setExpectedAmount(expectedAmount); + period.setPaidAmount(BigDecimal.ZERO); + period.setOutstandingAmount(expectedAmount); + period.setMinPaymentCriteriaMet(null); + + fromDate = toDate.plusDays(1); + } + loanDelinquencyRangeScheduleRepository.saveAll(existingFuturePeriods); } @Override - public void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, LocalDate pauseEnd) { - long pauseDays = java.time.temporal.ChronoUnit.DAYS.between(pauseStart, pauseEnd); - List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + public void extendPeriodsForPause(final WorkingCapitalLoan loan, final LocalDate pauseStart, final LocalDate pauseEnd) { + final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd); + List periods = loanDelinquencyRangeScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(loan.getId()); for (WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { if (period.getMinPaymentCriteriaMet() != null) { continue; @@ -207,7 +321,7 @@ public void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, period.setFromDate(period.getFromDate().plusDays(pauseDays)); } } - repository.saveAll(periods); + loanDelinquencyRangeScheduleRepository.saveAll(periods); log.debug("Extended delinquency range schedule periods for WC loan {} by {} days due to pause [{} - {}]", loan.getId(), pauseDays, pauseStart, pauseEnd); } 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..6f3baf511e8 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,11 @@ 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.delinquency.domain.DelinquencyMinimumPaymentType; 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 +52,139 @@ @Component public class WorkingCapitalLoanDelinquencyActionParseAndValidator extends ParseAndValidator { + private static final String MINIMUM_PAYMENT = "minimumPayment"; + private static final String MINIMUM_PAYMENT_TYPE = "minimumPaymentType"; + 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); + + final boolean hasPaymentGroup = action.getMinimumPayment() != null || action.getMinimumPaymentType() != null; + final boolean hasFrequencyGroup = action.getFrequency() != null || action.getFrequencyType() != null; + + if (!hasPaymentGroup && !hasFrequencyGroup) { + raiseValidationError("wc-loan-delinquency-action-reschedule-no-change", + "At least one of payment (minimumPayment + minimumPaymentType) or frequency (frequency + frequencyType) group must be provided"); + } + if (hasPaymentGroup) { + validateMinimumPaymentGroupProvided(action); + } + if (hasFrequencyGroup) { + validateFrequencyGroupProvided(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.setMinimumPaymentType(extractMinimumPaymentType(json)); + 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 DelinquencyMinimumPaymentType extractMinimumPaymentType(final JsonElement json) { + final String value = jsonHelper.extractStringNamed(MINIMUM_PAYMENT_TYPE, json); + if (StringUtils.isEmpty(value)) { + return null; + } + try { + return DelinquencyMinimumPaymentType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PlatformApiDataValidationException( + List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-minimum-payment-type", + "Invalid minimum payment type: " + value + ". Supported: PERCENTAGE, FLAT", MINIMUM_PAYMENT_TYPE)), + e); + } + } + + 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 +192,45 @@ 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 validateMinimumPaymentGroupProvided(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); + } + if (action.getMinimumPaymentType() == null) { + raiseValidationError("wc-loan-delinquency-action-missing-minimum-payment-type", + "The parameter `minimumPaymentType` is mandatory when `minimumPayment` is provided", MINIMUM_PAYMENT_TYPE); + } + } + + private void validateFrequencyGroupProvided(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 when `frequency` is provided", 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 +254,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 +267,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 +282,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 1646600db3e..8ac5eea47c1 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 @@ -44,4 +44,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0023_wc_loan_delinquency_action_reschedule.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0023_wc_loan_delinquency_action_reschedule.xml new file mode 100644 index 00000000000..9396a470611 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0023_wc_loan_delinquency_action_reschedule.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +