From ba3751a5e74f6f69992c9120ab57b5d53d9d8800 Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Thu, 11 Jun 2026 11:21:44 +0300 Subject: [PATCH 1/2] 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 f4a0dc6e19d..7e15c36dc1d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -881,6 +881,15 @@ public CommandWrapperBuilder createWorkingCapitalLoanDelinquencyAction(final Lon return this; } + public CommandWrapperBuilder createWorkingCapitalLoanBreachAction(final Long workingCapitalLoanId) { + this.actionName = "CREATE"; + this.entityName = "WC_BREACH_ACTION"; + this.entityId = workingCapitalLoanId; + this.loanId = workingCapitalLoanId; + this.href = "/working-capital-loans/" + workingCapitalLoanId + "/breach-actions"; + return this; + } + public CommandWrapperBuilder updateDiscountWorkingCapitalLoanApplication(final Long loanId) { this.actionName = "UPDATEDISCOUNT"; this.entityName = "WORKINGCAPITALLOAN"; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java index cb87f2b3392..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 25190c291cb..9d0a40e9ef8 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -65,4 +65,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 720b1a9942b7c56cd1cce903d6ad90d9f919bccc Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Wed, 17 Jun 2026 18:38:20 +0200 Subject: [PATCH 2/2] 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 |