From b78cce5f0297ddd262db91fbcb52a2759f4c0478 Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Thu, 11 Jun 2026 11:21:44 +0300 Subject: [PATCH 1/3] FINERACT-2455: Add WC Breach pause --- .../client/feign/FineractFeignClient.java | 5 + .../service/CommandWrapperBuilder.java | 9 + .../WorkingCapitalLoanRequestFactory.java | 10 + .../WorkingCapitalBreachActionStepDef.java | 223 ++++++++++++ .../WorkingCapitalBreachPause.feature | 337 ++++++++++++++++++ ...ingCapitalLoanBreachActionApiResource.java | 135 +++++++ ...talLoanBreachActionApiResourceSwagger.java | 59 +++ .../WorkingCapitalLoanBreachActionData.java | 27 ++ .../WorkingCapitalLoanBreachAction.java | 56 +++ .../WorkingCapitalLoanBreachActionType.java | 24 ++ ...CapitalLoanBreachActionCommandHandler.java | 43 +++ ...kingCapitalLoanBreachActionRepository.java | 29 ++ ...ingCapitalLoanBreachActionReadService.java | 28 ++ ...apitalLoanBreachActionReadServiceImpl.java | 51 +++ ...ngCapitalLoanBreachActionWriteService.java | 28 ++ ...pitalLoanBreachActionWriteServiceImpl.java | 71 ++++ ...rkingCapitalLoanBreachScheduleService.java | 2 + ...gCapitalLoanBreachScheduleServiceImpl.java | 71 +++- ...kingCapitalLoanBreachActionParameters.java | 30 ++ ...italLoanBreachActionParseAndValidator.java | 143 ++++++++ .../module-changelog-master.xml | 1 + .../parts/0044_wc_loan_breach_action.xml | 127 +++++++ 22 files changed, 1508 insertions(+), 1 deletion(-) 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/WorkingCapitalBreachPause.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/domain/WorkingCapitalLoanBreachActionType.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/WorkingCapitalLoanBreachActionParameters.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 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 d96301cbf7d..7f4036ad7f2 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..2ce5ab2ff46 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,15 @@ public PostWorkingCapitalLoansDelinquencyActionRequest defaultWorkingCapitalLoan .locale(DEFAULT_LOCALE);// } + public PostWorkingCapitalLoansBreachActionRequest defaultWorkingCapitalLoansBreachActionRequest(final String action) { + return new PostWorkingCapitalLoansBreachActionRequest()// + .action(action)// + .startDate(DATE_SUBMIT_STRING)// + .endDate(DATE_SUBMIT_STRING)// + .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..ebb993e4969 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java @@ -0,0 +1,223 @@ +/** + * 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.time.LocalDate; +import java.util.List; +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.test.factory.WorkingCapitalLoanRequestFactory; +import org.apache.fineract.test.stepdef.AbstractStepDef; +import org.apache.fineract.test.support.TestContextKey; +import org.junit.jupiter.api.Assertions; + +@Slf4j +@RequiredArgsConstructor +public class WorkingCapitalBreachActionStepDef extends AbstractStepDef { + + private static final Long NON_EXISTENT_LOAN_ID = 999999999L; + + private final FineractFeignClient fineractClient; + private final WorkingCapitalLoanRequestFactory workingCapitalLoanRequestFactory; + + @Then("Retrieving breach actions for a non-existent Working Capital loan results in a 404 error") + public void retrieveBreachActionsForNonExistentLoanResultsInNotFound() { + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanBreachActions().retrieveBreachActions(NON_EXISTENT_LOAN_ID)); + + assertThat(exception.getStatus()).as("HTTP status code should be 404").isEqualTo(404); + + log.info("Verified breach actions retrieval failed with 404 for non-existent loan {}", NON_EXISTENT_LOAN_ID); + } + + @When("Admin initiate a Working Capital loan breach pause with startDate {string} and endDate {string}") + public void initiateBreachPause(final String startDate, final String endDate) { + final Long loanId = extractLoanId(); + final PostWorkingCapitalLoansBreachActionRequest request = buildBreachActionRequest("pause", startDate, endDate); + final PostWorkingCapitalLoansBreachActionResponse response = createBreachActionById(loanId, request); + + log.debug("Breach pause initiated for loan {} with startDate: {}, endDate: {}, response: {}", loanId, startDate, endDate, response); + } + + @When("Admin initiate a Working Capital loan breach pause by external ID with startDate {string} and endDate {string}") + public void initiateBreachPauseByExternalId(final String startDate, final String endDate) { + final String loanExternalId = extractLoanExternalId(); + final PostWorkingCapitalLoansBreachActionRequest request = buildBreachActionRequest("pause", startDate, endDate); + final PostWorkingCapitalLoansBreachActionResponse response = createBreachActionByExternalId(loanExternalId, request); + + log.debug("Breach pause initiated for loan externalId {} with startDate: {}, endDate: {}, response: {}", loanExternalId, startDate, + endDate, response); + } + + @Then("Initiating a Working Capital loan breach pause with startDate {string} and endDate {string} results an error with the following data:") + public void initiateBreachPauseResultsAnError(final String startDate, final String endDate, final DataTable table) { + initiateBreachActionResultsAnError("pause", startDate, endDate, table); + } + + @Then("Initiating a Working Capital loan breach action {string} with startDate {string} and endDate {string} results an error with the following data:") + public void initiateBreachActionResultsAnError(final String action, final String startDate, final String endDate, + final DataTable table) { + final Long loanId = extractLoanId(); + + final PostWorkingCapitalLoansBreachActionRequest request = buildBreachActionRequest(action, startDate, endDate); + + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + + verifyBreachActionErrorWithTable(exception, table); + + log.info("Verified breach action initiation failed with expected error for loan {}", loanId); + } + + @Then("Initiating a Working Capital loan breach action without {string} results an error with the following data:") + public void initiateBreachActionWithoutFieldResultsAnError(final String omittedField, final DataTable table) { + final Long loanId = extractLoanId(); + + final PostWorkingCapitalLoansBreachActionRequest request = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoansBreachActionRequest("pause"); + switch (omittedField) { + case "action" -> request.action(null); + case "startDate" -> request.startDate(null); + case "endDate" -> request.endDate(null); + default -> throw new IllegalArgumentException("Unknown breach action field: " + omittedField); + } + + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + + verifyBreachActionErrorWithTable(exception, table); + + log.info("Verified breach action initiation without '{}' failed with expected error for loan {}", omittedField, loanId); + } + + @Then("Working Capital loan breach action has the following data:") + public void verifyBreachActions(final DataTable dataTable) { + final Long loanId = extractLoanId(); + final List actualActions = retrieveBreachActions(loanId); + verifyBreachActionsWithTable(actualActions, dataTable); + } + + @Then("Working Capital loan breach action by external ID has the following data:") + public void verifyBreachActionsByExternalId(final DataTable dataTable) { + final String loanExternalId = extractLoanExternalId(); + final List actualActions = retrieveBreachActionsByExternalId(loanExternalId); + verifyBreachActionsWithTable(actualActions, dataTable); + } + + private void verifyBreachActionsWithTable(final List actualActions, final DataTable dataTable) { + assertThat(actualActions).as("Breach actions should not be empty").isNotEmpty(); + + final List> rows = dataTable.asLists(); + final List headers = rows.getFirst(); + final List> expectedData = rows.subList(1, rows.size()); + + assertThat(actualActions).as("Breach actions size should match expected data").hasSize(expectedData.size()); + + for (int i = 0; i < expectedData.size(); i++) { + final List expectedRow = expectedData.get(i); + final WorkingCapitalLoanBreachActionData actualAction = actualActions.get(i); + + for (int j = 0; j < headers.size(); j++) { + final String header = headers.get(j); + final String expectedValue = expectedRow.get(j); + verifyBreachActionField(actualAction, header, expectedValue, i + 1); + } + } + + log.info("Successfully verified {} breach action(s)", actualActions.size()); + } + + private void verifyBreachActionField(final WorkingCapitalLoanBreachActionData actual, final String fieldName, + final String expectedValue, final int rowNumber) { + Assertions.assertNotNull(actual.getAction()); + switch (fieldName) { + case "action" -> assertThat(actual.getAction().name()).as("Action for row %d", rowNumber).isEqualTo(expectedValue); + case "startDate" -> + assertThat(actual.getStartDate()).as("Start date for row %d", rowNumber).isEqualTo(LocalDate.parse(expectedValue)); + case "endDate" -> + assertThat(actual.getEndDate()).as("End date for row %d", rowNumber).isEqualTo(LocalDate.parse(expectedValue)); + default -> throw new IllegalArgumentException("Unknown field name: " + fieldName); + } + } + + private Long extractLoanId() { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + return loanResponse.getLoanId(); + } + + private String extractLoanExternalId() { + final Long loanId = extractLoanId(); + return ok(() -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)).getExternalId(); + } + + private PostWorkingCapitalLoansBreachActionRequest buildBreachActionRequest(final String action, final String startDate, + final String endDate) { + return workingCapitalLoanRequestFactory.defaultWorkingCapitalLoansBreachActionRequest(action).startDate(startDate).endDate(endDate); + } + + private PostWorkingCapitalLoansBreachActionResponse createBreachActionById(final Long loanId, + final PostWorkingCapitalLoansBreachActionRequest request) { + return ok(() -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + } + + private PostWorkingCapitalLoansBreachActionResponse createBreachActionByExternalId(final String loanExternalId, + final PostWorkingCapitalLoansBreachActionRequest request) { + return ok(() -> fineractClient.workingCapitalLoanBreachActions().createBreachActionByExternalId(loanExternalId, request)); + } + + private List retrieveBreachActions(final Long loanId) { + final List actions = ok( + () -> fineractClient.workingCapitalLoanBreachActions().retrieveBreachActions(loanId)); + log.debug("Breach actions for loan {}: {}", loanId, actions); + return actions; + } + + private List retrieveBreachActionsByExternalId(final String loanExternalId) { + final List actions = ok( + () -> fineractClient.workingCapitalLoanBreachActions().retrieveBreachActionsByExternalId(loanExternalId)); + log.debug("Breach actions for loan externalId {}: {}", loanExternalId, actions); + return actions; + } + + private void verifyBreachActionErrorWithTable(final CallFailedRuntimeException exception, final DataTable table) { + final List> data = table.asLists(); + final String expectedHttpCode = data.get(1).get(0); + final String expectedErrorMessage = data.get(1).get(1); + + log.info("Checking for Http code: {} and error message: \"{}\"", expectedHttpCode, expectedErrorMessage); + + assertThat(exception.getStatus()).as("HTTP status code should be " + expectedHttpCode) + .isEqualTo(Integer.parseInt(expectedHttpCode)); + assertThat(exception.getMessage()).as("Should contain error message").contains(expectedErrorMessage); + } + +} diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature new file mode 100644 index 00000000000..06b3d3e539a --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -0,0 +1,337 @@ +@WorkingCapital +@WorkingCapitalBreachPauseFeature +Feature: Working Capital Breach Pause + + Scenario: Verify working capital loan breach pause - pause in current period extends breach schedule and does not affect delinquency schedule + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan 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 | null | + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-01-25 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + + Scenario: Verify working capital loan breach pause - backdated pause re-triggers evaluation of an already evaluated period + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 March 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 | null | + When Admin initiate a Working Capital loan breach pause with startDate "20 February 2026" and endDate "02 March 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-02-20 | 2026-03-02 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | + | 2 | 2026-03-11 | 2026-05-10 | 61 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "11 March 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-03-10 | 69 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-11 | 2026-05-10 | 61 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - backdated pause keeps breach flag when extended period still ends in the past + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 April 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 | null | + When Admin initiate a Working Capital loan breach pause with startDate "20 February 2026" and endDate "25 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-02-20 | 2026-02-25 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-05 | 64 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-06 | 2026-05-05 | 61 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - multiple non-overlapping pauses are cumulative + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" + And Admin initiate a Working Capital loan breach pause with startDate "01 February 2026" and endDate "06 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-01-25 | + | PAUSE | 2026-02-01 | 2026-02-06 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-15 | 74 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - overlapping pauses are rejected + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" + Then Initiating a Working Capital loan breach pause with startDate "20 January 2026" and endDate "30 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: overlapping.pause.periods | + And Initiating a Working Capital loan breach pause with startDate "10 January 2026" and endDate "30 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: overlapping.pause.periods | + And Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-01-25 | + + Scenario: Verify working capital loan breach pause - breach pause and delinquency pause are independent + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "15 January 2026" and endDate "25 January 2026" + And Admin initiate a Working Capital loan breach pause with startDate "10 January 2026" and endDate "30 January 2026" + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-02-09 | 270.0 | 0.0 | 270.0 | null | null | null | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-20 | 79 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - next period is generated from the extended period and recorded pauses apply to it + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" + And Admin initiate a Working Capital loan breach pause with startDate "20 March 2026" and endDate "25 March 2026" + When Admin sets the business date to "11 March 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-03-10 | 69 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-11 | 2026-05-15 | 66 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - future pause beyond the schedule end is preserved when a later backdated pause extends the period over its window + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "05 March 2026" and endDate "08 March 2026" + 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 | null | + When Admin initiate a Working Capital loan breach pause with startDate "01 February 2026" and endDate "10 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-03-05 | 2026-03-08 | + | PAUSE | 2026-02-01 | 2026-02-10 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-12 | 71 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "13 March 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-03-12 | 71 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-13 | 2026-05-12 | 61 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - pause created before the first COB run is applied to the initial period + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin initiate a Working Capital loan breach pause with startDate "05 January 2026" and endDate "15 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-05 | 2026-01-15 | + When 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-03-10 | 69 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - validation errors + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach pause with startDate "15 January 2026" and endDate "15 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.be.after.startDate | + And Initiating a Working Capital loan breach pause with startDate "25 January 2026" and endDate "15 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.be.after.startDate | + And Initiating a Working Capital loan breach pause with startDate "25 December 2025" and endDate "05 January 2026" results an error with the following data: + | httpCode | message | + | 400 | The parameter `startDate` must be greater than or equal to the provided date: 2026-01-01 | + And Initiating a Working Capital loan breach action "resume" with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: + | httpCode | message | + | 400 | The parameter `action` must be one of [ pause ] | + And Initiating a Working Capital loan breach action without "action" results an error with the following data: + | httpCode | message | + | 400 | The parameter `action` is mandatory | + And Initiating a Working Capital loan breach action without "startDate" results an error with the following data: + | httpCode | message | + | 400 | The parameter `startDate` is mandatory | + And Initiating a Working Capital loan breach action without "endDate" results an error with the following data: + | httpCode | message | + | 400 | The parameter `endDate` is mandatory | + And Retrieving breach actions for a non-existent Working Capital loan results in a 404 error + + Scenario: Verify working capital loan breach pause - pause is rejected for a loan without breach configuration + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: no.breach.configuration | + + Scenario: Verify working capital loan breach pause - pause start date is validated against the grace-shifted breach schedule start + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | + | 7 | DAYS | PERCENTAGE | 9 | 3 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 1000 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-04 | 2026-01-10 | 7 | 900.00 | 900.00 | null | null | + And Initiating a Working Capital loan breach pause with startDate "01 January 2026" and endDate "10 January 2026" results an error with the following data: + | httpCode | message | + | 400 | The parameter `startDate` must be greater than or equal to the provided date: 2026-01-04 | + When Admin initiate a Working Capital loan breach pause with startDate "04 January 2026" and endDate "08 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-04 | 2026-01-08 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-04 | 2026-01-14 | 11 | 900.00 | 900.00 | null | null | + + Scenario: Verify working capital loan breach pause - pause is rejected for a not yet active loan + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Initiating a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: loan.is.not.active | + + Scenario: Verify working capital loan breach pause - backdated payment resets breach flag of an already breached period + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 March 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 | null | + When Admin initiate a Working Capital loan breach pause with startDate "20 March 2026" and endDate "30 March 2026" + 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-05-10 | 71 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "14 April 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin makes Internal Payment "150.0" on "2026-02-15" + 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 | 0.00 | null | false | + | 2 | 2026-03-01 | 2026-05-10 | 71 | 110.70 | 110.70 | null | null | 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..7a98299104b --- /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 pause 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 action (pause) 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 action (pause) 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..ca4ec16ee75 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanBreachActionApiResourceSwagger.java @@ -0,0 +1,59 @@ +/** + * 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; + +public final class WorkingCapitalLoanBreachActionApiResourceSwagger { + + private WorkingCapitalLoanBreachActionApiResourceSwagger() {} + + @Schema(description = "PostWorkingCapitalLoansBreachActionRequest") + public static final class PostWorkingCapitalLoansBreachActionRequest { + + private PostWorkingCapitalLoansBreachActionRequest() {} + + @Schema(example = "pause", description = "Breach action type: pause") + public String action; + @Schema(example = "2026-03-05", description = "Start date of the pause period") + public String startDate; + @Schema(example = "2026-03-12", description = "End date of the pause period") + public String endDate; + @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..29ce26c5d2c --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanBreachActionData.java @@ -0,0 +1,27 @@ +/** + * 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.time.LocalDate; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType; + +public record WorkingCapitalLoanBreachActionData(Long id, WorkingCapitalLoanBreachActionType action, LocalDate startDate, + LocalDate endDate) { + +} 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..a8b5ea5bbc6 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachAction.java @@ -0,0 +1,56 @@ +/** + * 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.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +@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 WorkingCapitalLoanBreachActionType action; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachActionType.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachActionType.java new file mode 100644 index 00000000000..519ac11efad --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachActionType.java @@ -0,0 +1,24 @@ +/** + * 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; + +public enum WorkingCapitalLoanBreachActionType { + PAUSE, // + RESUME // +} 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..569adc5911b --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java @@ -0,0 +1,29 @@ +/** + * 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 org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanBreachActionRepository extends JpaRepository { + + List findByWorkingCapitalLoanIdOrderById(Long workingCapitalLoanId); + +} 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..475424372aa --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionReadServiceImpl.java @@ -0,0 +1,51 @@ +/** + * 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()); + } + +} 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..2106c2ce279 --- /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 java.util.List; +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.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 List existing = actionRepository.findByWorkingCapitalLoanIdOrderById(workingCapitalLoanId); + + final WorkingCapitalLoanBreachAction action = validator.validateAndParse(command, workingCapitalLoan, existing); + action.setWorkingCapitalLoan(workingCapitalLoan); + + final WorkingCapitalLoanBreachAction saved = actionRepository.saveAndFlush(action); + log.debug("Created WC loan breach action {} for loan {}", action.getAction(), workingCapitalLoanId); + + breachScheduleService.recalculatePeriodsForPauses(workingCapitalLoan); + + 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..fb2c11bb2e3 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 @@ -40,4 +40,6 @@ public interface WorkingCapitalLoanBreachScheduleService { void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal amount); void evaluateBreach(WorkingCapitalLoan loan, LocalDate businessDate); + + void recalculatePeriodsForPauses(WorkingCapitalLoan loan); } 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..b26b0c66976 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 @@ -22,19 +22,24 @@ import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; 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.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.WorkingCapitalLoanBreachActionType; 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 +55,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) { @@ -70,6 +76,7 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) { final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach); final WorkingCapitalLoanBreachSchedule period = createPeriod(loan, 1, fromDate, toDate, minPaymentAmount); + applyRecordedPauses(period, findRecordedPauses(loan.getId())); repository.saveAndFlush(period); log.debug("Generated initial breach schedule period for WC loan {}", loan.getId()); } @@ -93,6 +100,7 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca final WorkingCapitalBreach breach = breachOpt.get(); final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach); + final List recordedPauses = findRecordedPauses(loan.getId()); final List newPeriods = new ArrayList<>(); WorkingCapitalLoanBreachSchedule latestPeriod = latestPeriodOpt.get(); @@ -102,6 +110,7 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca final WorkingCapitalLoanBreachSchedule nextPeriod = createPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate, newToDate, minPaymentAmount); + applyRecordedPauses(nextPeriod, recordedPauses); newPeriods.add(nextPeriod); latestPeriod = nextPeriod; } @@ -138,7 +147,7 @@ private void applyRepayment(final WorkingCapitalLoanBreachSchedule period, BigDe BigDecimal newPaidAmount = period.getPaidAmount().add(payAmount); period.setPaidAmount(newPaidAmount); period.setOutstandingAmount(period.getOutstandingAmount().subtract(payAmount).max(BigDecimal.ZERO)); - if (period.getOutstandingAmount().compareTo(BigDecimal.ZERO) == 0 && period.getBreach() == null) { + if (period.getOutstandingAmount().compareTo(BigDecimal.ZERO) == 0) { period.setBreach(false); } repository.saveAndFlush(period); @@ -170,6 +179,66 @@ public List retrieveBreachSchedule(final L return mapper.toDataList(periods); } + @Override + public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) { + final Optional breachOpt = getBreachConfig(loan); + if (breachOpt.isEmpty()) { + return; + } + final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + if (periods.isEmpty()) { + return; + } + final WorkingCapitalBreach breach = breachOpt.get(); + final List recordedPauses = findRecordedPauses(loan.getId()); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + LocalDate fromDate = periods.getFirst().getFromDate(); + for (final WorkingCapitalLoanBreachSchedule period : periods) { + period.setFromDate(fromDate); + period.setToDate(calculateToDate(fromDate, breach.getBreachFrequency(), breach.getBreachFrequencyType())); + applyRecordedPauses(period, recordedPauses); + recomputeBreach(period, businessDate); + fromDate = period.getToDate().plusDays(1); + } + repository.saveAll(periods); + log.debug("Recalculated breach schedule periods for WC loan {} by replaying {} recorded pauses", loan.getId(), + recordedPauses.size()); + } + + private void recomputeBreach(final WorkingCapitalLoanBreachSchedule period, final LocalDate businessDate) { + if (period.getOutstandingAmount().compareTo(BigDecimal.ZERO) == 0) { + period.setBreach(false); + } else if (businessDate.isAfter(period.getToDate())) { + // COB evaluates with effective date businessDate-1, so breach is set only after the toDate has passed + period.setBreach(true); + } else { + period.setBreach(null); + } + } + + private List findRecordedPauses(final Long loanId) { + return breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId).stream() + .filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) + .sorted(Comparator.comparing(WorkingCapitalLoanBreachAction::getStartDate)).toList(); + } + + private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, + final List pauseActions) { + for (final WorkingCapitalLoanBreachAction pause : pauseActions) { + final LocalDate pauseStart = pause.getStartDate(); + final LocalDate pauseEnd = pause.getEndDate(); + // Apply only if the pause overlaps this period's date range + if (pauseEnd.isAfter(period.getFromDate()) && !pauseStart.isAfter(period.getToDate())) { + final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd); + period.setToDate(period.getToDate().plusDays(pauseDays)); + if (period.getFromDate().isAfter(pauseStart)) { + period.setFromDate(period.getFromDate().plusDays(pauseDays)); + } + } + } + period.setNumberOfDays((int) ChronoUnit.DAYS.between(period.getFromDate(), period.getToDate()) + 1); + } + 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; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParameters.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParameters.java new file mode 100644 index 00000000000..e6a520d0093 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParameters.java @@ -0,0 +1,30 @@ +/** + * 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; + +public final class WorkingCapitalLoanBreachActionParameters { + + private WorkingCapitalLoanBreachActionParameters() {} + + public static final String ACTION = "action"; + public static final String START_DATE = "startDate"; + public static final String END_DATE = "endDate"; + public static final String DATE_FORMAT = "dateFormat"; + public static final String LOCALE = "locale"; +} 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..82367493989 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java @@ -0,0 +1,143 @@ +/** + * 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.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.ACTION; +import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.DATE_FORMAT; +import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.END_DATE; +import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.LOCALE; +import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.START_DATE; + +import com.google.gson.JsonElement; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +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.serialization.JsonParserHelper; +import org.apache.fineract.infrastructure.core.validator.ParseAndValidator; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class WorkingCapitalLoanBreachActionParseAndValidator extends ParseAndValidator { + + private static final String PAUSE_ACTION = "pause"; + + private final FromJsonHelper jsonHelper; + private final WorkingCapitalLoanRepository loanRepository; + + public WorkingCapitalLoanBreachAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, + final List existing) { + final DataValidatorBuilder dataValidator = new DataValidatorBuilder(new ArrayList<>()).resource("workingCapitalLoanBreachAction"); + final JsonElement json = command.parsedJson(); + + 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(PAUSE_ACTION); + } + + final LocalDate startDate = extractDate(json, START_DATE); + dataValidator.reset().parameter(START_DATE).value(startDate).notNull(); + + final LocalDate endDate = extractDate(json, END_DATE); + dataValidator.reset().parameter(END_DATE).value(endDate).notNull(); + + validateLoanIsActive(dataValidator, workingCapitalLoan); + validateBreachConfigurationExists(dataValidator, workingCapitalLoan); + validateStartBeforeEnd(dataValidator, startDate, endDate); + validateNotBeforeScheduleStart(dataValidator, startDate, workingCapitalLoan); + validateNoOverlap(dataValidator, startDate, endDate, existing); + + throwExceptionIfValidationWarningsExist(dataValidator); + + final WorkingCapitalLoanBreachAction action = new WorkingCapitalLoanBreachAction(); + action.setAction(WorkingCapitalLoanBreachActionType.PAUSE); + action.setStartDate(startDate); + action.setEndDate(endDate); + return action; + } + + private LocalDate extractDate(final JsonElement json, final String paramName) { + final String dateFormat = jsonHelper.extractStringNamed(DATE_FORMAT, json); + final String locale = jsonHelper.extractStringNamed(LOCALE, json); + return jsonHelper.extractLocalDateNamed(paramName, json, dateFormat, JsonParserHelper.localeFromString(locale)); + } + + private void validateLoanIsActive(final DataValidatorBuilder dataValidator, final WorkingCapitalLoan workingCapitalLoan) { + if (!workingCapitalLoan.getLoanStatus().isActive()) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.is.not.active"); + } + } + + private void validateBreachConfigurationExists(final DataValidatorBuilder dataValidator, final WorkingCapitalLoan workingCapitalLoan) { + final WorkingCapitalLoanProductRelatedDetails details = workingCapitalLoan.getLoanProductRelatedDetails(); + if (details == null || details.getBreach() == null) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.configuration"); + } + } + + private void validateStartBeforeEnd(final DataValidatorBuilder dataValidator, final LocalDate startDate, final LocalDate endDate) { + if (startDate != null && endDate != null && !startDate.isBefore(endDate)) { + dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.be.after.startDate"); + } + } + + private void validateNotBeforeScheduleStart(final DataValidatorBuilder dataValidator, final LocalDate startDate, + final WorkingCapitalLoan workingCapitalLoan) { + loanRepository.findFirstActualDisbursementDate(workingCapitalLoan.getId()) + .map(disbursementDate -> disbursementDate.plusDays(getBreachGraceDays(workingCapitalLoan))) + .ifPresent(scheduleStartDate -> dataValidator.reset().parameter(START_DATE).value(startDate) + .validateDateAfterOrEqual(scheduleStartDate)); + } + + private int getBreachGraceDays(final WorkingCapitalLoan workingCapitalLoan) { + final WorkingCapitalLoanProductRelatedDetails details = workingCapitalLoan.getLoanProductRelatedDetails(); + if (details == null || details.getBreachGraceDays() == null) { + return 0; + } + return details.getBreachGraceDays(); + } + + private void validateNoOverlap(final DataValidatorBuilder dataValidator, final LocalDate startDate, final LocalDate endDate, + final List existing) { + if (startDate == null || endDate == null) { + return; + } + final boolean overlaps = existing.stream().filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) + .anyMatch(action -> isOverlapping(startDate, endDate, action)); + if (overlaps) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("overlapping.pause.periods"); + } + } + + private boolean isOverlapping(final LocalDate startDate, final LocalDate endDate, final WorkingCapitalLoanBreachAction other) { + return startDate.isBefore(other.getEndDate()) && other.getStartDate().isBefore(endDate); + } + +} 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 6fcc1ddd652..6abc8492468 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 @@ -66,4 +66,5 @@ + 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..cbbc681ce69 --- /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,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_WC_BREACH_ACTION' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_WC_BREACH_ACTION' + + + + + + + + + + + From 226deba11f39e46b1e5c556c1d3479cb2dbc128b Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Wed, 17 Jun 2026 18:38:20 +0200 Subject: [PATCH 2/3] FINERACT-2455: Added d2d tests verifying WC Breach pause --- .../WorkingCapitalBreachPause.feature | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature index 06b3d3e539a..ce26e6e8250 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -2,6 +2,7 @@ @WorkingCapitalBreachPauseFeature Feature: Working Capital Breach Pause + @TestRailId:C85234 Scenario: Verify working capital loan breach pause - pause in current period extends breach schedule and does not affect delinquency schedule When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -31,6 +32,7 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + @TestRailId:C85235 Scenario: Verify working capital loan breach pause - backdated pause re-triggers evaluation of an already evaluated period When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -62,6 +64,7 @@ Feature: Working Capital Breach Pause | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | true | | 2 | 2026-03-11 | 2026-05-10 | 61 | 110.70 | 110.70 | null | null | + @TestRailId:C85236 Scenario: Verify working capital loan breach pause - backdated pause keeps breach flag when extended period still ends in the past When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -87,6 +90,7 @@ Feature: Working Capital Breach Pause | 1 | 2026-01-01 | 2026-03-05 | 64 | 110.70 | 110.70 | null | true | | 2 | 2026-03-06 | 2026-05-05 | 61 | 110.70 | 110.70 | null | null | + @TestRailId:C85237 Scenario: Verify working capital loan breach pause - multiple non-overlapping pauses are cumulative When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -109,6 +113,7 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-01 | 2026-03-15 | 74 | 110.70 | 110.70 | null | null | + @TestRailId:C85238 Scenario: Verify working capital loan breach pause - overlapping pauses are rejected When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -132,6 +137,7 @@ Feature: Working Capital Breach Pause | action | startDate | endDate | | PAUSE | 2026-01-15 | 2026-01-25 | + @TestRailId:C85239 Scenario: Verify working capital loan breach pause - breach pause and delinquency pause are independent When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -153,6 +159,7 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-01 | 2026-03-20 | 79 | 110.70 | 110.70 | null | null | + @TestRailId:C85240 Scenario: Verify working capital loan breach pause - next period is generated from the extended period and recorded pauses apply to it When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -174,6 +181,7 @@ Feature: Working Capital Breach Pause | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | true | | 2 | 2026-03-11 | 2026-05-15 | 66 | 110.70 | 110.70 | null | null | + @TestRailId:C85241 Scenario: Verify working capital loan breach pause - future pause beyond the schedule end is preserved when a later backdated pause extends the period over its window When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -205,6 +213,7 @@ Feature: Working Capital Breach Pause | 1 | 2026-01-01 | 2026-03-12 | 71 | 110.70 | 110.70 | null | true | | 2 | 2026-03-13 | 2026-05-12 | 61 | 110.70 | 110.70 | null | null | + @TestRailId:C85242 Scenario: Verify working capital loan breach pause - pause created before the first COB run is applied to the initial period When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -223,6 +232,7 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | + @TestRailId:C85243 Scenario: Verify working capital loan breach pause - validation errors When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -256,6 +266,7 @@ Feature: Working Capital Breach Pause | 400 | The parameter `endDate` is mandatory | And Retrieving breach actions for a non-existent Working Capital loan results in a 404 error + @TestRailId:C85244 Scenario: Verify working capital loan breach pause - pause is rejected for a loan without breach configuration When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -269,6 +280,7 @@ Feature: Working Capital Breach Pause | httpCode | message | | 400 | Failed data validation due to: no.breach.configuration | + @TestRailId:C85245 Scenario: Verify working capital loan breach pause - pause start date is validated against the grace-shifted breach schedule start When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -295,6 +307,7 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-04 | 2026-01-14 | 11 | 900.00 | 900.00 | null | null | + @TestRailId:C85246 Scenario: Verify working capital loan breach pause - pause is rejected for a not yet active loan When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -307,6 +320,7 @@ Feature: Working Capital Breach Pause | httpCode | message | | 400 | Failed data validation due to: loan.is.not.active | + @TestRailId:C85247 Scenario: Verify working capital loan breach pause - backdated payment resets breach flag of an already breached period When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -335,3 +349,77 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-01 | 2026-02-28 | 59 | 110.70 | 0.00 | null | false | | 2 | 2026-03-01 | 2026-05-10 | 71 | 110.70 | 110.70 | null | null | + + @TestRailId:C85248 + Scenario: Verify working capital loan breach pause - adjacent (touching) pauses are allowed and are cumulative + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # second pause starts exactly where the first ends; half-open [start,end) semantics mean no overlap (differs from delinquency predicate) + And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" + And Admin initiate a Working Capital loan breach pause with startDate "25 January 2026" and endDate "05 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-01-25 | + | PAUSE | 2026-01-25 | 2026-02-05 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-21 | 80 | 110.70 | 110.70 | null | null | + + @TestRailId:C85249 + Scenario: Verify working capital loan breach pause - a pre-existing payment is not re-bucketed when a later pause shifts the period boundary across its date + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "01 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "20 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # the 50.00 paid on 05 Mar falls in period 2 (01 Mar - 30 Apr) at the time it is made + And Admin makes Internal Payment "50.0" on "2026-03-05" + 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 | 60.70 | null | null | + # backdated pause inside period 1 pushes its end to 16 Mar, so period 1's window now covers 05 Mar, + # but the 50.00 paid that day stays credited to period 2 + When Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "31 January 2026" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-16 | 75 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-17 | 2026-05-16 | 61 | 110.70 | 60.70 | null | null | + + @TestRailId:C85250 + Scenario: Verify working capital loan breach pause - pause can be applied and retrieved by loan external id + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause by external ID with startDate "15 January 2026" and endDate "25 January 2026" + Then Working Capital loan breach action by external ID has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-01-25 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | From fe2fba601235c0e761877ad2270293eb88ec104e Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Mon, 22 Jun 2026 14:07:42 +0300 Subject: [PATCH 3/3] FINERACT-2455: Make WC Breach pause start and end dates inclusive --- .../WorkingCapitalBreachPause.feature | 105 +++++++++++------- ...gCapitalLoanBreachScheduleServiceImpl.java | 2 +- ...italLoanBreachActionParseAndValidator.java | 6 +- 3 files changed, 67 insertions(+), 46 deletions(-) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature index ce26e6e8250..d916e2d0c90 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -27,7 +27,7 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-01-15 | 2026-01-25 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | null | And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | @@ -55,14 +55,14 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-02-20 | 2026-03-02 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | - | 2 | 2026-03-11 | 2026-05-10 | 61 | 110.70 | 110.70 | null | null | - When Admin sets the business date to "11 March 2026" + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | null | + | 2 | 2026-03-12 | 2026-05-11 | 61 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "12 March 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-03-10 | 69 | 110.70 | 110.70 | null | true | - | 2 | 2026-03-11 | 2026-05-10 | 61 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-12 | 2026-05-11 | 61 | 110.70 | 110.70 | null | null | @TestRailId:C85236 Scenario: Verify working capital loan breach pause - backdated pause keeps breach flag when extended period still ends in the past @@ -87,8 +87,8 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-02-20 | 2026-02-25 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-05 | 64 | 110.70 | 110.70 | null | true | - | 2 | 2026-03-06 | 2026-05-05 | 61 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-06 | 65 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-07 | 2026-05-06 | 61 | 110.70 | 110.70 | null | null | @TestRailId:C85237 Scenario: Verify working capital loan breach pause - multiple non-overlapping pauses are cumulative @@ -111,7 +111,7 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-02-01 | 2026-02-06 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-15 | 74 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-17 | 76 | 110.70 | 110.70 | null | null | @TestRailId:C85238 Scenario: Verify working capital loan breach pause - overlapping pauses are rejected @@ -128,10 +128,10 @@ Feature: Working Capital Breach Pause And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" Then Initiating a Working Capital loan breach pause with startDate "20 January 2026" and endDate "30 January 2026" results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | Failed data validation due to: overlapping.pause.periods | And Initiating a Working Capital loan breach pause with startDate "10 January 2026" and endDate "30 January 2026" results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | Failed data validation due to: overlapping.pause.periods | And Working Capital loan breach action has the following data: | action | startDate | endDate | @@ -157,7 +157,7 @@ Feature: Working Capital Breach Pause | 1 | 2026-01-01 | 2026-02-09 | 270.0 | 0.0 | 270.0 | null | null | null | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-20 | 79 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-21 | 80 | 110.70 | 110.70 | null | null | @TestRailId:C85240 Scenario: Verify working capital loan breach pause - next period is generated from the extended period and recorded pauses apply to it @@ -174,12 +174,12 @@ Feature: Working Capital Breach Pause And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" And Admin initiate a Working Capital loan breach pause with startDate "20 March 2026" and endDate "25 March 2026" - When Admin sets the business date to "11 March 2026" + When Admin sets the business date to "12 March 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-03-10 | 69 | 110.70 | 110.70 | null | true | - | 2 | 2026-03-11 | 2026-05-15 | 66 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-12 | 2026-05-17 | 67 | 110.70 | 110.70 | null | null | @TestRailId:C85241 Scenario: Verify working capital loan breach pause - future pause beyond the schedule end is preserved when a later backdated pause extends the period over its window @@ -205,13 +205,13 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-02-01 | 2026-02-10 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-12 | 71 | 110.70 | 110.70 | null | null | - When Admin sets the business date to "13 March 2026" + | 1 | 2026-01-01 | 2026-03-14 | 73 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "15 March 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-03-12 | 71 | 110.70 | 110.70 | null | true | - | 2 | 2026-03-13 | 2026-05-12 | 61 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-14 | 73 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-15 | 2026-05-14 | 61 | 110.70 | 110.70 | null | null | @TestRailId:C85242 Scenario: Verify working capital loan breach pause - pause created before the first COB run is applied to the initial period @@ -230,7 +230,7 @@ Feature: Working Capital Breach Pause When 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-03-10 | 69 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | null | @TestRailId:C85243 Scenario: Verify working capital loan breach pause - validation errors @@ -243,17 +243,14 @@ Feature: Working Capital Breach Pause And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - Then Initiating a Working Capital loan breach pause with startDate "15 January 2026" and endDate "15 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.be.after.startDate | - And Initiating a Working Capital loan breach pause with startDate "25 January 2026" and endDate "15 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.be.after.startDate | + Then Initiating a Working Capital loan breach pause with startDate "25 January 2026" and endDate "15 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.be.on.or.after.startDate | And Initiating a Working Capital loan breach pause with startDate "25 December 2025" and endDate "05 January 2026" results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | The parameter `startDate` must be greater than or equal to the provided date: 2026-01-01 | And Initiating a Working Capital loan breach action "resume" with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | The parameter `action` must be one of [ pause ] | And Initiating a Working Capital loan breach action without "action" results an error with the following data: | httpCode | message | @@ -297,7 +294,7 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-04 | 2026-01-10 | 7 | 900.00 | 900.00 | null | null | And Initiating a Working Capital loan breach pause with startDate "01 January 2026" and endDate "10 January 2026" results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | The parameter `startDate` must be greater than or equal to the provided date: 2026-01-04 | When Admin initiate a Working Capital loan breach pause with startDate "04 January 2026" and endDate "08 January 2026" Then Working Capital loan breach action has the following data: @@ -305,7 +302,7 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-01-04 | 2026-01-08 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-04 | 2026-01-14 | 11 | 900.00 | 900.00 | null | null | + | 1 | 2026-01-04 | 2026-01-15 | 12 | 900.00 | 900.00 | null | null | @TestRailId:C85246 Scenario: Verify working capital loan breach pause - pause is rejected for a not yet active loan @@ -317,7 +314,7 @@ Feature: Working Capital Breach Pause | 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" Then Initiating a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: - | httpCode | message | + | httpCode | message | | 400 | Failed data validation due to: loan.is.not.active | @TestRailId:C85247 @@ -341,17 +338,17 @@ Feature: Working Capital Breach Pause 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-05-10 | 71 | 110.70 | 110.70 | null | null | + | 2 | 2026-03-01 | 2026-05-11 | 72 | 110.70 | 110.70 | null | null | When Admin sets the business date to "14 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId And Admin makes Internal Payment "150.0" on "2026-02-15" 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 | 0.00 | null | false | - | 2 | 2026-03-01 | 2026-05-10 | 71 | 110.70 | 110.70 | null | null | + | 2 | 2026-03-01 | 2026-05-11 | 72 | 110.70 | 110.70 | null | null | @TestRailId:C85248 - Scenario: Verify working capital loan breach pause - adjacent (touching) pauses are allowed and are cumulative + Scenario: Verify working capital loan breach pause - touching pauses are rejected and contiguous pauses must not share a day 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 @@ -363,16 +360,20 @@ Feature: Working Capital Breach Pause And Admin runs inline COB job for Working Capital Loan by loanId When Admin sets the business date to "15 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId - # second pause starts exactly where the first ends; half-open [start,end) semantics mean no overlap (differs from delinquency predicate) And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" - And Admin initiate a Working Capital loan breach pause with startDate "25 January 2026" and endDate "05 February 2026" + # start and end are inclusive, so a second pause sharing the boundary day (25 Jan) overlaps and is rejected + Then Initiating a Working Capital loan breach pause with startDate "25 January 2026" and endDate "05 February 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: overlapping.pause.periods | + # a contiguous pause must start the day after the previous one ends, so the boundary day is not double-counted + When Admin initiate a Working Capital loan breach pause with startDate "26 January 2026" and endDate "05 February 2026" Then Working Capital loan breach action has the following data: | action | startDate | endDate | | PAUSE | 2026-01-15 | 2026-01-25 | - | PAUSE | 2026-01-25 | 2026-02-05 | + | PAUSE | 2026-01-26 | 2026-02-05 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-21 | 80 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-22 | 81 | 110.70 | 110.70 | null | null | @TestRailId:C85249 Scenario: Verify working capital loan breach pause - a pre-existing payment is not re-bucketed when a later pause shifts the period boundary across its date @@ -395,13 +396,13 @@ Feature: Working Capital Breach Pause | 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 | 60.70 | null | null | - # backdated pause inside period 1 pushes its end to 16 Mar, so period 1's window now covers 05 Mar, + # backdated pause inside period 1 pushes its end to 17 Mar, so period 1's window now covers 05 Mar, # but the 50.00 paid that day stays credited to period 2 When Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "31 January 2026" Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-16 | 75 | 110.70 | 110.70 | null | true | - | 2 | 2026-03-17 | 2026-05-16 | 61 | 110.70 | 60.70 | null | null | + | 1 | 2026-01-01 | 2026-03-17 | 76 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-18 | 2026-05-17 | 61 | 110.70 | 60.70 | null | null | @TestRailId:C85250 Scenario: Verify working capital loan breach pause - pause can be applied and retrieved by loan external id @@ -422,4 +423,24 @@ Feature: Working Capital Breach Pause | PAUSE | 2026-01-15 | 2026-01-25 | And Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-10 | 69 | 110.70 | 110.70 | null | null | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - a single day pause with the same start and end date extends the schedule by one day + 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 using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "20 January 2026" and endDate "20 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-20 | 2026-01-20 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 110.70 | 110.70 | null | null | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java index b26b0c66976..9220ba1851b 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 @@ -229,7 +229,7 @@ private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, final LocalDate pauseEnd = pause.getEndDate(); // Apply only if the pause overlaps this period's date range if (pauseEnd.isAfter(period.getFromDate()) && !pauseStart.isAfter(period.getToDate())) { - final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd); + final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd) + 1; period.setToDate(period.getToDate().plusDays(pauseDays)); if (period.getFromDate().isAfter(pauseStart)) { period.setFromDate(period.getFromDate().plusDays(pauseDays)); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java index 82367493989..79d0b117904 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java @@ -103,8 +103,8 @@ private void validateBreachConfigurationExists(final DataValidatorBuilder dataVa } private void validateStartBeforeEnd(final DataValidatorBuilder dataValidator, final LocalDate startDate, final LocalDate endDate) { - if (startDate != null && endDate != null && !startDate.isBefore(endDate)) { - dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.be.after.startDate"); + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.be.on.or.after.startDate"); } } @@ -137,7 +137,7 @@ private void validateNoOverlap(final DataValidatorBuilder dataValidator, final L } private boolean isOverlapping(final LocalDate startDate, final LocalDate endDate, final WorkingCapitalLoanBreachAction other) { - return startDate.isBefore(other.getEndDate()) && other.getStartDate().isBefore(endDate); + return !startDate.isAfter(other.getEndDate()) && !other.getStartDate().isAfter(endDate); } }