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..2e63368ac83 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyRescheduleStepDef.java @@ -0,0 +1,182 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.stepdef.loan; + +import static org.apache.fineract.client.feign.util.FeignCalls.fail; +import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.assertj.core.api.Assertions.assertThat; + +import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.DelinquencyBucketRequest; +import org.apache.fineract.client.models.MinimumPaymentPeriodAndRule; +import org.apache.fineract.client.models.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; +import org.springframework.beans.factory.annotation.Autowired; + +@Slf4j +@RequiredArgsConstructor +public class WorkingCapitalDelinquencyRescheduleStepDef extends AbstractStepDef { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + + private final FineractFeignClient fineractFeignClient; + + @Autowired + private 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); + final PostWorkingCapitalLoanProductsResponse response = ok( + () -> fineractFeignClient.workingCapitalLoanProducts().createWorkingCapitalLoanProduct(request)); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + log.info("Created WC product id={} with delinquency bucket id={}", response.getResourceId(), bucketId); + } + + @When("Admin creates WC Delinquency Bucket with frequency {int} {word} and minimumPayment {int} {word}") + public void createWcDelinquencyBucket(final int frequency, final String frequencyType, final int minimumPayment, + final String minimumPaymentType) { + final DelinquencyBucketRequest request = new DelinquencyBucketRequest().name("DB-WCL-" + Utils.randomStringGenerator(12)) + .bucketType("WORKING_CAPITAL").ranges(List.of(1L)) + .minimumPaymentPeriodAndRule(new MinimumPaymentPeriodAndRule().frequency(frequency).frequencyType(frequencyType) + .minimumPayment(new BigDecimal(minimumPayment)).minimumPaymentType(minimumPaymentType)); + + final PostDelinquencyBucketResponse result = ok( + () -> fineractFeignClient.delinquencyRangeAndBucketsManagement().createBucket(request)); + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isNotNull(); + TestContext.GLOBAL.set(TestContextKey.DELINQUENCY_BUCKET_ID, result.getResourceId()); + log.info("Created WC delinquency bucket id={} with frequency={} {} minimumPayment={} {}", result.getResourceId(), frequency, + frequencyType, minimumPayment, minimumPaymentType); + } + + @When("Admin creates WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word}") + public void createRescheduleAction(final int minimumPayment, final int frequency, final String frequencyType) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), frequency, + frequencyType); + log.info("Creating RESCHEDULE action for WC loan {}: minimumPayment={}, frequency={} {}", loanId, minimumPayment, frequency, + frequencyType); + + final PostWorkingCapitalLoansDelinquencyActionResponse result = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isNotNull(); + log.info("RESCHEDULE action created with id={}", result.getResourceId()); + } + + @Then("Admin fails to create WC delinquency reschedule action with minimumPayment {int} and frequency {int} {word}") + public void failToCreateRescheduleAction(final int minimumPayment, final int frequency, final String frequencyType) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), frequency, + frequencyType); + log.info("Attempting to create RESCHEDULE action for WC loan {} (expecting failure): minimumPayment={}, frequency={} {}", loanId, + minimumPayment, frequency, frequencyType); + + fail(() -> fineractFeignClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + } + + @Then("WC loan delinquency range schedule has the following periods:") + public void verifyPeriods(final DataTable table) { + final Long loanId = getLoanId(); + final List periods = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyRangeSchedule().retrieveDelinquencyRangeSchedule(loanId)); + + final List> expectedRows = table.asMaps(); + assertThat(periods).as("Number of periods").hasSize(expectedRows.size()); + + for (int i = 0; i < expectedRows.size(); i++) { + final Map expected = expectedRows.get(i); + final WorkingCapitalLoanDelinquencyRangeScheduleData actual = periods.get(i); + final String periodLabel = "Period " + (i + 1); + + assertThat(actual.getPeriodNumber()).as(periodLabel + " periodNumber") + .isEqualTo(Integer.parseInt(expected.get("periodNumber"))); + assertThat(actual.getFromDate()).as(periodLabel + " fromDate") + .isEqualTo(LocalDate.parse(expected.get("fromDate"), DATE_FORMAT)); + assertThat(actual.getToDate()).as(periodLabel + " toDate").isEqualTo(LocalDate.parse(expected.get("toDate"), DATE_FORMAT)); + assertThat(actual.getExpectedAmount()).as(periodLabel + " expectedAmount") + .isEqualByComparingTo(new BigDecimal(expected.get("expectedAmount"))); + assertThat(actual.getPaidAmount()).as(periodLabel + " paidAmount") + .isEqualByComparingTo(new BigDecimal(expected.get("paidAmount"))); + assertThat(actual.getOutstandingAmount()).as(periodLabel + " outstandingAmount") + .isEqualByComparingTo(new BigDecimal(expected.get("outstandingAmount"))); + + final String criteriaMetStr = expected.get("minPaymentCriteriaMet"); + if (criteriaMetStr == null || criteriaMetStr.isBlank()) { + assertThat(actual.getMinPaymentCriteriaMet()).as(periodLabel + " minPaymentCriteriaMet").isNull(); + } else { + assertThat(actual.getMinPaymentCriteriaMet()).as(periodLabel + " minPaymentCriteriaMet") + .isEqualTo(Boolean.parseBoolean(criteriaMetStr)); + } + } + } + + @Then("WC loan delinquency actions contain {int} action(s)") + public void verifyActionCount(final int count) { + final Long loanId = getLoanId(); + final List actions = ok( + () -> fineractFeignClient.workingCapitalLoanDelinquencyActions().retrieveDelinquencyActions(loanId)); + + assertThat(actions).hasSize(count); + } + + private Long getLoanId() { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + assertThat(loanResponse).isNotNull(); + return loanResponse.getLoanId(); + } + + private PostWorkingCapitalLoansDelinquencyActionRequest buildRescheduleRequest(final BigDecimal minimumPayment, final int frequency, + final String frequencyType) { + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setMinimumPayment(minimumPayment); + request.setFrequency(frequency); + request.setFrequencyType(frequencyType); + request.setLocale("en"); + return request; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index 18c1f5fe85a..1c1ab6ad169 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 @@ -639,9 +639,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); @@ -708,6 +717,11 @@ private Long extractClientId() { } private Long resolveLoanProductId(final String loanProductName) { + if ("WCLP_DELINQUENCY".equals(loanProductName)) { + final PostWorkingCapitalLoanProductsResponse response = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + return response.getResourceId(); + } final DefaultWorkingCapitalLoanProduct product = DefaultWorkingCapitalLoanProduct.valueOf(loanProductName); return workingCapitalLoanProductResolver.resolve(product); } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature new file mode 100644 index 00000000000..da7ccdff9df --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -0,0 +1,129 @@ +@WCCOBFeature +Feature: Working Capital Delinquency Reschedule Action + + Scenario: Reschedule changes minimumPayment only + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 300 | 0 | 300 | | + When Admin creates WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 100 | 0 | 100 | false | + | 7 | 30 June 2026 | 29 July 2026 | 100 | 0 | 100 | false | + | 8 | 30 July 2026 | 28 August 2026 | 100 | 0 | 100 | | + + Scenario: Reschedule changes frequency only + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 3 and frequency 15 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 300 | 0 | 300 | false | + | 7 | 30 June 2026 | 14 July 2026 | 300 | 0 | 300 | false | + | 8 | 15 July 2026 | 29 July 2026 | 300 | 0 | 300 | false | + | 9 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | + | 10 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | + + Scenario: Reschedule changes minimumPayment and frequency + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 June 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 15 DAYS + When Admin sets the business date to "15 August 2026" + When Admin runs inline COB job for Working Capital Loan + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | false | + | 2 | 31 January 2026 | 01 March 2026 | 300 | 0 | 300 | false | + | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | + | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | + | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | + | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | false | + | 7 | 30 June 2026 | 14 July 2026 | 200 | 0 | 200 | false | + | 8 | 15 July 2026 | 29 July 2026 | 200 | 0 | 200 | false | + | 9 | 30 July 2026 | 13 August 2026 | 200 | 0 | 200 | false | + | 10 | 14 August 2026 | 28 August 2026 | 200 | 0 | 200 | | + + Scenario: Multiple reschedules - last one wins + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with minimumPayment 2 and frequency 30 DAYS + When Admin creates WC delinquency reschedule action with minimumPayment 5 and frequency 30 DAYS + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 500 | 0 | 500 | | + Then WC loan delinquency actions contain 2 actions + + Scenario: Reschedule on non-active loan and validation errors are rejected + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 DAYS + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + Then Admin fails to create WC delinquency reschedule action with minimumPayment 0 and frequency 30 DAYS + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 0 DAYS + Then Admin fails to create WC delinquency reschedule action with minimumPayment 1 and frequency 30 INVALID diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java index 40cea6647ee..b2192bf8410 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/domain/DelinquencyAction.java @@ -20,5 +20,6 @@ public enum DelinquencyAction { PAUSE, // - RESUME // + RESUME, // + RESCHEDULE // } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java index 3b7e734f93e..cf17b5724be 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java @@ -66,7 +66,7 @@ public class WorkingCapitalLoanDelinquencyActionApiResource { @Path("{loanId}/delinquency-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Create Delinquency Pause Action", description = "Creates a delinquency pause action for a Working Capital loan, extending the active delinquency range period and shifting future periods by the pause duration.") + @Operation(summary = "Create Delinquency Action", description = "Creates a delinquency action (pause or reschedule) for a Working Capital loan.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionResponse.class))), @@ -86,7 +86,7 @@ public CommandProcessingResult createDelinquencyAction(@PathParam("loanId") @Par @Path("external-id/{loanExternalId}/delinquency-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "createDelinquencyActionByExternalId", summary = "Create Delinquency Pause Action by external id", description = "Creates a delinquency pause action for a Working Capital loan identified by external id, extending the active delinquency range period and shifting future periods by the pause duration.") + @Operation(operationId = "createDelinquencyActionByExternalId", summary = "Create Delinquency Action by external id", description = "Creates a delinquency action (pause or reschedule) for a Working Capital loan identified by external id.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionResponse.class))), diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java index 043d8fc5209..e740c81768f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.workingcapitalloan.api; import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; public final class WorkingCapitalLoanDelinquencyActionApiResourceSwagger { @@ -29,12 +30,18 @@ public static final class PostWorkingCapitalLoansDelinquencyActionRequest { private PostWorkingCapitalLoansDelinquencyActionRequest() {} - @Schema(example = "pause") + @Schema(example = "pause", description = "Delinquency action type: pause or reschedule") public String action; - @Schema(example = "2026-03-05") + @Schema(example = "2026-03-05", description = "Start date of the pause period (required for pause)") public String startDate; - @Schema(example = "2026-03-12") + @Schema(example = "2026-03-12", description = "End date of the pause period (required for pause)") public String endDate; + @Schema(example = "2", description = "Minimum payment percentage (required for reschedule)") + public BigDecimal minimumPayment; + @Schema(example = "30", description = "Frequency value (required for reschedule)") + public Integer frequency; + @Schema(example = "DAYS", description = "Frequency type: DAYS, WEEKS, MONTHS, YEARS (required for reschedule)") + public String frequencyType; @Schema(example = "yyyy-MM-dd") public String dateFormat; @Schema(example = "en") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java index ce42f68a453..ed1a5974ae4 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanDelinquencyActionData.java @@ -18,11 +18,13 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.data; +import java.math.BigDecimal; import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; @AllArgsConstructor @Getter @@ -33,5 +35,8 @@ public class WorkingCapitalLoanDelinquencyActionData { private DelinquencyAction action; private LocalDate startDate; private LocalDate endDate; + private BigDecimal minimumPayment; + private Integer frequency; + private DelinquencyFrequencyType frequencyType; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java index 9ff9bf5f70a..c3658847fff 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyAction.java @@ -26,12 +26,14 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.math.BigDecimal; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; @Getter @Setter @@ -51,7 +53,17 @@ public class WorkingCapitalLoanDelinquencyAction extends AbstractAuditableWithUT @Column(name = "start_date", nullable = false) private LocalDate startDate; - @Column(name = "end_date", nullable = false) + @Column(name = "end_date") private LocalDate endDate; + @Column(name = "minimum_payment", scale = 6, precision = 19) + private BigDecimal minimumPayment; + + @Column(name = "frequency") + private Integer frequency; + + @Enumerated(EnumType.STRING) + @Column(name = "frequency_type") + private DelinquencyFrequencyType frequencyType; + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java index 271518d908a..aab24c89763 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java @@ -19,6 +19,8 @@ package org.apache.fineract.portfolio.workingcapitalloan.repository; import java.util.List; +import java.util.Optional; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -28,4 +30,7 @@ public interface WorkingCapitalLoanDelinquencyActionRepository extends JpaReposi List findByWorkingCapitalLoanIdOrderById(Long workingCapitalLoanId); + Optional findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, + DelinquencyAction action); + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java index 6458a295f1e..f1783bd3f60 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionReadServiceImpl.java @@ -39,7 +39,8 @@ public List retrieveDelinquencyActions( } private WorkingCapitalLoanDelinquencyActionData toData(final WorkingCapitalLoanDelinquencyAction action) { - return new WorkingCapitalLoanDelinquencyActionData(action.getId(), action.getAction(), action.getStartDate(), action.getEndDate()); + return new WorkingCapitalLoanDelinquencyActionData(action.getId(), action.getAction(), action.getStartDate(), action.getEndDate(), + action.getMinimumPayment(), action.getFrequency(), action.getFrequencyType()); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java index 95692391512..ff28ac29980 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java @@ -24,6 +24,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; @@ -58,7 +59,11 @@ public CommandProcessingResult createDelinquencyAction(final Long workingCapital final WorkingCapitalLoanDelinquencyAction saved = actionRepository.saveAndFlush(action); log.debug("Created WC loan delinquency action {} for loan {}", action.getAction(), workingCapitalLoanId); - rangeScheduleService.extendPeriodsForPause(workingCapitalLoan, action.getStartDate(), action.getEndDate()); + if (DelinquencyAction.PAUSE.equals(action.getAction())) { + rangeScheduleService.extendPeriodsForPause(workingCapitalLoan, action.getStartDate(), action.getEndDate()); + } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { + rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); + } return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java index bdb819cbb21..600eb716968 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java @@ -23,6 +23,7 @@ import java.util.List; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; public interface WorkingCapitalLoanDelinquencyRangeScheduleService { @@ -40,4 +41,6 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleService { void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, LocalDate pauseEnd); + void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction rescheduleAction); + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index 6e23ecab3d6..9742a28360a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -21,21 +21,27 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentPeriodAndRule; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentType; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.springframework.stereotype.Service; @@ -46,6 +52,7 @@ public class WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl implements WorkingCapitalLoanDelinquencyRangeScheduleService { private final WorkingCapitalLoanDelinquencyRangeScheduleRepository repository; + private final WorkingCapitalLoanDelinquencyActionRepository actionRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleMapper mapper; @Override @@ -62,7 +69,7 @@ public void generateInitialPeriod(WorkingCapitalLoan loan) { return; } LocalDate toDate = calculateToDate(fromDate, rule.getFrequency(), rule.getFrequencyType()); - BigDecimal expectedAmount = calculateExpectedAmount(loan, rule); + BigDecimal expectedAmount = calculateExpectedAmount(loan, rule, null); WorkingCapitalLoanDelinquencyRangeSchedule period = new WorkingCapitalLoanDelinquencyRangeSchedule(); period.setLoan(loan); @@ -85,24 +92,30 @@ public boolean hasSchedule(Long loanId) { @Override public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate businessDate) { - DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); + final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); if (rule == null) { return; } - Optional latestPeriodOpt = repository + final Optional latestPeriodOpt = repository .findTopByLoanIdOrderByPeriodNumberDesc(loan.getId()); if (latestPeriodOpt.isEmpty()) { return; } + final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); + final Integer effectiveFrequency = latestReschedule.map(WorkingCapitalLoanDelinquencyAction::getFrequency) + .orElse(rule.getFrequency()); + final DelinquencyFrequencyType effectiveFreqType = latestReschedule.map(WorkingCapitalLoanDelinquencyAction::getFrequencyType) + .orElse(rule.getFrequencyType()); + WorkingCapitalLoanDelinquencyRangeSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { - LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); - LocalDate newToDate = calculateToDate(newFromDate, rule.getFrequency(), rule.getFrequencyType()); - BigDecimal expectedAmount = calculateExpectedAmount(loan, rule); + final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); + final LocalDate newToDate = calculateToDate(newFromDate, effectiveFrequency, effectiveFreqType); + final BigDecimal expectedAmount = calculateExpectedAmount(loan, rule, latestReschedule.orElse(null)); - WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = new WorkingCapitalLoanDelinquencyRangeSchedule(); + final WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = new WorkingCapitalLoanDelinquencyRangeSchedule(); nextPeriod.setLoan(loan); nextPeriod.setPeriodNumber(latestPeriod.getPeriodNumber() + 1); nextPeriod.setFromDate(newFromDate); @@ -175,26 +188,103 @@ private LocalDate calculateToDate(LocalDate fromDate, Integer frequency, Delinqu }; } - private BigDecimal calculateExpectedAmount(WorkingCapitalLoan loan, DelinquencyMinimumPaymentPeriodAndRule rule) { - BigDecimal minimumPayment = rule.getMinimumPayment(); + private BigDecimal calculateExpectedAmount(final WorkingCapitalLoan loan, final DelinquencyMinimumPaymentPeriodAndRule rule, + final WorkingCapitalLoanDelinquencyAction rescheduleOverride) { + final BigDecimal principal = loan.getApprovedPrincipal(); + if (principal == null) { + return BigDecimal.ZERO; + } + if (rescheduleOverride != null) { + return MathUtil.percentageOf(principal, rescheduleOverride.getMinimumPayment(), MathContext.DECIMAL128); + } + final BigDecimal minimumPayment = rule.getMinimumPayment(); if (minimumPayment == null) { return BigDecimal.ZERO; } if (DelinquencyMinimumPaymentType.FLAT.equals(rule.getMinimumPaymentType())) { return minimumPayment; } - BigDecimal principal = loan.getApprovedPrincipal(); - if (principal == null) { - return BigDecimal.ZERO; - } BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() : null; BigDecimal base = discount != null ? principal.add(discount) : principal; return MathUtil.percentageOf(base, minimumPayment, MathContext.DECIMAL128); } + private Optional findLatestRescheduleAction(final Long loanId) { + return actionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); + } + + @Override + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction rescheduleAction) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, null, rescheduleAction); + final Integer newFrequency = rescheduleAction.getFrequency(); + final DelinquencyFrequencyType newFreqType = rescheduleAction.getFrequencyType(); + + final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + + WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod = null; + final List futurePeriods = new ArrayList<>(); + + for (final WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { + if (period.getMinPaymentCriteriaMet() != null) { + continue; + } + final boolean isCurrent = !period.getFromDate().isAfter(businessDate) && !period.getToDate().isBefore(businessDate); + final boolean isFuture = period.getFromDate().isAfter(businessDate); + + if (isCurrent) { + currentPeriod = period; + period.setExpectedAmount(newExpectedAmount); + period.setOutstandingAmount(newExpectedAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); + } else if (isFuture) { + futurePeriods.add(period); + } + } + + repository.deleteAll(futurePeriods); + repository.flush(); + + if (currentPeriod != null) { + repository.saveAndFlush(currentPeriod); + regenerateFuturePeriods(loan, currentPeriod, newExpectedAmount, newFrequency, newFreqType); + } + + evaluateExpiredPeriods(loan, businessDate); + + log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={}%, frequency={} {}", loan.getId(), + rescheduleAction.getMinimumPayment(), newFrequency, newFreqType); + } + + private void regenerateFuturePeriods(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod, + final BigDecimal expectedAmount, final Integer frequency, final DelinquencyFrequencyType frequencyType) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + int periodNumber = currentPeriod.getPeriodNumber(); + LocalDate fromDate = currentPeriod.getToDate().plusDays(1); + + while (!fromDate.isAfter(businessDate)) { + final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType); + periodNumber++; + + final WorkingCapitalLoanDelinquencyRangeSchedule period = new WorkingCapitalLoanDelinquencyRangeSchedule(); + period.setLoan(loan); + period.setPeriodNumber(periodNumber); + period.setFromDate(fromDate); + period.setToDate(toDate); + period.setExpectedAmount(expectedAmount); + period.setPaidAmount(BigDecimal.ZERO); + period.setOutstandingAmount(expectedAmount); + period.setMinPaymentCriteriaMet(null); + + repository.saveAndFlush(period); + log.debug("Regenerated delinquency range schedule period {} for WC loan {}", periodNumber, loan.getId()); + + fromDate = toDate.plusDays(1); + } + } + @Override - public void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, LocalDate pauseEnd) { - long pauseDays = java.time.temporal.ChronoUnit.DAYS.between(pauseStart, pauseEnd); + public void extendPeriodsForPause(final WorkingCapitalLoan loan, final LocalDate pauseStart, final LocalDate pauseEnd) { + final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd); List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); for (WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { if (period.getMinPaymentCriteriaMet() != null) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java index efdd52e1382..e8a49284fd8 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java @@ -25,6 +25,7 @@ import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.START_DATE; import com.google.gson.JsonElement; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import java.util.Objects; @@ -35,8 +36,10 @@ import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.validator.ParseAndValidator; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; @@ -48,49 +51,110 @@ @Component public class WorkingCapitalLoanDelinquencyActionParseAndValidator extends ParseAndValidator { + private static final String MINIMUM_PAYMENT = "minimumPayment"; + private static final String FREQUENCY = "frequency"; + private static final String FREQUENCY_TYPE = "frequencyType"; + private final FromJsonHelper jsonHelper; private final WorkingCapitalLoanDelinquencyRangeScheduleRepository rangeScheduleRepository; public WorkingCapitalLoanDelinquencyAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, final List existing) { - WorkingCapitalLoanDelinquencyAction parsedAction = parseCommand(command); + final WorkingCapitalLoanDelinquencyAction parsedAction = parseCommand(command); validateLoanIsActive(workingCapitalLoan); - validateBothDatesProvided(parsedAction); - validateStartBeforeEnd(parsedAction); - validateNotBeforeDisbursement(parsedAction, workingCapitalLoan); - validateNotInEvaluatedPeriod(parsedAction, workingCapitalLoan); - validateNoOverlap(parsedAction, existing); + + if (DelinquencyAction.PAUSE.equals(parsedAction.getAction())) { + validatePause(parsedAction, workingCapitalLoan, existing); + } else if (DelinquencyAction.RESCHEDULE.equals(parsedAction.getAction())) { + validateReschedule(parsedAction, workingCapitalLoan); + } + return parsedAction; } + private void validatePause(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan, + final List existing) { + validateBothDatesProvided(action); + validateStartBeforeEnd(action); + validateNotBeforeDisbursement(action, workingCapitalLoan); + validateNotInEvaluatedPeriod(action, workingCapitalLoan); + validateNoOverlap(action, existing); + } + + private void validateReschedule(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan) { + validateLoanIsDisbursed(workingCapitalLoan); + validateScheduleExists(workingCapitalLoan); + validateMinimumPaymentProvided(action); + validateFrequencyProvided(action); + } + private WorkingCapitalLoanDelinquencyAction parseCommand(final JsonCommand command) { - JsonElement json = command.parsedJson(); - WorkingCapitalLoanDelinquencyAction action = new WorkingCapitalLoanDelinquencyAction(); + final JsonElement json = command.parsedJson(); + final WorkingCapitalLoanDelinquencyAction action = new WorkingCapitalLoanDelinquencyAction(); action.setAction(extractAction(json)); - action.setStartDate(extractDate(json, START_DATE)); - action.setEndDate(extractDate(json, END_DATE)); + + if (DelinquencyAction.PAUSE.equals(action.getAction())) { + action.setStartDate(extractDate(json, START_DATE)); + action.setEndDate(extractDate(json, END_DATE)); + } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { + action.setStartDate(DateUtils.getBusinessLocalDate()); + action.setMinimumPayment(extractBigDecimal(json, MINIMUM_PAYMENT)); + action.setFrequency(extractInteger(json, FREQUENCY)); + action.setFrequencyType(extractFrequencyType(json)); + } + return action; } private DelinquencyAction extractAction(final JsonElement json) { - String actionString = jsonHelper.extractStringNamed(ACTION, json); + final String actionString = jsonHelper.extractStringNamed(ACTION, json); if (StringUtils.isEmpty(actionString)) { raiseValidationError("wc-loan-delinquency-action-missing-action", "Delinquency Action must not be null or empty", ACTION); } - if (!"pause".equalsIgnoreCase(actionString)) { - throw new PlatformApiDataValidationException( - List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-action", - "Only PAUSE action is supported. Invalid action: " + actionString, ACTION))); + if ("pause".equalsIgnoreCase(actionString)) { + return DelinquencyAction.PAUSE; + } else if ("reschedule".equalsIgnoreCase(actionString)) { + return DelinquencyAction.RESCHEDULE; } - return DelinquencyAction.PAUSE; + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-action", + "Invalid Delinquency Action: " + actionString + ". Supported actions: pause, reschedule", ACTION))); } private LocalDate extractDate(final JsonElement json, final String paramName) { - String dateFormat = jsonHelper.extractStringNamed(DATE_FORMAT, json); - String locale = jsonHelper.extractStringNamed(LOCALE, json); + final String dateFormat = jsonHelper.extractStringNamed(DATE_FORMAT, json); + final String locale = jsonHelper.extractStringNamed(LOCALE, json); return jsonHelper.extractLocalDateNamed(paramName, json, dateFormat, JsonParserHelper.localeFromString(locale)); } + private BigDecimal extractBigDecimal(final JsonElement json, final String paramName) { + if (json.getAsJsonObject().has(paramName)) { + return jsonHelper.extractBigDecimalWithLocaleNamed(paramName, json); + } + return null; + } + + private Integer extractInteger(final JsonElement json, final String paramName) { + if (json.getAsJsonObject().has(paramName)) { + return jsonHelper.extractIntegerWithLocaleNamed(paramName, json); + } + return null; + } + + private DelinquencyFrequencyType extractFrequencyType(final JsonElement json) { + final String value = jsonHelper.extractStringNamed(FREQUENCY_TYPE, json); + if (StringUtils.isEmpty(value)) { + return null; + } + try { + return DelinquencyFrequencyType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PlatformApiDataValidationException( + List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-frequency-type", + "Invalid frequency type: " + value + ". Supported: DAYS, WEEKS, MONTHS, YEARS", FREQUENCY_TYPE)), + e); + } + } + private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan) { if (!workingCapitalLoan.getLoanStatus().isActive()) { raiseValidationError("wc-loan-delinquency-action-invalid-loan-state", @@ -98,6 +162,41 @@ private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan) { } } + private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan) { + final boolean isDisbursed = workingCapitalLoan.getDisbursementDetails().stream() + .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); + if (!isDisbursed) { + raiseValidationError("wc-loan-delinquency-action-loan-not-disbursed", "Reschedule action requires the loan to be disbursed."); + } + } + + private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan) { + final List periods = rangeScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); + if (periods.isEmpty()) { + raiseValidationError("wc-loan-delinquency-action-no-schedule", + "Reschedule action requires an existing delinquency range schedule."); + } + } + + private void validateMinimumPaymentProvided(final WorkingCapitalLoanDelinquencyAction action) { + if (action.getMinimumPayment() == null || action.getMinimumPayment().compareTo(BigDecimal.ZERO) <= 0) { + raiseValidationError("wc-loan-delinquency-action-invalid-minimum-payment", + "The parameter `minimumPayment` must be greater than 0", MINIMUM_PAYMENT); + } + } + + private void validateFrequencyProvided(final WorkingCapitalLoanDelinquencyAction action) { + if (action.getFrequency() == null || action.getFrequency() <= 0) { + raiseValidationError("wc-loan-delinquency-action-invalid-frequency", "The parameter `frequency` must be greater than 0", + FREQUENCY); + } + if (action.getFrequencyType() == null) { + raiseValidationError("wc-loan-delinquency-action-missing-frequency-type", + "The parameter `frequencyType` is mandatory for reschedule action", FREQUENCY_TYPE); + } + } + private void validateBothDatesProvided(final WorkingCapitalLoanDelinquencyAction action) { if (action.getStartDate() == null) { raiseValidationError("wc-loan-delinquency-action-pause-startDate-cannot-be-blank", "The parameter `startDate` is mandatory", @@ -121,7 +220,7 @@ private void validateNotBeforeDisbursement(final WorkingCapitalLoanDelinquencyAc if (action.getStartDate() == null) { return; } - LocalDate firstDisbursementDate = workingCapitalLoan.getDisbursementDetails().stream() + final LocalDate firstDisbursementDate = workingCapitalLoan.getDisbursementDetails().stream() .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).filter(Objects::nonNull).findFirst().orElse(null); if (firstDisbursementDate != null && firstDisbursementDate.isAfter(action.getStartDate())) { raiseValidationError("wc-loan-delinquency-action-invalid-start-date", @@ -134,9 +233,9 @@ private void validateNotInEvaluatedPeriod(final WorkingCapitalLoanDelinquencyAct if (action.getStartDate() == null) { return; } - List periods = rangeScheduleRepository + final List periods = rangeScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); - boolean startsInEvaluatedPeriod = periods.stream().filter(p -> p.getMinPaymentCriteriaMet() != null) + final boolean startsInEvaluatedPeriod = periods.stream().filter(p -> p.getMinPaymentCriteriaMet() != null) .anyMatch(p -> !action.getStartDate().isAfter(p.getToDate())); if (startsInEvaluatedPeriod) { raiseValidationError("wc-loan-delinquency-action-pause-in-evaluated-period", @@ -149,7 +248,8 @@ private void validateNoOverlap(final WorkingCapitalLoanDelinquencyAction parsed, if (parsed.getStartDate() == null || parsed.getEndDate() == null) { return; } - boolean overlaps = existing.stream().anyMatch(e -> isOverlapping(parsed, e)); + final boolean overlaps = existing.stream().filter(e -> DelinquencyAction.PAUSE.equals(e.getAction())) + .anyMatch(e -> isOverlapping(parsed, e)); if (overlaps) { raiseValidationError("wc-loan-delinquency-action-overlapping", "Delinquency pause period cannot overlap with another pause period"); diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 84a8aab3329..52bebc178d3 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 @@ -37,4 +37,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0016_wc_loan_delinquency_action_reschedule.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0016_wc_loan_delinquency_action_reschedule.xml new file mode 100644 index 00000000000..67d590a25d2 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0016_wc_loan_delinquency_action_reschedule.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java index 408609455b4..3369edb0852 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDelinquencyActionHelper.java @@ -20,6 +20,7 @@ import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; @@ -36,6 +37,20 @@ public final class WorkingCapitalLoanDelinquencyActionHelper { private WorkingCapitalLoanDelinquencyActionHelper() {} + public static PostWorkingCapitalLoansDelinquencyActionResponse createRescheduleAction(final Long loanId, + final BigDecimal minimumPayment, final int frequency, final String frequencyType) { + final PostWorkingCapitalLoansDelinquencyActionRequest request = new PostWorkingCapitalLoansDelinquencyActionRequest(); + request.setAction("reschedule"); + request.setMinimumPayment(minimumPayment); + request.setFrequency(frequency); + request.setFrequencyType(frequencyType); + request.setLocale("en"); + log.info("Creating RESCHEDULE delinquency action for loan {} minimumPayment={} frequency={} {}", loanId, minimumPayment, frequency, + frequencyType); + return ok(() -> FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanDelinquencyActions() + .createDelinquencyAction(loanId, request)); + } + public static PostWorkingCapitalLoansDelinquencyActionResponse createDelinquencyAction(final Long loanId, final String action, final LocalDate startDate, final LocalDate endDate) { final PostWorkingCapitalLoansDelinquencyActionRequest request = buildActionRequest(action, startDate, endDate);