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..13feba7032e 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,135 @@ 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); + } + + @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" -> + 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 +256,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 +287,41 @@ private PostWorkingCapitalLoansBreachActionRequest buildRescheduleRequest(final Optional.ofNullable(params.get("frequencyType")).ifPresent(request::setFrequencyType); return request; } + + 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-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index f53c1de7805..621448ccebb 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -2568,6 +2568,21 @@ public void makeWorkingCapitalLoanRepaymentWithPaymentDetails(final String trans validateRepaymentResponse(response, transactionAmount, transactionDate, loanId); } + @Then("Admin closes the Working Capital loan with a full repayment on {string}") + public void closeWorkingCapitalLoanWithFullRepayment(final String transactionDate) { + final Long loanId = getCreatedLoanId(); + final GetWorkingCapitalLoansLoanIdResponse loanDetails = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + Assertions.assertNotNull(loanDetails.getBalance()); + Assertions.assertNotNull(loanDetails.getBalance().getTotalOutstanding()); + final BigDecimal totalOutstanding = loanDetails.getBalance().getTotalOutstanding(); + final PostWorkingCapitalLoanTransactionsRequest repaymentRequest = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest().transactionDate(transactionDate).transactionAmount(totalOutstanding); + final PostWorkingCapitalLoanTransactionsResponse response = executeRepaymentLikeById(loanId, "repayment", repaymentRequest); + Assertions.assertNotNull(loanDetails.getBalance()); + validateRepaymentResponse(response, totalOutstanding.doubleValue(), transactionDate, loanId); + } + @Then("Customer makes credit balance refund on {string} with {double} transaction amount on Working Capital loan") public void makeWorkingCapitalLoanCreditBalanceRefund(final String transactionDate, final double transactionAmount) { final Long loanId = getCreatedLoanId(); 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..ccaea06f82f --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -0,0 +1,446 @@ +@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 "resume" with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: + | httpCode | message | + | 400 | The parameter `action` must be one of [ pause, reschedule ] | + 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 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature index 737c7366c20..268d704ddd3 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -31,6 +31,7 @@ Feature: Working Capital Breach Reschedule Action | 2 | 2026-03-01 | 2026-04-30 | 61 | 110.70 | 110.70 | null | true | | 3 | 2026-05-01 | 2026-06-30 | 61 | 90 | 90 | null | true | | 4 | 2026-07-01 | 2026-08-31 | 62 | 90 | 90 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85273 Scenario: Verify breach reschedule - UC2: changes frequency only @@ -57,6 +58,7 @@ Feature: Working Capital Breach Reschedule Action | 3 | 2026-05-01 | 2026-06-30 | 61 | 110.70 | 110.70 | null | true | | 4 | 2026-07-01 | 2026-07-30 | 30 | 110.70 | 110.70 | null | true | | 5 | 2026-07-31 | 2026-08-29 | 30 | 110.70 | 110.70 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85274 Scenario: Verify breach reschedule - UC3: changes minimumPayment and frequency @@ -83,6 +85,7 @@ Feature: Working Capital Breach Reschedule Action | 3 | 2026-05-01 | 2026-06-30 | 61 | 90 | 90 | null | true | | 4 | 2026-07-01 | 2026-07-30 | 30 | 90 | 90 | null | true | | 5 | 2026-07-31 | 2026-08-29 | 30 | 90 | 90 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85275 Scenario: Verify breach reschedule - UC4: latest reschedule action wins @@ -112,6 +115,7 @@ Feature: Working Capital Breach Reschedule Action | 3 | 2026-05-01 | 2026-06-30 | 61 | 90 | 90 | null | true | | 4 | 2026-07-01 | 2026-07-30 | 30 | 90 | 90 | null | true | | 5 | 2026-07-31 | 2026-08-29 | 30 | 90 | 90 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85276 Scenario: Verify breach reschedule - UC5: multiple reschedules on the same date are stored in history @@ -140,6 +144,7 @@ Feature: Working Capital Breach Reschedule Action | RESCHEDULE | 01 June 2026 | 2 | PERCENTAGE | 2 | MONTHS | | RESCHEDULE | 01 June 2026 | 1 | PERCENTAGE | 2 | MONTHS | | RESCHEDULE | 01 June 2026 | 1.5 | PERCENTAGE | 2 | MONTHS | + Then Admin closes the Working Capital loan with a full repayment on "01 June 2026" @TestRailId:C85277 Scenario: Verify breach reschedule - UC6: fails when no change parameters are provided (Negative) @@ -153,6 +158,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId Then Admin fails to create WC breach reschedule action with no parameters with error containing "reschedule.no.change.parameters" + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C85278 Scenario: Verify breach reschedule - UC7: fails with negative minimumPayment (Negative) @@ -166,6 +172,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId Then Admin fails to create WC breach reschedule action with minimumPayment -1 PERCENTAGE and frequency 30 DAYS with error containing "minimumPayment" + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C85279 Scenario: Verify breach reschedule - UC8: payment-only reschedule after frequency reschedule falls back to product breach frequency @@ -194,6 +201,7 @@ Feature: Working Capital Breach Reschedule Action | 2 | 2026-03-01 | 2026-04-30 | 61 | 110.70 | 110.70 | null | true | | 3 | 2026-05-01 | 2026-06-30 | 61 | 90 | 90 | null | true | | 4 | 2026-07-01 | 2026-08-31 | 62 | 90 | 90 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85280 Scenario: Verify breach reschedule - UC9: updates current period after partial repayment and replays payments @@ -217,6 +225,7 @@ Feature: Working Capital Breach Reschedule Action Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2019-01-01 | 2019-03-31 | 90 | 500.00 | 50.00 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "10 March 2019" @TestRailId:C85281 Scenario: Verify breach reschedule - UC10: preserves already evaluated periods @@ -247,6 +256,7 @@ Feature: Working Capital Breach Reschedule Action | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2019-01-01 | 2019-03-31 | 90 | 900.00 | 450.00 | null | true | | 2 | 2019-04-01 | 2019-06-29 | 90 | 450.00 | 450.00 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "10 April 2019" @TestRailId:C85282 Scenario: Verify breach reschedule - UC11: changes frequency from 90 days to 30 days for current and future periods @@ -278,6 +288,7 @@ Feature: Working Capital Breach Reschedule Action | 2 | 2019-04-01 | 2019-04-30 | 30 | 900.00 | 900.00 | null | true | | 3 | 2019-05-01 | 2019-05-30 | 30 | 900.00 | 900.00 | null | true | | 4 | 2019-05-31 | 2019-06-29 | 30 | 900.00 | 900.00 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 June 2019" @TestRailId:C85283 Scenario: Verify breach reschedule - UC12: changes minimum payment and frequency together @@ -309,3 +320,4 @@ Feature: Working Capital Breach Reschedule Action | 2 | 2019-04-01 | 2019-04-30 | 30 | 500.00 | 500.00 | null | true | | 3 | 2019-05-01 | 2019-05-30 | 30 | 500.00 | 500.00 | null | true | | 4 | 2019-05-31 | 2019-06-29 | 30 | 500.00 | 500.00 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 June 2019" 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..0f479ed13c0 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") public String action; + @Schema(example = "2024-01-01", description = "Pause start date (required for pause action)") + public String startDate; + @Schema(example = "2024-01-31", description = "Pause end date (required for pause action)") + 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..a30977c52c7 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,17 @@ 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())) { + 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..389542ca70c 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, findRecordedPauses(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 recordedPauses = findRecordedPauses(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, recordedPauses); 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,66 @@ 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 recordedPauses = findRecordedPauses(loan.getId()); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + LocalDate fromDate = periods.getFirst().getFromDate(); + for (final WorkingCapitalLoanBreachSchedule period : periods) { + period.setFromDate(fromDate); + period.setToDate(calculateToDate(fromDate, breach.getBreachFrequency(), breach.getBreachFrequencyType())); + applyRecordedPauses(period, recordedPauses); + recomputeBreach(period, businessDate); + fromDate = period.getToDate().plusDays(1); + } + repository.saveAll(periods); + log.debug("Recalculated breach schedule periods for WC loan {} by replaying {} recorded pauses", loan.getId(), + recordedPauses.size()); + } + + private void recomputeBreach(final WorkingCapitalLoanBreachSchedule period, final LocalDate businessDate) { + if (period.getOutstandingAmount().compareTo(BigDecimal.ZERO) == 0) { + period.setBreach(false); + } else if (businessDate.isAfter(period.getToDate())) { + // COB evaluates with effective date businessDate-1, so breach is set only after the toDate has passed + period.setBreach(true); + } else { + period.setBreach(null); + } + } + + private List findRecordedPauses(final Long loanId) { + return breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId).stream() + .filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) + .sorted(Comparator.comparing(WorkingCapitalLoanBreachAction::getStartDate)).toList(); + } + + private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, + final List pauseActions) { + for (final WorkingCapitalLoanBreachAction pause : pauseActions) { + final LocalDate pauseStart = pause.getStartDate(); + final LocalDate pauseEnd = pause.getEndDate(); + // Apply only if the pause overlaps this period's date range + if (pauseEnd.isAfter(period.getFromDate()) && !pauseStart.isAfter(period.getToDate())) { + final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd) + 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 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..a352bf4e9e3 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,13 +19,18 @@ 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; @@ -34,6 +39,7 @@ 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 +49,84 @@ 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 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); } + throwExceptionIfValidationWarningsExist(dataValidator); + + validateLoanIsActive(dataValidator, workingCapitalLoan); + + if (RESCHEDULE_ACTION.equalsIgnoreCase(actionString)) { + return parseAndValidateReschedule(json, workingCapitalLoan, 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(); + + validateBreachConfigurationExists(dataValidator, workingCapitalLoan); + 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); - } - if (RESCHEDULE_ACTION.equalsIgnoreCase(actionString)) { - return WorkingCapitalLoanBreachActionType.RESCHEDULE; - } - return null; + 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 +170,62 @@ 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); + validateBreachConfigurationExists(dataValidator, workingCapitalLoan); final boolean hasPaymentGroup = action.getMinimumPayment() != null || action.getMinimumPaymentType() != null; final boolean hasFrequencyGroup = action.getFrequency() != null || action.getFrequencyType() != null; @@ -155,12 +241,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 +257,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) {