From 33607ac4b1bb38e1279a76e68f28ab999bb47110 Mon Sep 17 00:00:00 2001 From: mariiaKraievska Date: Tue, 16 Jun 2026 14:43:48 +0300 Subject: [PATCH] FINERACT-2455: WC - Breach Management - Recalculation when Breach evaluation is changed(breach reschedule) --- .../client/feign/FineractFeignClient.java | 5 + .../service/CommandWrapperBuilder.java | 9 + .../WorkingCapitalLoanRequestFactory.java | 8 + .../WorkingCapitalBreachActionStepDef.java | 201 ++++++++++++ .../WorkingCapitalLoanAccountStepDef.java | 6 +- .../WorkingCapitalBreachReschedule.feature | 296 ++++++++++++++++++ ...ingCapitalLoanBreachActionApiResource.java | 135 ++++++++ ...talLoanBreachActionApiResourceSwagger.java | 64 ++++ .../WorkingCapitalLoanBreachActionData.java | 31 ++ .../WorkingCapitalLoanBreachAction.java | 73 +++++ ...CapitalLoanBreachActionCommandHandler.java | 43 +++ ...kingCapitalLoanBreachActionRepository.java | 34 ++ ...ngCapitalLoanBreachScheduleRepository.java | 2 + ...ingCapitalLoanBreachActionReadService.java | 28 ++ ...apitalLoanBreachActionReadServiceImpl.java | 52 +++ ...ngCapitalLoanBreachActionWriteService.java | 28 ++ ...pitalLoanBreachActionWriteServiceImpl.java | 71 +++++ ...rkingCapitalLoanBreachScheduleService.java | 3 + ...gCapitalLoanBreachScheduleServiceImpl.java | 152 ++++++++- ...italLoanBreachActionParseAndValidator.java | 205 ++++++++++++ .../module-changelog-master.xml | 2 + .../parts/0044_wc_loan_breach_action.xml | 151 +++++++++ .../0045_wc_loan_breach_action_reschedule.xml | 41 +++ .../persistence.xml | 1 + 24 files changed, 1629 insertions(+), 12 deletions(-) create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResource.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResourceSwagger.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBreachActionData.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachAction.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/CreateWorkingCapitalLoanBreachActionCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadServiceImpl.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_breach_action.xml create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_breach_action_reschedule.xml diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java index e2851b5db61..1ed76801d0a 100644 --- a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java +++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java @@ -156,6 +156,7 @@ import org.apache.fineract.client.feign.services.UsersApi; import org.apache.fineract.client.feign.services.WorkingCapitalBreachApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanAccountLockApi; +import org.apache.fineract.client.feign.services.WorkingCapitalLoanBreachActionsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanBreachScheduleApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanChargesApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanCobCatchUpApi; @@ -779,6 +780,10 @@ public WorkingCapitalLoanBreachScheduleApi workingCapitalLoanBreachSchedule() { return create(WorkingCapitalLoanBreachScheduleApi.class); } + public WorkingCapitalLoanBreachActionsApi workingCapitalLoanBreachActions() { + return create(WorkingCapitalLoanBreachActionsApi.class); + } + public InternalWorkingCapitalLoansApi internalWorkingCapitalLoans() { return create(InternalWorkingCapitalLoansApi.class); } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index f4a0dc6e19d..7e15c36dc1d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -881,6 +881,15 @@ public CommandWrapperBuilder createWorkingCapitalLoanDelinquencyAction(final Lon return this; } + public CommandWrapperBuilder createWorkingCapitalLoanBreachAction(final Long workingCapitalLoanId) { + this.actionName = "CREATE"; + this.entityName = "WC_BREACH_ACTION"; + this.entityId = workingCapitalLoanId; + this.loanId = workingCapitalLoanId; + this.href = "/working-capital-loans/" + workingCapitalLoanId + "/breach-actions"; + return this; + } + public CommandWrapperBuilder updateDiscountWorkingCapitalLoanApplication(final Long loanId) { this.actionName = "UPDATEDISCOUNT"; this.entityName = "WORKINGCAPITALLOAN"; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java index cb87f2b3392..83f99e035c7 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; +import org.apache.fineract.client.models.PostWorkingCapitalLoansBreachActionRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansDelinquencyActionRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; @@ -117,6 +118,13 @@ public PostWorkingCapitalLoansDelinquencyActionRequest defaultWorkingCapitalLoan .locale(DEFAULT_LOCALE);// } + public PostWorkingCapitalLoansBreachActionRequest defaultWorkingCapitalLoansBreachActionRequest(String action) { + return new PostWorkingCapitalLoansBreachActionRequest()// + .action(action)// + .dateFormat(DATE_FORMAT)// + .locale(DEFAULT_LOCALE);// + } + public PutWorkingCapitalLoansLoanIdDiscountRequest defaultWorkingCapitalLoanUpdateDiscountRequest() { return new PutWorkingCapitalLoansLoanIdDiscountRequest()// .discountAmount(DEFAULT_DISCOUNT).note("")// diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java new file mode 100644 index 00000000000..48535070504 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java @@ -0,0 +1,201 @@ +/** + * 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.PostWorkingCapitalLoansBreachActionRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansBreachActionResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse; +import org.apache.fineract.client.models.WorkingCapitalLoanBreachActionData; +import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; +import org.apache.fineract.test.factory.WorkingCapitalLoanRequestFactory; +import org.apache.fineract.test.stepdef.AbstractStepDef; +import org.apache.fineract.test.support.TestContextKey; + +@Slf4j +@RequiredArgsConstructor +public class WorkingCapitalBreachActionStepDef extends AbstractStepDef { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + + private final FineractFeignClient fineractFeignClient; + private final WorkingCapitalLoanRequestFactory workingCapitalLoanRequestFactory; + + @When("Admin creates WC breach reschedule action with the following parameters:") + public void createRescheduleAction(final DataTable table) { + final Map params = table.asMaps().getFirst(); + final PostWorkingCapitalLoansBreachActionRequest request = buildRescheduleRequest(params); + executeRescheduleAction(request); + } + + @Then("Admin fails to create WC breach 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 PostWorkingCapitalLoansBreachActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), + minimumPaymentType, frequency, frequencyType); + final CallFailedRuntimeException exception = fail( + () -> fineractFeignClient.workingCapitalLoanBreachActions().createBreachAction(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 breach reschedule action with no parameters with error containing {string}") + public void failToCreateEmptyRescheduleAction(final String expectedMessage) { + final Long loanId = getLoanId(); + final PostWorkingCapitalLoansBreachActionRequest request = buildRescheduleRequest(Map.of()); + + final CallFailedRuntimeException exception = fail( + () -> fineractFeignClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + assertThat(exception.getStatus()).as("HTTP status code").isEqualTo(400); + assertThat(exception.getDeveloperMessage()).as("Developer message").contains(expectedMessage); + } + + @Then("WC loan breach actions have the following data:") + public void verifyBreachActionsHistory(final DataTable table) { + final Long loanId = getLoanId(); + final List actions = retrieveBreachActions(loanId); + final List> expectedRows = table.asMaps(); + assertThat(actions).as("Breach actions count").hasSize(expectedRows.size()); + for (int i = 0; i < expectedRows.size(); i++) { + final WorkingCapitalLoanBreachActionData actual = actions.get(i); + final int rowNumber = i + 1; + expectedRows.get(i).forEach((field, value) -> verifyActionField(actual, field, value, rowNumber)); + } + log.info("Successfully verified {} breach action(s) for loan {}", actions.size(), loanId); + } + + @Then("Working Capital loan breach schedule periods have specific data:") + public void verifySpecificPeriods(final DataTable table) { + final Long loanId = getLoanId(); + final List periods = ok( + () -> fineractFeignClient.workingCapitalLoanBreachSchedule().retrieveBreachSchedule(loanId)); + + for (final Map expected : table.asMaps()) { + final int periodNumber = Integer.parseInt(expected.get("periodNumber")); + final WorkingCapitalLoanBreachScheduleData actual = periods.stream().filter(p -> { + assert p.getPeriodNumber() != null; + return p.getPeriodNumber().equals(periodNumber); + }).findFirst().orElse(null); + assertThat(actual).as("Period %d should exist", periodNumber).isNotNull(); + expected.forEach((field, value) -> verifyScheduleField(actual, field, value, periodNumber)); + } + } + + private void executeRescheduleAction(final PostWorkingCapitalLoansBreachActionRequest request) { + final Long loanId = getLoanId(); + log.info("Creating breach RESCHEDULE action for WC loan {}: {}", loanId, request); + + final PostWorkingCapitalLoansBreachActionResponse result = ok( + () -> fineractFeignClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isNotNull(); + log.info("Breach RESCHEDULE action created with id={}", result.getResourceId()); + } + + private List retrieveBreachActions(final Long loanId) { + return ok(() -> fineractFeignClient.workingCapitalLoanBreachActions().retrieveBreachActions(loanId)); + } + + private void verifyActionField(final WorkingCapitalLoanBreachActionData actual, final String field, final String expected, + final int rowNumber) { + final String label = "Action " + rowNumber + " " + field; + switch (field) { + case "action" -> { + assert actual.getAction() != null; + assertThat(actual.getAction().name()).as(label).isEqualTo(expected); + } + case "startDate" -> assertThat(actual.getStartDate()).as(label).isEqualTo(LocalDate.parse(expected, DATE_FORMAT)); + case "minimumPayment" -> assertThat(actual.getMinimumPayment()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "minimumPaymentType" -> + verifyOptionalField(expected, v -> assertThat(String.valueOf(actual.getMinimumPaymentType())).as(label).isEqualTo(v), + () -> assertThat(actual.getMinimumPaymentType()).as(label).isNull()); + case "frequency" -> assertThat(actual.getFrequency()).as(label).isEqualTo(Integer.parseInt(expected)); + case "frequencyType" -> + verifyOptionalField(expected, v -> assertThat(String.valueOf(actual.getFrequencyType())).as(label).isEqualTo(v), + () -> assertThat(actual.getFrequencyType()).as(label).isNull()); + default -> throw new IllegalArgumentException("Unknown action field: " + field); + } + } + + private void verifyScheduleField(final WorkingCapitalLoanBreachScheduleData 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)); + case "toDate" -> assertThat(actual.getToDate()).as(label).isEqualTo(LocalDate.parse(expected)); + case "numberOfDays" -> + verifyOptionalField(expected, v -> assertThat(actual.getNumberOfDays()).as(label).isEqualTo(Integer.parseInt(v)), + () -> assertThat(actual.getNumberOfDays()).as(label).isNull()); + case "minPaymentAmount" -> assertThat(actual.getMinPaymentAmount()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "outstandingAmount" -> assertThat(actual.getOutstandingAmount()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "nearBreach" -> + verifyOptionalField(expected, v -> assertThat(actual.getNearBreach()).as(label).isEqualTo(Boolean.parseBoolean(v)), + () -> assertThat(actual.getNearBreach()).as(label).isNull()); + case "breach" -> verifyOptionalField(expected, v -> assertThat(actual.getBreach()).as(label).isEqualTo(Boolean.parseBoolean(v)), + () -> assertThat(actual.getBreach()).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 PostWorkingCapitalLoansBreachActionRequest buildRescheduleRequest(final BigDecimal minimumPayment, + final String minimumPaymentType, final int frequency, final String frequencyType) { + return buildRescheduleRequest(Map.of("minimumPayment", minimumPayment.toPlainString(), "minimumPaymentType", minimumPaymentType, + "frequency", String.valueOf(frequency), "frequencyType", frequencyType)); + } + + private PostWorkingCapitalLoansBreachActionRequest buildRescheduleRequest(final Map params) { + final PostWorkingCapitalLoansBreachActionRequest request = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoansBreachActionRequest("reschedule"); + 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); + 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 73d35035d07..f0649f15b76 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 @@ -1954,10 +1954,12 @@ private Long extractClientId() { } private Long resolveLoanProductId(final String loanProductName) { - if ("WCLP_DELINQUENCY".equals(loanProductName)) { + if ("WCLP_DELINQUENCY".equals(loanProductName) || "WCLP_BREACH".equals(loanProductName)) { final PostWorkingCapitalLoanProductsResponse response = testContext() .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); - return response.getResourceId(); + if (response != null) { + return response.getResourceId(); + } } final DefaultWorkingCapitalLoanProduct product = DefaultWorkingCapitalLoanProduct.valueOf(loanProductName); return workingCapitalLoanProductResolver.resolve(product); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature new file mode 100644 index 00000000000..cbe7ba2ce59 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -0,0 +1,296 @@ +@WorkingCapital +@WorkingCapitalBreachRescheduleActionFeature @WCCOBFeature +Feature: Working Capital Breach Reschedule Action + + Scenario: Verify that breach reschedule changes minimumPayment only + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-01 | 2026-04-30 | 61 | 110.70 | 110.70 | null | true | + | 3 | 2026-05-01 | 2026-06-30 | 61 | 110.70 | 110.70 | null | null | + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 1 | PERCENTAGE | + When Admin sets the business date to "15 August 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2026-01-01 | 2026-02-28 | 110.70 | 110.70 | true | + | 2 | 2026-03-01 | 2026-04-30 | 110.70 | 110.70 | true | + | 3 | 2026-05-01 | 2026-06-30 | 90 | 90 | true | + | 4 | 2026-07-01 | 2026-08-31 | 90 | 90 | | + + Scenario: Verify that breach reschedule changes frequency only + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 30 | DAYS | + When Admin sets the business date to "15 August 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2026-01-01 | 2026-02-28 | 110.70 | 110.70 | true | + | 2 | 2026-03-01 | 2026-04-30 | 110.70 | 110.70 | true | + | 3 | 2026-05-01 | 2026-06-30 | 110.70 | 110.70 | true | + | 4 | 2026-07-01 | 2026-07-30 | 110.70 | 110.70 | true | + | 5 | 2026-07-31 | 2026-08-29 | 110.70 | 110.70 | | + + Scenario: Verify that breach reschedule changes minimumPayment and frequency + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 30 | DAYS | + When Admin sets the business date to "15 August 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2026-01-01 | 2026-02-28 | 110.70 | 110.70 | true | + | 2 | 2026-03-01 | 2026-04-30 | 110.70 | 110.70 | true | + | 3 | 2026-05-01 | 2026-06-30 | 90 | 90 | true | + | 4 | 2026-07-01 | 2026-07-30 | 90 | 90 | true | + | 5 | 2026-07-31 | 2026-08-29 | 90 | 90 | | + + Scenario: Verify that the latest breach reschedule action wins + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 30 | DAYS | + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 30 | DAYS | + When Admin sets the business date to "15 August 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 3 | 2026-05-01 | 2026-06-30 | 90 | 90 | true | + | 4 | 2026-07-01 | 2026-07-30 | 90 | 90 | true | + + Scenario: Verify multiple breach reschedules on the same date are stored in history + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 2 | PERCENTAGE | 2 | MONTHS | + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1 | PERCENTAGE | 2 | MONTHS | + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 1.5 | PERCENTAGE | 2 | MONTHS | + And WC loan breach actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 01 June 2026 | 2 | PERCENTAGE | 2 | MONTHS | + | RESCHEDULE | 01 June 2026 | 1 | PERCENTAGE | 2 | MONTHS | + | RESCHEDULE | 01 June 2026 | 1.5 | PERCENTAGE | 2 | MONTHS | + + Scenario: Verify breach reschedule fails when no change parameters are provided + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Admin fails to create WC breach reschedule action with no parameters with error containing "reschedule.no.change.parameters" + + Scenario: Verify breach reschedule fails with negative minimumPayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Admin fails to create WC breach reschedule action with minimumPayment -1 PERCENTAGE and frequency 30 DAYS with error containing "minimumPayment" + + Scenario: Verify that payment-only reschedule after frequency reschedule falls back to product breach frequency + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 June 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 30 | DAYS | + When Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 1 | PERCENTAGE | + When Admin sets the business date to "15 August 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2026-01-01 | 2026-02-28 | 110.70 | 110.70 | true | + | 2 | 2026-03-01 | 2026-04-30 | 110.70 | 110.70 | true | + | 3 | 2026-05-01 | 2026-06-30 | 90 | 90 | true | + | 4 | 2026-07-01 | 2026-08-31 | 90 | 90 | | + + Scenario: Verify breach reschedule updates current period after partial repayment and replays payments + When Admin sets the business date to "01 January 2019" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 90 | DAYS | PERCENTAGE | 9 | 3 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2019 | 01 January 2019 | 9000 | 100000 | 18 | 1000 | + And Admin successfully approves the working capital loan on "01 January 2019" with "9000" amount and expected disbursement date on "01 January 2019" + And Admin successfully disburse the Working Capital loan on "01 January 2019" with "9000" EUR transaction amount and "1000" discount amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "05 March 2019" + And Customer makes repayment on "05 March 2019" with 450.0 transaction amount on Working Capital loan + When Admin sets the business date to "10 March 2019" + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 5 | PERCENTAGE | + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 500.00 | 50.00 | | + + Scenario: Verify breach reschedule preserves already evaluated periods + When Admin sets the business date to "01 January 2019" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 90 | DAYS | PERCENTAGE | 10 | 3 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2019 | 01 January 2019 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2019" with "9000" amount and expected disbursement date on "01 January 2019" + And Admin successfully disburse the Working Capital loan on "01 January 2019" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "05 March 2019" + And Customer makes repayment on "05 March 2019" with 450.0 transaction amount on Working Capital loan + When Admin sets the business date to "06 April 2019" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 900.00 | 450.00 | true | + | 2 | 2019-04-01 | 2019-06-29 | 900.00 | 900.00 | | + When Admin sets the business date to "10 April 2019" + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 5 | PERCENTAGE | + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 900.00 | 450.00 | true | + | 2 | 2019-04-01 | 2019-06-29 | 450.00 | 450.00 | | + + Scenario: Verify breach reschedule changes frequency from 90 days to 30 days for current and future periods + When Admin sets the business date to "01 January 2019" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 90 | DAYS | PERCENTAGE | 9 | 3 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2019 | 01 January 2019 | 9000 | 100000 | 18 | 1000 | + And Admin successfully approves the working capital loan on "01 January 2019" with "9000" amount and expected disbursement date on "01 January 2019" + And Admin successfully disburse the Working Capital loan on "01 January 2019" with "9000" EUR transaction amount and "1000" discount amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "10 March 2019" + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 900.00 | 900.00 | | + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 30 | DAYS | + When Admin sets the business date to "04 April 2019" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 June 2019" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 900.00 | 900.00 | true | + | 2 | 2019-04-01 | 2019-04-30 | 900.00 | 900.00 | true | + | 3 | 2019-05-01 | 2019-05-30 | 900.00 | 900.00 | true | + | 4 | 2019-05-31 | 2019-06-29 | 900.00 | 900.00 | | + + Scenario: Verify breach reschedule changes minimum payment and frequency together + When Admin sets the business date to "01 January 2019" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 90 | DAYS | PERCENTAGE | 9 | 3 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2019 | 01 January 2019 | 9000 | 100000 | 18 | 1000 | + And Admin successfully approves the working capital loan on "01 January 2019" with "9000" amount and expected disbursement date on "01 January 2019" + And Admin successfully disburse the Working Capital loan on "01 January 2019" with "9000" EUR transaction amount and "1000" discount amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "10 March 2019" + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 900.00 | 900.00 | | + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 5 | PERCENTAGE | 30 | DAYS | + When Admin sets the business date to "04 April 2019" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 June 2019" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule periods have specific data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | + | 1 | 2019-01-01 | 2019-03-31 | 500.00 | 500.00 | true | + | 2 | 2019-04-01 | 2019-04-30 | 500.00 | 500.00 | true | + | 3 | 2019-05-01 | 2019-05-30 | 500.00 | 500.00 | true | + | 4 | 2019-05-31 | 2019-06-29 | 500.00 | 500.00 | | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResource.java new file mode 100644 index 00000000000..3fbc49daa2c --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResource.java @@ -0,0 +1,135 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachActionData; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanBreachActionReadService; +import org.springframework.stereotype.Component; + +@Path("/v1/working-capital-loans") +@Component +@Tag(name = "Working Capital Loan Breach Actions", description = "Manages breach reschedule actions for Working Capital loans") +@RequiredArgsConstructor +public class WorkingCapitalLoanBreachActionApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "WC_BREACH_ACTION"; + + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final WorkingCapitalLoanBreachActionReadService readService; + private final WorkingCapitalLoanApplicationReadPlatformService loanReadPlatformService; + + @POST + @Path("{loanId}/breach-actions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Create Breach Action", description = "Creates a breach reschedule action for a Working Capital loan.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanBreachActionApiResourceSwagger.PostWorkingCapitalLoansBreachActionRequest.class))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanBreachActionApiResourceSwagger.PostWorkingCapitalLoansBreachActionResponse.class))), + @ApiResponse(responseCode = "400", description = "Bad Request"), + @ApiResponse(responseCode = "404", description = "Working Capital Loan not found") }) + public CommandProcessingResult createBreachAction(@PathParam("loanId") @Parameter(description = "loanId") final Long loanId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + this.context.authenticatedUser().validateHasCreatePermission(RESOURCE_NAME_FOR_PERMISSIONS); + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .createWorkingCapitalLoanBreachAction(loanId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("external-id/{loanExternalId}/breach-actions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "createBreachActionByExternalId", summary = "Create Breach Action by external id", description = "Creates a breach reschedule action for a Working Capital loan identified by external id.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanBreachActionApiResourceSwagger.PostWorkingCapitalLoansBreachActionRequest.class))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanBreachActionApiResourceSwagger.PostWorkingCapitalLoansBreachActionResponse.class))), + @ApiResponse(responseCode = "400", description = "Bad Request"), + @ApiResponse(responseCode = "404", description = "Working Capital Loan not found") }) + public CommandProcessingResult createBreachAction( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return createBreachAction(resolveExternalId(loanExternalId), apiRequestBodyAsJson); + } + + @GET + @Path("{loanId}/breach-actions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Breach Actions", description = "Retrieves all breach actions for a Working Capital loan") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = WorkingCapitalLoanBreachActionData.class)))) }) + public List retrieveBreachActions( + @PathParam("loanId") @Parameter(description = "loanId") final Long loanId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readService.retrieveBreachActions(loanId); + } + + @GET + @Path("external-id/{loanExternalId}/breach-actions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveBreachActionsByExternalId", summary = "Retrieve Breach Actions by external id", description = "Retrieves all breach actions for a Working Capital loan identified by external id") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = WorkingCapitalLoanBreachActionData.class)))) }) + public List retrieveBreachActions( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId) { + return retrieveBreachActions(resolveExternalId(loanExternalId)); + } + + private Long resolveExternalId(final String loanExternalIdStr) { + final ExternalId externalId = ExternalIdFactory.produce(loanExternalIdStr); + final Long resolvedLoanId = loanReadPlatformService.getResolvedLoanId(externalId); + if (resolvedLoanId == null) { + throw new WorkingCapitalLoanNotFoundException(externalId); + } + return resolvedLoanId; + } + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResourceSwagger.java new file mode 100644 index 00000000000..117ba5b72ab --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResourceSwagger.java @@ -0,0 +1,64 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; + +public final class WorkingCapitalLoanBreachActionApiResourceSwagger { + + private WorkingCapitalLoanBreachActionApiResourceSwagger() {} + + @Schema(description = "PostWorkingCapitalLoansBreachActionRequest") + public static final class PostWorkingCapitalLoansBreachActionRequest { + + private PostWorkingCapitalLoansBreachActionRequest() {} + + @Schema(example = "reschedule", description = "Breach action type: reschedule") + public String action; + @Schema(example = "33.33", 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") + public String locale; + } + + @Schema(description = "PostWorkingCapitalLoansBreachActionResponse") + public static final class PostWorkingCapitalLoansBreachActionResponse { + + private PostWorkingCapitalLoansBreachActionResponse() {} + + @Schema(example = "1") + public Long officeId; + + @Schema(example = "1") + public Long clientId; + + @Schema(example = "1") + public Long resourceId; + } + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBreachActionData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBreachActionData.java new file mode 100644 index 00000000000..fba96d752c7 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBreachActionData.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; + +public record WorkingCapitalLoanBreachActionData(Long id, DelinquencyAction action, LocalDate startDate, LocalDate endDate, + BigDecimal minimumPayment, WorkingCapitalBreachAmountCalculationType minimumPaymentType, Integer frequency, + WorkingCapitalLoanPeriodFrequencyType frequencyType) { + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachAction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachAction.java new file mode 100644 index 00000000000..0a0fa3eb200 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachAction.java @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +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.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "m_wc_loan_breach_action") +public class WorkingCapitalLoanBreachAction extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "wc_loan_id", nullable = false) + private WorkingCapitalLoan workingCapitalLoan; + + @Enumerated(EnumType.STRING) + @Column(name = "action", nullable = false) + private DelinquencyAction action; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @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 WorkingCapitalBreachAmountCalculationType minimumPaymentType; + + @Column(name = "frequency") + private Integer frequency; + + @Enumerated(EnumType.STRING) + @Column(name = "frequency_type") + private WorkingCapitalLoanPeriodFrequencyType frequencyType; + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/CreateWorkingCapitalLoanBreachActionCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/CreateWorkingCapitalLoanBreachActionCommandHandler.java new file mode 100644 index 00000000000..1ab7d124c9b --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/CreateWorkingCapitalLoanBreachActionCommandHandler.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanBreachActionWriteService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "WC_BREACH_ACTION", action = "CREATE") +public class CreateWorkingCapitalLoanBreachActionCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanBreachActionWriteService writeService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return writeService.createBreachAction(command.entityId(), command); + } + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java new file mode 100644 index 00000000000..d2cec8de334 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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.WorkingCapitalLoanBreachAction; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanBreachActionRepository extends JpaRepository { + + 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/repository/WorkingCapitalLoanBreachScheduleRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java index e36098ea2e7..c53ccd21d5c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java @@ -34,4 +34,6 @@ public interface WorkingCapitalLoanBreachScheduleRepository extends JpaRepositor Optional findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(Long loanId, LocalDate transactionDate, LocalDate transactionDate1); + + List findByLoanIdAndToDateBeforeAndBreach(Long loanId, LocalDate toDateBefore, Boolean breach); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadService.java new file mode 100644 index 00000000000..955b12b3e65 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadService.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.util.List; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachActionData; + +public interface WorkingCapitalLoanBreachActionReadService { + + List retrieveBreachActions(Long workingCapitalLoanId); + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadServiceImpl.java new file mode 100644 index 00000000000..8e8a59e69c4 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadServiceImpl.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachActionData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class WorkingCapitalLoanBreachActionReadServiceImpl implements WorkingCapitalLoanBreachActionReadService { + + private final WorkingCapitalLoanBreachActionRepository actionRepository; + private final WorkingCapitalLoanRepository loanRepository; + + @Transactional(readOnly = true) + @Override + public List retrieveBreachActions(final Long workingCapitalLoanId) { + if (!loanRepository.existsById(workingCapitalLoanId)) { + throw new WorkingCapitalLoanNotFoundException(workingCapitalLoanId); + } + return actionRepository.findByWorkingCapitalLoanIdOrderById(workingCapitalLoanId).stream().map(this::toData).toList(); + } + + private WorkingCapitalLoanBreachActionData toData(final WorkingCapitalLoanBreachAction action) { + return new WorkingCapitalLoanBreachActionData(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/WorkingCapitalLoanBreachActionWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteService.java new file mode 100644 index 00000000000..13423b77529 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteService.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface WorkingCapitalLoanBreachActionWriteService { + + CommandProcessingResult createBreachAction(Long workingCapitalLoanId, JsonCommand command); + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java new file mode 100644 index 00000000000..2b0c4c0cbd7 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.WorkingCapitalLoanBreachAction; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParseAndValidator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkingCapitalLoanBreachActionWriteServiceImpl implements WorkingCapitalLoanBreachActionWriteService { + + private final WorkingCapitalLoanRepository loanRepository; + private final WorkingCapitalLoanBreachActionRepository actionRepository; + private final WorkingCapitalLoanBreachActionParseAndValidator validator; + private final WorkingCapitalLoanBreachScheduleService breachScheduleService; + + @Transactional + @Override + public CommandProcessingResult createBreachAction(final Long workingCapitalLoanId, final JsonCommand command) { + final WorkingCapitalLoan workingCapitalLoan = loanRepository.findById(workingCapitalLoanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(workingCapitalLoanId)); + + final WorkingCapitalLoanBreachAction action = validator.validateAndParse(command, workingCapitalLoan); + action.setWorkingCapitalLoan(workingCapitalLoan); + + final WorkingCapitalLoanBreachAction saved = actionRepository.saveAndFlush(action); + log.debug("Created WC loan breach action {} for loan {}", action.getAction(), workingCapitalLoanId); + + if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { + breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); + } + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(saved.getId()) // + .withLoanId(workingCapitalLoanId) // + .withOfficeId(workingCapitalLoan.getOfficeId()) // + .withClientId(workingCapitalLoan.getClientId()) // + .build(); + } + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java index 67ebee0740a..f304387523d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java @@ -23,6 +23,7 @@ import java.util.List; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; public interface WorkingCapitalLoanBreachScheduleService { @@ -40,4 +41,6 @@ public interface WorkingCapitalLoanBreachScheduleService { void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal amount); void evaluateBreach(WorkingCapitalLoan loan, LocalDate businessDate); + + void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction rescheduleAction); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java index b57b257cbbb..38e656d2f2d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java @@ -26,15 +26,19 @@ 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.workingcapitalloan.data.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanBreachScheduleMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloanbreach.domain.WorkingCapitalBreach; @@ -50,6 +54,7 @@ public class WorkingCapitalLoanBreachScheduleServiceImpl implements WorkingCapit private final WorkingCapitalLoanBreachScheduleRepository repository; private final WorkingCapitalLoanBreachScheduleMapper mapper; private final WorkingCapitalLoanRepository loanRepository; + private final WorkingCapitalLoanBreachActionRepository breachActionRepository; @Override public void generateInitialPeriod(final WorkingCapitalLoan loan) { @@ -66,8 +71,11 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) { final LocalDate fromDate = disbursementDateOptional.get().plusDays(getBreachGraceDays(loan)); final WorkingCapitalBreach breach = breachOpt.get(); - final LocalDate toDate = calculateToDate(fromDate, breach.getBreachFrequency(), breach.getBreachFrequencyType()); - final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach); + final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); + final Integer effectiveFrequency = resolveFrequency(latestReschedule.orElse(null), breach); + final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = resolveFrequencyType(latestReschedule.orElse(null), breach); + final LocalDate toDate = calculateToDate(fromDate, effectiveFrequency, effectiveFreqType); + final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach, latestReschedule.orElse(null)); final WorkingCapitalLoanBreachSchedule period = createPeriod(loan, 1, fromDate, toDate, minPaymentAmount); repository.saveAndFlush(period); @@ -92,13 +100,16 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca } final WorkingCapitalBreach breach = breachOpt.get(); - final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach); + final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); + final Integer effectiveFrequency = resolveFrequency(latestReschedule.orElse(null), breach); + final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = resolveFrequencyType(latestReschedule.orElse(null), breach); + final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach, latestReschedule.orElse(null)); final List newPeriods = new ArrayList<>(); WorkingCapitalLoanBreachSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); - final LocalDate newToDate = calculateToDate(newFromDate, breach.getBreachFrequency(), breach.getBreachFrequencyType()); + final LocalDate newToDate = calculateToDate(newFromDate, effectiveFrequency, effectiveFreqType); final WorkingCapitalLoanBreachSchedule nextPeriod = createPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate, newToDate, minPaymentAmount); @@ -170,6 +181,52 @@ public List retrieveBreachSchedule(final L return mapper.toDataList(periods); } + @Override + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachAction rescheduleAction) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final Optional breachOpt = getBreachConfig(loan); + if (breachOpt.isEmpty()) { + log.warn("No breach configuration found for WC loan {}, skipping reschedule", loan.getId()); + return; + } + final WorkingCapitalBreach breach = breachOpt.get(); + final BigDecimal newMinPaymentAmount = calculateMinPaymentAmount(loan, breach, rescheduleAction); + final Integer newFrequency = resolveFrequency(rescheduleAction, breach); + final WorkingCapitalLoanPeriodFrequencyType newFreqType = resolveFrequencyType(rescheduleAction, breach); + + final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + + WorkingCapitalLoanBreachSchedule currentPeriod = null; + final List futurePeriods = new ArrayList<>(); + + for (final WorkingCapitalLoanBreachSchedule period : periods) { + if (period.getBreach() != 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.setMinPaymentAmount(newMinPaymentAmount); + period.setOutstandingAmount(newMinPaymentAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); + period.setNearBreach(null); + } else if (isFuture) { + futurePeriods.add(period); + } + } + + if (currentPeriod != null) { + repository.saveAndFlush(currentPeriod); + updateFuturePeriods(currentPeriod, futurePeriods, newMinPaymentAmount, newFrequency, newFreqType); + } + + evaluateExpiredBreaches(loan, businessDate); + + log.debug("Rescheduled breach schedule for WC loan {}: new minimumPayment={} {}, frequency={} {}", loan.getId(), + rescheduleAction.getMinimumPayment(), rescheduleAction.getMinimumPaymentType(), newFrequency, newFreqType); + } + private WorkingCapitalLoanBreachSchedule createPeriod(final WorkingCapitalLoan loan, final int periodNumber, final LocalDate fromDate, final LocalDate toDate, final BigDecimal minPaymentAmount) { final int numberOfDays = (int) ChronoUnit.DAYS.between(fromDate, toDate) + 1; @@ -210,13 +267,16 @@ private LocalDate calculateToDate(final LocalDate fromDate, final Integer freque }; } - private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, final WorkingCapitalBreach breach) { - final BigDecimal breachAmount = breach.getBreachAmount(); - if (breachAmount == null) { + private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, final WorkingCapitalBreach breach, + final WorkingCapitalLoanBreachAction rescheduleOverride) { + final BigDecimal effectiveBreachAmount = resolveBreachAmount(rescheduleOverride, breach); + if (effectiveBreachAmount == null) { return BigDecimal.ZERO; } - if (WorkingCapitalBreachAmountCalculationType.FLAT.equals(breach.getBreachAmountCalculationType())) { - return breachAmount; + final WorkingCapitalBreachAmountCalculationType effectiveCalculationType = resolveBreachAmountCalculationType(rescheduleOverride, + breach); + if (WorkingCapitalBreachAmountCalculationType.FLAT.equals(effectiveCalculationType)) { + return effectiveBreachAmount; } final BigDecimal principal = loan.getApprovedPrincipal(); if (principal == null) { @@ -224,8 +284,80 @@ private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, fina } final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() : null; final BigDecimal base = discount != null ? principal.add(discount) : principal; - final BigDecimal rawAmount = MathUtil.percentageOf(base, breachAmount, MoneyHelper.getMathContext()); + final BigDecimal rawAmount = MathUtil.percentageOf(base, effectiveBreachAmount, MoneyHelper.getMathContext()); return Money.of(loan.getLoanProductRelatedDetails().getCurrency(), rawAmount).getAmount(); } + private Optional findLatestRescheduleAction(final Long loanId) { + return breachActionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); + } + + private Integer resolveFrequency(final WorkingCapitalLoanBreachAction rescheduleOverride, final WorkingCapitalBreach breach) { + if (rescheduleOverride != null && rescheduleOverride.getFrequency() != null) { + return rescheduleOverride.getFrequency(); + } + return breach.getBreachFrequency(); + } + + private WorkingCapitalLoanPeriodFrequencyType resolveFrequencyType(final WorkingCapitalLoanBreachAction rescheduleOverride, + final WorkingCapitalBreach breach) { + if (rescheduleOverride != null && rescheduleOverride.getFrequencyType() != null) { + return rescheduleOverride.getFrequencyType(); + } + return breach.getBreachFrequencyType(); + } + + private BigDecimal resolveBreachAmount(final WorkingCapitalLoanBreachAction rescheduleOverride, final WorkingCapitalBreach breach) { + if (rescheduleOverride != null && rescheduleOverride.getMinimumPayment() != null) { + return rescheduleOverride.getMinimumPayment(); + } + return breach.getBreachAmount(); + } + + private WorkingCapitalBreachAmountCalculationType resolveBreachAmountCalculationType( + final WorkingCapitalLoanBreachAction rescheduleOverride, final WorkingCapitalBreach breach) { + if (rescheduleOverride != null && rescheduleOverride.getMinimumPaymentType() != null) { + return rescheduleOverride.getMinimumPaymentType(); + } + return breach.getBreachAmountCalculationType() != null ? breach.getBreachAmountCalculationType() + : WorkingCapitalBreachAmountCalculationType.PERCENTAGE; + } + + private void evaluateExpiredBreaches(final WorkingCapitalLoan loan, final LocalDate businessDate) { + final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + for (final WorkingCapitalLoanBreachSchedule period : periods) { + if (period.getBreach() != null) { + continue; + } + if (!period.getToDate().isAfter(businessDate) && evaluateBreachOnDate(period, businessDate)) { + repository.saveAndFlush(period); + } + } + } + + private void updateFuturePeriods(final WorkingCapitalLoanBreachSchedule currentPeriod, + final List existingFuturePeriods, final BigDecimal minPaymentAmount, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType) { + int periodNumber = currentPeriod.getPeriodNumber(); + LocalDate fromDate = currentPeriod.getToDate().plusDays(1); + + for (final WorkingCapitalLoanBreachSchedule period : existingFuturePeriods) { + final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType); + periodNumber++; + + period.setPeriodNumber(periodNumber); + period.setFromDate(fromDate); + period.setToDate(toDate); + period.setNumberOfDays((int) ChronoUnit.DAYS.between(fromDate, toDate) + 1); + period.setMinPaymentAmount(minPaymentAmount); + period.setPaidAmount(BigDecimal.ZERO); + period.setOutstandingAmount(minPaymentAmount); + period.setNearBreach(null); + period.setBreach(null); + + fromDate = toDate.plusDays(1); + } + repository.saveAll(existingFuturePeriods); + } + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java new file mode 100644 index 00000000000..03c3558a817 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java @@ -0,0 +1,205 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.validator; + +import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.ACTION; + +import com.google.gson.JsonElement; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +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.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class WorkingCapitalLoanBreachActionParseAndValidator extends ParseAndValidator { + + private static final String RESCHEDULE_ACTION = "reschedule"; + 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 WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; + + public WorkingCapitalLoanBreachAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan) { + final DataValidatorBuilder dataValidator = new DataValidatorBuilder(new ArrayList<>()).resource("workingCapitalLoanBreachAction"); + final WorkingCapitalLoanBreachAction parsedAction = parseCommand(command, dataValidator); + validateLoanIsActive(workingCapitalLoan, dataValidator); + + if (DelinquencyAction.RESCHEDULE.equals(parsedAction.getAction())) { + validateReschedule(parsedAction, workingCapitalLoan, dataValidator); + } else if (parsedAction.getAction() != null) { + dataValidator.reset().parameter(ACTION).value(parsedAction.getAction()).failWithCode("invalid.action"); + } + + throwExceptionIfValidationWarningsExist(dataValidator); + return parsedAction; + } + + private WorkingCapitalLoanBreachAction parseCommand(final JsonCommand command, final DataValidatorBuilder dataValidator) { + final JsonElement json = command.parsedJson(); + final WorkingCapitalLoanBreachAction action = new WorkingCapitalLoanBreachAction(); + action.setAction(extractAction(json, dataValidator)); + action.setStartDate(DateUtils.getBusinessLocalDate()); + action.setMinimumPayment(extractBigDecimal(json, MINIMUM_PAYMENT)); + action.setMinimumPaymentType(extractMinimumPaymentType(json, dataValidator)); + action.setFrequency(extractInteger(json, FREQUENCY)); + action.setFrequencyType(extractFrequencyType(json, dataValidator)); + return action; + } + + private DelinquencyAction extractAction(final JsonElement json, final DataValidatorBuilder dataValidator) { + final String actionString = jsonHelper.extractStringNamed(ACTION, json); + dataValidator.reset().parameter(ACTION).value(actionString).notBlank(); + if (StringUtils.isNotBlank(actionString)) { + dataValidator.reset().parameter(ACTION).value(actionString).isOneOfTheseStringValues(RESCHEDULE_ACTION); + } + if (RESCHEDULE_ACTION.equalsIgnoreCase(actionString)) { + return DelinquencyAction.RESCHEDULE; + } + return null; + } + + 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 WorkingCapitalBreachAmountCalculationType extractMinimumPaymentType(final JsonElement json, + final DataValidatorBuilder dataValidator) { + final String value = jsonHelper.extractStringNamed(MINIMUM_PAYMENT_TYPE, json); + if (StringUtils.isEmpty(value)) { + return null; + } + try { + return WorkingCapitalBreachAmountCalculationType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + dataValidator.reset().parameter(MINIMUM_PAYMENT_TYPE).value(value).failWithCode("invalid.minimumPaymentType"); + return null; + } + } + + private WorkingCapitalLoanPeriodFrequencyType extractFrequencyType(final JsonElement json, final DataValidatorBuilder dataValidator) { + final String value = jsonHelper.extractStringNamed(FREQUENCY_TYPE, json); + if (StringUtils.isEmpty(value)) { + return null; + } + try { + return WorkingCapitalLoanPeriodFrequencyType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + dataValidator.reset().parameter(FREQUENCY_TYPE).value(value).failWithCode("invalid.frequencyType"); + return null; + } + } + + private void validateReschedule(final WorkingCapitalLoanBreachAction action, final WorkingCapitalLoan workingCapitalLoan, + final DataValidatorBuilder dataValidator) { + validateLoanIsDisbursed(workingCapitalLoan, dataValidator); + validateScheduleExists(workingCapitalLoan, dataValidator); + validateBreachConfigured(workingCapitalLoan, dataValidator); + + final boolean hasPaymentGroup = action.getMinimumPayment() != null || action.getMinimumPaymentType() != null; + final boolean hasFrequencyGroup = action.getFrequency() != null || action.getFrequencyType() != null; + + if (!hasPaymentGroup && !hasFrequencyGroup) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("reschedule.no.change.parameters"); + } + if (hasPaymentGroup) { + validateMinimumPaymentGroupProvided(action, dataValidator); + } + if (hasFrequencyGroup) { + validateFrequencyGroupProvided(action, dataValidator); + } + } + + private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { + if (!workingCapitalLoan.getLoanStatus().isActive()) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.is.not.active"); + } + } + + private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { + final boolean isDisbursed = workingCapitalLoan.getDisbursementDetails().stream() + .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); + if (!isDisbursed) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.not.disbursed"); + } + } + + private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { + final List periods = breachScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); + if (periods.isEmpty()) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.schedule"); + } + } + + private void validateBreachConfigured(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { + if (workingCapitalLoan.getLoanProductRelatedDetails() == null + || workingCapitalLoan.getLoanProductRelatedDetails().getBreach() == null) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.configuration"); + } + } + + private void validateMinimumPaymentGroupProvided(final WorkingCapitalLoanBreachAction action, + final DataValidatorBuilder dataValidator) { + if (action.getMinimumPayment() == null || action.getMinimumPayment().compareTo(BigDecimal.ZERO) <= 0) { + dataValidator.reset().parameter(MINIMUM_PAYMENT).value(action.getMinimumPayment()).failWithCode("must.be.greater.than.zero"); + } + if (action.getMinimumPaymentType() == null) { + dataValidator.reset().parameter(MINIMUM_PAYMENT_TYPE).value(action.getMinimumPaymentType()).notNull(); + } + } + + private void validateFrequencyGroupProvided(final WorkingCapitalLoanBreachAction action, final DataValidatorBuilder dataValidator) { + if (action.getFrequency() == null || action.getFrequency() <= 0) { + dataValidator.reset().parameter(FREQUENCY).value(action.getFrequency()).integerGreaterThanZero(); + } + if (action.getFrequencyType() == null) { + dataValidator.reset().parameter(FREQUENCY_TYPE).value(action.getFrequencyType()).notNull(); + } + } +} 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 25190c291cb..e10f296597b 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 @@ -65,4 +65,6 @@ + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_breach_action.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_breach_action.xml new file mode 100644 index 00000000000..661f9e729bd --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_breach_action.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_WC_BREACH_ACTION' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_WC_BREACH_ACTION' + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_breach_action_reschedule.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_breach_action_reschedule.xml new file mode 100644 index 00000000000..420a587bc22 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_breach_action_reschedule.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml b/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml index 17fa19e7760..4b4993d4975 100644 --- a/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml +++ b/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml @@ -162,6 +162,7 @@ org.apache.fineract.cob.domain.WorkingCapitalLoanAccountLock org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeScheduleTagHistory