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 83f99e035c7..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 @@ -118,9 +118,11 @@ public PostWorkingCapitalLoansDelinquencyActionRequest defaultWorkingCapitalLoan .locale(DEFAULT_LOCALE);// } - public PostWorkingCapitalLoansBreachActionRequest defaultWorkingCapitalLoansBreachActionRequest(String action) { + 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);// } 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 index 1dcda7b909a..5daa251474e 100644 --- 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 @@ -44,14 +44,16 @@ 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 DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + private static final Long NON_EXISTENT_LOAN_ID = 999999999L; - private final FineractFeignClient fineractFeignClient; + private final FineractFeignClient fineractClient; private final WorkingCapitalLoanRequestFactory workingCapitalLoanRequestFactory; @When("Admin creates WC breach reschedule action with the following parameters:") @@ -64,29 +66,29 @@ public void createRescheduleAction(final DataTable table) { @Then("Admin fails to create WC breach reschedule action with minimumPayment {int} {word} and frequency {int} {word} with error containing {string}") public void failToCreateRescheduleActionWithMessage(final int minimumPayment, final String minimumPaymentType, final int frequency, final String frequencyType, final String expectedMessage) { - final Long loanId = getLoanId(); + final Long loanId = extractLoanId(); final PostWorkingCapitalLoansBreachActionRequest request = buildRescheduleRequest(new BigDecimal(minimumPayment), minimumPaymentType, frequency, frequencyType); final CallFailedRuntimeException exception = fail( - () -> fineractFeignClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + () -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); assertThat(exception.getStatus()).as("HTTP status code").isEqualTo(400); assertThat(exception.getDeveloperMessage()).as("Developer message").contains(expectedMessage); } @Then("Admin fails to create WC breach reschedule action with no parameters with error containing {string}") public void failToCreateEmptyRescheduleAction(final String expectedMessage) { - final Long loanId = getLoanId(); + final Long loanId = extractLoanId(); final PostWorkingCapitalLoansBreachActionRequest request = buildRescheduleRequest(Map.of()); final CallFailedRuntimeException exception = fail( - () -> fineractFeignClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + () -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); assertThat(exception.getStatus()).as("HTTP status code").isEqualTo(400); assertThat(exception.getDeveloperMessage()).as("Developer message").contains(expectedMessage); } @Then("WC loan breach actions have the following data:") public void verifyBreachActionsHistory(final DataTable table) { - final Long loanId = getLoanId(); + final Long loanId = extractLoanId(); final List actions = retrieveBreachActions(loanId); final List> expectedRows = table.asMaps(); assertThat(actions).as("Breach actions count").hasSize(expectedRows.size()); @@ -98,19 +100,172 @@ public void verifyBreachActionsHistory(final DataTable table) { log.info("Successfully verified {} breach action(s) for loan {}", actions.size(), loanId); } + @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); + } + + @When("Admin initiate a Working Capital loan breach resume with startDate {string}") + public void initiateBreachResume(final String startDate) { + final Long loanId = extractLoanId(); + final PostWorkingCapitalLoansBreachActionRequest request = buildResumeRequest(startDate); + final PostWorkingCapitalLoansBreachActionResponse response = createBreachActionById(loanId, request); + + log.debug("Breach resume initiated for loan {} with startDate: {}, response: {}", loanId, startDate, response); + } + + @When("Admin initiate a Working Capital loan breach resume by external ID with startDate {string}") + public void initiateBreachResumeByExternalId(final String startDate) { + final String loanExternalId = extractLoanExternalId(); + final PostWorkingCapitalLoansBreachActionRequest request = buildResumeRequest(startDate); + final PostWorkingCapitalLoansBreachActionResponse response = createBreachActionByExternalId(loanExternalId, request); + + log.debug("Breach resume initiated for loan externalId {} with startDate: {}, response: {}", loanExternalId, startDate, response); + } + + @Then("Initiating a Working Capital loan breach resume with startDate {string} results an error with the following data:") + public void initiateBreachResumeResultsAnError(final String startDate, final DataTable table) { + final Long loanId = extractLoanId(); + + final PostWorkingCapitalLoansBreachActionRequest request = buildResumeRequest(startDate); + + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + + verifyBreachActionErrorWithTable(exception, table); + + log.info("Verified breach resume initiation failed with expected error for loan {}", loanId); + } + + @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 executeRescheduleAction(final PostWorkingCapitalLoansBreachActionRequest request) { - final Long loanId = getLoanId(); + final Long loanId = extractLoanId(); log.debug("Creating breach RESCHEDULE action for WC loan {}: {}", loanId, request); final PostWorkingCapitalLoansBreachActionResponse result = ok( - () -> fineractFeignClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); + () -> fineractClient.workingCapitalLoanBreachActions().createBreachAction(loanId, request)); assertThat(result).isNotNull(); assertThat(result.getResourceId()).isNotNull(); log.info("Breach RESCHEDULE action created with id={}", result.getResourceId()); } - private List retrieveBreachActions(final Long loanId) { - return ok(() -> fineractFeignClient.workingCapitalLoanBreachActions().retrieveBreachActions(loanId)); + private void 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" -> { + if (expectedValue == null || expectedValue.isBlank()) { + assertThat(actual.getEndDate()).as("End date for row %d", rowNumber).isNull(); + } else { + assertThat(actual.getEndDate()).as("End date for row %d", rowNumber).isEqualTo(LocalDate.parse(expectedValue)); + } + } + default -> throw new IllegalArgumentException("Unknown field name: " + fieldName); + } } private void verifyActionField(final WorkingCapitalLoanBreachActionData actual, final String field, final String expected, @@ -138,12 +293,22 @@ private void verifyOptionalField(final String expected, final Consumer w Optional.ofNullable(expected).filter(Predicate.not(String::isBlank)).ifPresentOrElse(whenPresent, whenAbsent); } - private Long getLoanId() { + private Long extractLoanId() { final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); assertThat(loanResponse).isNotNull(); 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 PostWorkingCapitalLoansBreachActionRequest buildRescheduleRequest(final BigDecimal minimumPayment, final String minimumPaymentType, final int frequency, final String frequencyType) { return buildRescheduleRequest(Map.of("minimumPayment", minimumPayment.toPlainString(), "minimumPaymentType", minimumPaymentType, @@ -159,4 +324,45 @@ private PostWorkingCapitalLoansBreachActionRequest buildRescheduleRequest(final Optional.ofNullable(params.get("frequencyType")).ifPresent(request::setFrequencyType); return request; } + + private PostWorkingCapitalLoansBreachActionRequest buildResumeRequest(final String startDate) { + return workingCapitalLoanRequestFactory.defaultWorkingCapitalLoansBreachActionRequest("resume").startDate(startDate).endDate(null); + } + + 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..c8d2af34acf --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -0,0 +1,787 @@ +@WorkingCapital +@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 + 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-11 | 70 | 110.70 | 110.70 | null | null | + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + + @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 + 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-11 | 70 | 110.70 | 110.70 | null | null | + | 2 | 2026-03-12 | 2026-05-11 | 61 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "12 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-12 | 2026-05-11 | 61 | 110.70 | 110.70 | null | null | + + @TestRailId:C85236 + Scenario: Verify working capital loan breach pause - backdated pause keeps breach flag when extended period still ends in the past + 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-06 | 65 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-07 | 2026-05-06 | 61 | 110.70 | 110.70 | null | null | + + @TestRailId:C85237 + Scenario: Verify working capital loan breach pause - multiple non-overlapping pauses are cumulative + 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-17 | 76 | 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 + 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 | + + @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 + 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-21 | 80 | 110.70 | 110.70 | null | null | + + @TestRailId:C85240 + Scenario: Verify working capital loan breach pause - next period is generated from the extended period and recorded pauses apply to it + 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 "12 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-11 | 70 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-12 | 2026-05-17 | 67 | 110.70 | 110.70 | null | null | + + @TestRailId:C85241 + Scenario: Verify working capital loan breach pause - future pause beyond the schedule end is preserved when a later backdated pause extends the period over its window + 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-14 | 73 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "15 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-14 | 73 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-15 | 2026-05-14 | 61 | 110.70 | 110.70 | null | null | + + @TestRailId:C85242 + Scenario: Verify working capital loan breach pause - pause created before the first COB run is applied to the initial period + 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-11 | 70 | 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 + 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 "25 January 2026" and endDate "15 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.be.on.or.after.startDate | + And Initiating a Working Capital loan breach pause with startDate "25 December 2025" and endDate "05 January 2026" results an error with the following data: + | httpCode | message | + | 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 "invalid" 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, reschedule, resume ] | + 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 + + @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 + 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 | + + @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 + 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-15 | 12 | 900.00 | 900.00 | null | null | + + @TestRailId:C85246 + Scenario: Verify working capital loan breach pause - pause is rejected for a not yet active loan + 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 | + + @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 + 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-11 | 72 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "14 April 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin makes Internal Payment "150.0" on "2026-02-15" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 110.70 | 0.00 | null | false | + | 2 | 2026-03-01 | 2026-05-11 | 72 | 110.70 | 110.70 | null | null | + + @TestRailId:C85248 + Scenario: Verify working capital loan breach pause - touching pauses are rejected and contiguous pauses must not share a day + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + 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" + # start and end are inclusive, so a second pause sharing the boundary day (25 Jan) overlaps and is rejected + Then Initiating a Working Capital loan breach pause with startDate "25 January 2026" and endDate "05 February 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: overlapping.pause.periods | + # a contiguous pause must start the day after the previous one ends, so the boundary day is not double-counted + When Admin initiate a Working Capital loan breach pause with startDate "26 January 2026" and endDate "05 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-01-25 | + | PAUSE | 2026-01-26 | 2026-02-05 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-22 | 81 | 110.70 | 110.70 | null | null | + + @TestRailId:C85249 + Scenario: Verify working capital loan breach pause - a pre-existing payment is not re-bucketed when a later pause shifts the period boundary across its date + 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 17 Mar, so period 1's window now covers 05 Mar, + # but the 50.00 paid that day stays credited to period 2 + When Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "31 January 2026" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-17 | 76 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-18 | 2026-05-17 | 61 | 110.70 | 60.70 | null | null | + + @TestRailId:C85250 + Scenario: Verify working capital loan breach pause - pause can be applied and retrieved by loan external id + 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-11 | 70 | 110.70 | 110.70 | null | null | + + Scenario: Verify working capital loan breach pause - a single day pause with the same start and end date extends the schedule by one day + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach pause with startDate "20 January 2026" and endDate "20 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-20 | 2026-01-20 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 110.70 | 110.70 | null | null | + + @TestRailId:C85251 + Scenario: Verify working capital loan breach pause - resume shortens the active pause and recalculates the breach 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 + And 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 Admin initiate a Working Capital loan breach pause with startDate "01 January 2026" and endDate "16 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + And 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 | null | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach resume with startDate "10 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-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-09 | 68 | 110.70 | 110.70 | null | null | + + @TestRailId:C85252 + Scenario: Verify working capital loan breach pause - resume is rejected when there is no active pause on the resume 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 + And Admin initiate a Working Capital loan breach pause with startDate "05 January 2026" and endDate "15 January 2026" + When Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach resume with startDate "20 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: resume.not.during.active.pause | + + @TestRailId:C85253 + Scenario: Verify working capital loan breach pause - resume is rejected when the resume date is not the current business 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 + And Admin initiate a Working Capital loan breach pause with startDate "05 January 2026" and endDate "15 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach resume with startDate "11 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.be.current.business.date | + + @TestRailId:C85254 + Scenario: Verify working capital loan breach pause - resume with an end date is 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 + And Admin initiate a Working Capital loan breach pause with startDate "05 January 2026" and endDate "15 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach action "resume" with startDate "10 January 2026" and endDate "20 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.not.be.provided.for.resume | + + @TestRailId:C85255 + Scenario: Verify working capital loan breach pause - resume is rejected for a pause that was already resumed + 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 + And Admin initiate a Working Capital loan breach pause with startDate "01 January 2026" and endDate "16 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach resume with startDate "10 January 2026" + Then Initiating a Working Capital loan breach resume with startDate "10 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: resume.not.during.active.pause | + + @TestRailId:C85256 + Scenario: Verify working capital loan breach pause - resume shortens the pause so the period breaches earlier + 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 + And 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 Admin initiate a Working Capital loan breach pause with startDate "01 January 2026" and endDate "16 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + And 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 | null | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach resume with startDate "10 January 2026" + When Admin sets the business date to "10 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-10 | | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-09 | 68 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-10 | 2026-05-09 | 61 | 110.70 | 110.70 | null | null | + + @TestRailId:C85257 + Scenario: Verify working capital loan breach pause - resume on the day before planned pause end shortens schedule by one day + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + And 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 Admin initiate a Working Capital loan breach pause with startDate "01 January 2026" and endDate "16 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + And 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 | 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 resume with startDate "15 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-15 | | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-14 | 73 | 110.70 | 110.70 | null | null | + + @TestRailId:C85258 + Scenario: Verify working capital loan breach pause - resume by 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 + And 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 Admin initiate a Working Capital loan breach pause by external ID with startDate "01 January 2026" and endDate "16 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + And 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 | null | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach resume by external ID with startDate "10 January 2026" + Then Working Capital loan breach action by external ID has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-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-09 | 68 | 110.70 | 110.70 | null | null | + + @TestRailId:C85259 + Scenario: Verify working capital loan breach pause - backdated resume is 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 + And Admin initiate a Working Capital loan breach pause with startDate "01 January 2026" and endDate "16 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach resume with startDate "09 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: must.be.current.business.date | + + @TestRailId:C85260 + Scenario: Verify working capital loan breach pause - resume without an active pause is 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 "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Initiating a Working Capital loan breach resume with startDate "10 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Failed data validation due to: resume.not.during.active.pause | + + @TestRailId:C85261 + Scenario: Verify working capital loan breach pause - resume keeps the PAUSE action dates unchanged and only shortens the breach 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 + When Admin sets the business date to "28 January 2026" + And 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 Admin initiate a Working Capital loan breach pause with startDate "28 January 2026" and endDate "20 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-28 | 2026-02-20 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-24 | 83 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "29 January 2026" + And Admin initiate a Working Capital loan breach resume with startDate "29 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-28 | 2026-02-20 | + | RESUME | 2026-01-29 | | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 110.70 | 110.70 | null | null | + + @TestRailId:C85262 + Scenario: Verify working capital loan breach pause - COB-generated period after resume honours the shortened pause + 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 "28 January 2026" + And 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 Admin initiate a Working Capital loan breach pause with startDate "28 January 2026" and endDate "20 February 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-28 | 2026-02-20 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-24 | 83 | 110.70 | 110.70 | null | null | + When Admin sets the business date to "29 January 2026" + And Admin initiate a Working Capital loan breach resume with startDate "29 January 2026" + When Admin sets the business date to "02 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-28 | 2026-02-20 | + | RESUME | 2026-01-29 | | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 110.70 | 110.70 | null | true | + | 2 | 2026-03-02 | 2026-05-01 | 61 | 110.70 | 110.70 | null | null | + + @TestRailId:C85263 + Scenario: Verify working capital loan breach pause - resume does not affect delinquency range 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 + And 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 Admin initiate a Working Capital loan breach pause with startDate "01 January 2026" and endDate "16 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + And 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 | null | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan breach resume with startDate "10 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-10 | | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-09 | 68 | 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 | 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 index 3fbc49daa2c..f6738b4d618 100644 --- 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 @@ -66,7 +66,7 @@ public class WorkingCapitalLoanBreachActionApiResource { @Path("{loanId}/breach-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Create Breach Action", description = "Creates a breach reschedule action for a Working Capital loan.") + @Operation(summary = "Create Breach Action", description = "Creates a breach action (pause, reschedule or resume) for a Working Capital loan. A resume shortens the currently active pause to the current business date.") @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))), @@ -86,7 +86,7 @@ public CommandProcessingResult createBreachAction(@PathParam("loanId") @Paramete @Path("external-id/{loanExternalId}/breach-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "createBreachActionByExternalId", summary = "Create Breach Action by external id", description = "Creates a breach reschedule action for a Working Capital loan identified by external id.") + @Operation(operationId = "createBreachActionByExternalId", summary = "Create Breach Action by external id", description = "Creates a breach action (pause, reschedule or resume) 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))), 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 index 117ba5b72ab..73b0aa317db 100644 --- 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 @@ -30,8 +30,12 @@ public static final class PostWorkingCapitalLoansBreachActionRequest { private PostWorkingCapitalLoansBreachActionRequest() {} - @Schema(example = "reschedule", description = "Breach action type: reschedule") + @Schema(example = "pause", description = "Breach action type: pause, reschedule or resume") public String action; + @Schema(example = "2026-03-05", description = "For pause: start date of the pause period. For resume: the resume date, which must be the current business date") + public String startDate; + @Schema(example = "2026-03-12", description = "End date of the pause period. Must be omitted for resume and reschedule actions") + public String endDate; @Schema(example = "33.33", description = "Minimum payment value (required together with minimumPaymentType)") public BigDecimal minimumPayment; @Schema(example = "PERCENTAGE", description = "Minimum payment type: PERCENTAGE, FLAT (required together with minimumPayment)") 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 index 8c3ebee7353..22c8d1f01f2 100644 --- 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 @@ -18,6 +18,7 @@ */ 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; @@ -49,13 +50,18 @@ public CommandProcessingResult createBreachAction(final Long workingCapitalLoanI final WorkingCapitalLoan workingCapitalLoan = loanRepository.findById(workingCapitalLoanId) .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(workingCapitalLoanId)); - final WorkingCapitalLoanBreachAction action = validator.validateAndParse(command, workingCapitalLoan); + 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); - if (WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(action.getAction())) { + if (WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction()) + || WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction())) { + breachScheduleService.recalculatePeriodsForPauses(workingCapitalLoan); + } else if (WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(action.getAction())) { breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); } 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 f304387523d..a5aa8a97ccf 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 @@ -43,4 +43,6 @@ public interface WorkingCapitalLoanBreachScheduleService { void evaluateBreach(WorkingCapitalLoan loan, LocalDate businessDate); void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction rescheduleAction); + + 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 453f21f2c89..50599628555 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,6 +22,7 @@ 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; @@ -78,6 +79,7 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) { final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach, latestReschedule.orElse(null)); final WorkingCapitalLoanBreachSchedule period = createPeriod(loan, 1, fromDate, toDate, minPaymentAmount); + applyRecordedPauses(period, findEffectivePauses(loan.getId())); repository.saveAndFlush(period); log.debug("Generated initial breach schedule period for WC loan {}", loan.getId()); } @@ -104,6 +106,7 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca final Integer effectiveFrequency = resolveFrequency(latestReschedule.orElse(null), breach); final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = resolveFrequencyType(latestReschedule.orElse(null), breach); final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach, latestReschedule.orElse(null)); + final List effectivePauses = findEffectivePauses(loan.getId()); final List newPeriods = new ArrayList<>(); WorkingCapitalLoanBreachSchedule latestPeriod = latestPeriodOpt.get(); @@ -113,6 +116,7 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca final WorkingCapitalLoanBreachSchedule nextPeriod = createPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate, newToDate, minPaymentAmount); + applyRecordedPauses(nextPeriod, effectivePauses); newPeriods.add(nextPeriod); latestPeriod = nextPeriod; } @@ -149,7 +153,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); @@ -227,6 +231,81 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final Workin rescheduleAction.getMinimumPayment(), rescheduleAction.getMinimumPaymentType(), newFrequency, newFreqType); } + @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 effectivePauses = findEffectivePauses(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, effectivePauses); + recomputeBreach(period, businessDate); + fromDate = period.getToDate().plusDays(1); + } + repository.saveAll(periods); + log.debug("Recalculated breach schedule periods for WC loan {} by replaying {} effective pauses", loan.getId(), + effectivePauses.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 findEffectivePauses(final Long loanId) { + final List actions = breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId); + final List resumes = actions.stream() + .filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction())).toList(); + return actions.stream().filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) + .sorted(Comparator.comparing(WorkingCapitalLoanBreachAction::getStartDate)) + .map(pause -> new EffectivePause(pause.getStartDate(), effectivePauseEnd(pause, resumes))).toList(); + } + + private LocalDate effectivePauseEnd(final WorkingCapitalLoanBreachAction pause, final List resumes) { + // Resume ends the pause on the resume date, which is the first active (non-paused) day. Because pause start and + // end dates are both inclusive, the effective inclusive end is the day before the resume date. + return resumes.stream() + .filter(resume -> !pause.getStartDate().isAfter(resume.getStartDate()) + && !resume.getStartDate().isAfter(pause.getEndDate())) + .map(WorkingCapitalLoanBreachAction::getStartDate).min(Comparator.naturalOrder()) + .map(resumeDate -> resumeDate.minusDays(1)).orElse(pause.getEndDate()); + } + + private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, final List pauses) { + for (final EffectivePause pause : pauses) { + final LocalDate pauseStart = pause.startDate(); + final LocalDate pauseEnd = pause.endDate(); + // 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) + 1; + 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 record EffectivePause(LocalDate startDate, LocalDate endDate) { + } + 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/WorkingCapitalLoanBreachActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java index cae69708a49..a7f81711da9 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java @@ -19,21 +19,28 @@ 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.FREQUENCY; import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.FREQUENCY_TYPE; +import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.LOCALE; import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.MINIMUM_PAYMENT; import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.MINIMUM_PAYMENT_TYPE; +import static org.apache.fineract.portfolio.workingcapitalloan.validator.WorkingCapitalLoanBreachActionParameters.START_DATE; import com.google.gson.JsonElement; import java.math.BigDecimal; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; 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.service.DateUtils; import org.apache.fineract.infrastructure.core.validator.ParseAndValidator; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; @@ -43,55 +50,131 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; +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 static final String RESCHEDULE_ACTION = "reschedule"; + private static final String RESUME_ACTION = "resume"; private final FromJsonHelper jsonHelper; + private final WorkingCapitalLoanRepository loanRepository; private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; - public WorkingCapitalLoanBreachAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan) { + public WorkingCapitalLoanBreachAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, + final List existing) { final DataValidatorBuilder dataValidator = new DataValidatorBuilder(new ArrayList<>()).resource("workingCapitalLoanBreachAction"); - final WorkingCapitalLoanBreachAction parsedAction = parseCommand(command, dataValidator); - validateLoanIsActive(workingCapitalLoan, dataValidator); + final JsonElement json = command.parsedJson(); - if (WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(parsedAction.getAction())) { - validateReschedule(parsedAction, workingCapitalLoan, dataValidator); - } else if (parsedAction.getAction() != null) { - dataValidator.reset().parameter(ACTION).value(parsedAction.getAction()).failWithCode("invalid.action"); + 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, RESCHEDULE_ACTION, + RESUME_ACTION); } + throwExceptionIfValidationWarningsExist(dataValidator); + + validateLoanIsActive(dataValidator, workingCapitalLoan); + validateBreachConfigurationExists(dataValidator, workingCapitalLoan); + + if (RESCHEDULE_ACTION.equalsIgnoreCase(actionString)) { + return parseAndValidateReschedule(json, workingCapitalLoan, dataValidator); + } + if (RESUME_ACTION.equalsIgnoreCase(actionString)) { + return parseAndValidateResume(json, existing, dataValidator); + } + return parseAndValidatePause(json, workingCapitalLoan, existing, dataValidator); + } + + private WorkingCapitalLoanBreachAction parseAndValidatePause(final JsonElement json, final WorkingCapitalLoan workingCapitalLoan, + final List existing, final DataValidatorBuilder dataValidator) { + 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(); + + validateStartBeforeEnd(dataValidator, startDate, endDate); + validateNotBeforeScheduleStart(dataValidator, startDate, workingCapitalLoan); + validateNoOverlap(dataValidator, startDate, endDate, existing); throwExceptionIfValidationWarningsExist(dataValidator); - return parsedAction; + + final WorkingCapitalLoanBreachAction action = new WorkingCapitalLoanBreachAction(); + action.setAction(WorkingCapitalLoanBreachActionType.PAUSE); + action.setStartDate(startDate); + action.setEndDate(endDate); + return action; } - private WorkingCapitalLoanBreachAction parseCommand(final JsonCommand command, final DataValidatorBuilder dataValidator) { - final JsonElement json = command.parsedJson(); + private WorkingCapitalLoanBreachAction parseAndValidateReschedule(final JsonElement json, final WorkingCapitalLoan workingCapitalLoan, + final DataValidatorBuilder dataValidator) { final WorkingCapitalLoanBreachAction action = new WorkingCapitalLoanBreachAction(); - action.setAction(extractAction(json, dataValidator)); + action.setAction(WorkingCapitalLoanBreachActionType.RESCHEDULE); action.setStartDate(DateUtils.getBusinessLocalDate()); action.setMinimumPayment(extractBigDecimal(json, MINIMUM_PAYMENT)); action.setMinimumPaymentType(extractMinimumPaymentType(json, dataValidator)); action.setFrequency(extractInteger(json, FREQUENCY)); action.setFrequencyType(extractFrequencyType(json, dataValidator)); + + validateReschedule(action, workingCapitalLoan, dataValidator); + + throwExceptionIfValidationWarningsExist(dataValidator); return action; } - private WorkingCapitalLoanBreachActionType extractAction(final JsonElement json, final DataValidatorBuilder dataValidator) { - final String actionString = jsonHelper.extractStringNamed(ACTION, json); - dataValidator.reset().parameter(ACTION).value(actionString).notBlank(); - if (StringUtils.isNotBlank(actionString)) { - dataValidator.reset().parameter(ACTION).value(actionString).isOneOfTheseStringValues(RESCHEDULE_ACTION); + private WorkingCapitalLoanBreachAction parseAndValidateResume(final JsonElement json, + final List existing, final DataValidatorBuilder dataValidator) { + final LocalDate resumeDate = extractDate(json, START_DATE); + dataValidator.reset().parameter(START_DATE).value(resumeDate).notNull(); + + final LocalDate endDate = extractDate(json, END_DATE); + if (endDate != null) { + dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.not.be.provided.for.resume"); } - if (RESCHEDULE_ACTION.equalsIgnoreCase(actionString)) { - return WorkingCapitalLoanBreachActionType.RESCHEDULE; + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (resumeDate != null && !resumeDate.isEqual(businessDate)) { + dataValidator.reset().parameter(START_DATE).value(resumeDate).failWithCode("must.be.current.business.date"); } - return null; + + if (resumeDate != null && findActivePause(resumeDate, existing).isEmpty()) { + dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("resume.not.during.active.pause"); + } + + throwExceptionIfValidationWarningsExist(dataValidator); + + final WorkingCapitalLoanBreachAction resume = new WorkingCapitalLoanBreachAction(); + resume.setAction(WorkingCapitalLoanBreachActionType.RESUME); + resume.setStartDate(resumeDate); + return resume; + } + + private Optional findActivePause(final LocalDate resumeDate, + final List existing) { + if (resumeDate == null) { + return Optional.empty(); + } + return existing.stream().filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) + .filter(pause -> !isAlreadyResumed(pause, existing)) + .filter(pause -> !resumeDate.isBefore(pause.getStartDate()) && !resumeDate.isAfter(pause.getEndDate())).findFirst(); + } + + private boolean isAlreadyResumed(final WorkingCapitalLoanBreachAction pause, final List existing) { + return existing.stream().filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction())).anyMatch( + resume -> !pause.getStartDate().isAfter(resume.getStartDate()) && !resume.getStartDate().isAfter(pause.getEndDate())); + } + + 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 BigDecimal extractBigDecimal(final JsonElement json, final String paramName) { @@ -135,11 +218,61 @@ private WorkingCapitalLoanPeriodFrequencyType extractFrequencyType(final JsonEle } } + 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.isAfter(endDate)) { + dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.be.on.or.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.isAfter(other.getEndDate()) && !other.getStartDate().isAfter(endDate); + } + private void validateReschedule(final WorkingCapitalLoanBreachAction action, final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { validateLoanIsDisbursed(workingCapitalLoan, dataValidator); validateScheduleExists(workingCapitalLoan, dataValidator); - validateBreachConfigured(workingCapitalLoan, dataValidator); final boolean hasPaymentGroup = action.getMinimumPayment() != null || action.getMinimumPaymentType() != null; final boolean hasFrequencyGroup = action.getFrequency() != null || action.getFrequencyType() != null; @@ -155,12 +288,6 @@ private void validateReschedule(final WorkingCapitalLoanBreachAction action, fin } } - private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { - if (!workingCapitalLoan.getLoanStatus().isActive()) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.is.not.active"); - } - } - private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { final boolean isDisbursed = workingCapitalLoan.getDisbursementDetails().stream() .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); @@ -177,13 +304,6 @@ private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan, } } - private void validateBreachConfigured(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { - if (workingCapitalLoan.getLoanProductRelatedDetails() == null - || workingCapitalLoan.getLoanProductRelatedDetails().getBreach() == null) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.configuration"); - } - } - private void validateMinimumPaymentGroupProvided(final WorkingCapitalLoanBreachAction action, final DataValidatorBuilder dataValidator) { if (action.getMinimumPayment() == null || action.getMinimumPayment().compareTo(BigDecimal.ZERO) <= 0) {