diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyStepDef.java index 2b5fe1b6197..6c255b5c4b1 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalDelinquencyStepDef.java @@ -68,6 +68,37 @@ public void initiateDelinquencyPauseByExternalId(String startDate, String endDat startDate, endDate, response); } + @When("Admin initiate a Working Capital loan delinquency resume with startDate {string}") + public void initiateDelinquencyResume(final String startDate) { + final Long loanId = extractLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildResumeRequest(startDate); + final PostWorkingCapitalLoansDelinquencyActionResponse response = createDelinquencyActionById(loanId, request); + + log.debug("Delinquency resume initiated for loan {} with startDate: {}, response: {}", loanId, startDate, response); + } + + @When("Admin initiate a Working Capital loan delinquency resume by external ID with startDate {string}") + public void initiateDelinquencyResumeByExternalId(final String startDate) { + final String loanExternalId = extractLoanExternalId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildResumeRequest(startDate); + final PostWorkingCapitalLoansDelinquencyActionResponse response = createDelinquencyActionByExternalId(loanExternalId, request); + + log.debug("Delinquency resume initiated for loan externalId {} with startDate: {}, response: {}", loanExternalId, startDate, + response); + } + + @Then("Initiating a Working Capital loan delinquency resume with startDate {string} results an error with the following data:") + public void initiateDelinquencyResumeResultsAnErrorWithDetails(final String startDate, final DataTable table) { + final Long loanId = extractLoanId(); + final PostWorkingCapitalLoansDelinquencyActionRequest request = buildResumeRequest(startDate); + + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); + + verifyDelinquencyPauseErrorWithTable(exception, table); + log.info("Verified delinquency resume initiation failed with expected error for loan {}", loanId); + } + @Then("Initiating a Working Capital loan delinquency pause with startDate {string} and endDate {string} results an error") public void initiateDelinquencyPauseResultsAnError(String startDate, String endDate) { initiateDelinquencyPauseResultsAnErrorWithDetails(startDate, endDate, null); @@ -127,8 +158,13 @@ private void verifyDelinquencyActionField(WorkingCapitalLoanDelinquencyActionDat 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)); + 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); } } @@ -183,6 +219,11 @@ private PostWorkingCapitalLoansDelinquencyActionRequest buildDelinquencyActionRe .endDate(endDate); } + private PostWorkingCapitalLoansDelinquencyActionRequest buildResumeRequest(final String startDate) { + return workingCapitalLoanRequestFactory.defaultWorkingCapitalLoansDelinquencyActionRequest("resume").startDate(startDate) + .endDate(null); + } + private PostWorkingCapitalLoansDelinquencyActionResponse createDelinquencyActionById(Long loanId, PostWorkingCapitalLoansDelinquencyActionRequest request) { return ok(() -> fineractClient.workingCapitalLoanDelinquencyActions().createDelinquencyAction(loanId, request)); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature new file mode 100644 index 00000000000..3c2cf99d840 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature @@ -0,0 +1,212 @@ +@WorkingCapital +@WorkingCapitalDelinquencyResumeFeature +Feature: Working Capital Delinquency Resume + + @TestRailId:C85172 + Scenario: Verify working capital loan delinquency resume - UC1: resume shortens active pause and 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 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 + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "01 January 2026" and endDate "16 January 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + 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-02-14 | 270.0 | 0.0 | 270.0 | null | 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 delinquency resume with startDate "10 January 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-10 | | + 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-02-08 | 270.0 | 0.0 | 270.0 | null | null | null | + + @TestRailId:C85173 + Scenario: Verify working capital loan delinquency resume - UC2: 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 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 + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "01 January 2026" and endDate "16 January 2026" + 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-02-14 | 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 delinquency resume with startDate "15 January 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-15 | | + 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-02-13 | 270.0 | 0.0 | 270.0 | null | null | null | + + @TestRailId:C85174 + Scenario: Verify working capital loan delinquency resume - UC3: 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 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 + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause by external ID 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 delinquency resume by external ID with startDate "10 January 2026" + Then Working Capital loan delinquency action by external ID has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-01 | 2026-01-16 | + | RESUME | 2026-01-10 | | + + @TestRailId:C85175 + Scenario: Verify working capital loan delinquency resume - UC4: resume without an active pause results an error (Negative) + 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 + When 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 delinquency resume with startDate "10 January 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Resume Delinquency Action can only be created during an active pause | + + @TestRailId:C85176 + Scenario: Verify working capital loan delinquency resume - UC5: backdated resume results an error (Negative) + 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 + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency 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 delinquency resume with startDate "09 January 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Start date of the Resume Delinquency action must be the current business date | + + @TestRailId:C85177 + Scenario: Verify working capital loan delinquency resume - UC6: resume on pause start date results an error (Negative) + 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 + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "01 January 2026" and endDate "16 January 2026" + Then Initiating a Working Capital loan delinquency resume with startDate "01 January 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Resume date must be after the active pause start date | + + @TestRailId:C85178 + Scenario: Verify working capital loan delinquency resume - UC7: multiple resumes on the same pause results an error (Negative) + 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 + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency 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 delinquency resume with startDate "10 January 2026" + Then Initiating a Working Capital loan delinquency resume with startDate "10 January 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Resume Delinquency Action can only be created during an active pause | + + @TestRailId:C85179 + Scenario: Verify working capital loan delinquency resume - UC8: resume recalculates delinquency immediately + 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 + When Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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-01-30 | 270.0 | 0.0 | 270.0 | false | 270.0 | 16 | + | 2 | 2026-01-31 | 2026-03-01 | 270.0 | 0.0 | 270.0 | null | null | null | + And Admin initiate a Working Capital loan delinquency pause with startDate "15 February 2026" and endDate "15 March 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-01-30 | 270.0 | 0.0 | 270.0 | false | 270.0 | 16 | + | 2 | 2026-01-31 | 2026-03-29 | 270.0 | 0.0 | 270.0 | null | null | null | + When Admin sets the business date to "20 February 2026" + And Admin initiate a Working Capital loan delinquency resume with startDate "20 February 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-01-30 | 270.0 | 0.0 | 270.0 | false | 270.0 | 21 | + | 2 | 2026-01-31 | 2026-03-06 | 270.0 | 0.0 | 270.0 | null | null | null | + + @TestRailId:C85219 + Scenario: Verify resume keeps the PAUSE action dates unchanged and only shortens the delinquency period extension + 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 + When Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "28 January 2026" + And Admin initiate a Working Capital loan delinquency pause with startDate "28 January 2026" and endDate "20 February 2026" + When Admin sets the business date to "29 January 2026" + And Admin initiate a Working Capital loan delinquency resume with startDate "29 January 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-28 | 2026-02-20 | + | RESUME | 2026-01-29 | | + 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-31 | 270.0 | 0.0 | 270.0 | null | null | null | + + @TestRailId:C85220 + Scenario: Verify that COB-generated period after resume must honour the shortened (resumed) pause + 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 + When Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "28 January 2026" + And Admin initiate a Working Capital loan delinquency pause with startDate "28 January 2026" and endDate "20 February 2026" + When Admin sets the business date to "29 January 2026" + And Admin initiate a Working Capital loan delinquency resume with startDate "29 January 2026" + When Admin sets the business date to "02 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | + | 1 | 2026-01-01 | 2026-01-31 | 270.0 | 0.0 | 270.0 | + | 2 | 2026-02-01 | 2026-03-02 | 270.0 | 0.0 | 270.0 | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java index cf17b5724be..c1566b2d3a6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResource.java @@ -51,7 +51,7 @@ @Path("/v1/working-capital-loans") @Component -@Tag(name = "Working Capital Loan Delinquency Actions", description = "Manages delinquency pause actions for Working Capital loans") +@Tag(name = "Working Capital Loan Delinquency Actions", description = "Manages delinquency pause, resume and reschedule actions for Working Capital loans") @RequiredArgsConstructor public class WorkingCapitalLoanDelinquencyActionApiResource { @@ -66,7 +66,7 @@ public class WorkingCapitalLoanDelinquencyActionApiResource { @Path("{loanId}/delinquency-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Create Delinquency Action", description = "Creates a delinquency action (pause or reschedule) for a Working Capital loan.") + @Operation(summary = "Create Delinquency Action", description = "Creates a delinquency action (pause, resume or reschedule) for a Working Capital loan.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionResponse.class))), @@ -86,7 +86,7 @@ public CommandProcessingResult createDelinquencyAction(@PathParam("loanId") @Par @Path("external-id/{loanExternalId}/delinquency-actions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "createDelinquencyActionByExternalId", summary = "Create Delinquency Action by external id", description = "Creates a delinquency action (pause or reschedule) for a Working Capital loan identified by external id.") + @Operation(operationId = "createDelinquencyActionByExternalId", summary = "Create Delinquency Action by external id", description = "Creates a delinquency action (pause, resume or reschedule) for a Working Capital loan identified by external id.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanDelinquencyActionApiResourceSwagger.PostWorkingCapitalLoansDelinquencyActionResponse.class))), diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java index c8eb7f6b499..1adefce0323 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanDelinquencyActionApiResourceSwagger.java @@ -30,11 +30,11 @@ public static final class PostWorkingCapitalLoansDelinquencyActionRequest { private PostWorkingCapitalLoansDelinquencyActionRequest() {} - @Schema(example = "pause", description = "Delinquency action type: pause or reschedule") + @Schema(example = "pause", description = "Delinquency action type: pause, resume or reschedule") public String action; - @Schema(example = "2026-03-05", description = "Start date of the pause period (required for pause)") + @Schema(example = "2026-03-05", description = "Start date of the pause period (required for pause) or resume date (required for resume, must be current business date)") public String startDate; - @Schema(example = "2026-03-12", description = "End date of the pause period (required for pause)") + @Schema(example = "2026-03-12", description = "End date of the pause period (required for pause, must not be provided for resume)") public String endDate; @Schema(example = "2", description = "Minimum payment value (required together with minimumPaymentType)") public BigDecimal minimumPayment; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java index ff28ac29980..2fa418f706c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java @@ -24,6 +24,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; @@ -63,6 +64,10 @@ public CommandProcessingResult createDelinquencyAction(final Long workingCapital rangeScheduleService.extendPeriodsForPause(workingCapitalLoan, action.getStartDate(), action.getEndDate()); } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); + } else if (DelinquencyAction.RESUME.equals(action.getAction())) { + final WorkingCapitalLoanDelinquencyAction activePause = validator.findActivePauseForResume(existing, + DateUtils.getBusinessLocalDate()); + rangeScheduleService.resumeActivePause(workingCapitalLoan, activePause, action); } return new CommandProcessingResultBuilder() // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java index 600eb716968..ba9f49de0de 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java @@ -43,4 +43,7 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleService { void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction rescheduleAction); + void resumeActivePause(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction activePause, + WorkingCapitalLoanDelinquencyAction resumeAction); + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index 9119991a19c..4f11774fee2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -57,6 +57,7 @@ public class WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl implements Wo private final WorkingCapitalLoanDelinquencyActionRepository loanDelinquencyActionRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleMapper capitalLoanDelinquencyRangeScheduleMapper; private final DelinquencyMinimumPaymentPeriodAndRuleRepository minimumPaymentPeriodAndRuleRepository; + private final WorkingCapitalLoanDelinquencyClassificationService delinquencyClassificationService; @Override public void generateInitialPeriod(WorkingCapitalLoan loan) { @@ -112,8 +113,6 @@ public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate busine final DelinquencyFrequencyType effectiveFreqType = latestReschedule.map(WorkingCapitalLoanDelinquencyAction::getFrequencyType) .orElse(rule.getFrequencyType()); - final List recordedPauses = findAllPauseActions(loan.getId()); - WorkingCapitalLoanDelinquencyRangeSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); @@ -130,7 +129,7 @@ public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate busine nextPeriod.setOutstandingAmount(expectedAmount); nextPeriod.setMinPaymentCriteriaMet(null); - applyRecordedPauses(nextPeriod, recordedPauses); + applyRecordedPauses(nextPeriod, loan); latestPeriod = loanDelinquencyRangeScheduleRepository.saveAndFlush(nextPeriod); log.debug("Generated next delinquency range schedule period {} for WC loan {}", nextPeriod.getPeriodNumber(), loan.getId()); @@ -197,6 +196,100 @@ public List retrieveRangeSchedul return capitalLoanDelinquencyRangeScheduleMapper.toDataList(periods); } + @Override + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction rescheduleAction) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); + if (rule == null) { + log.warn("No minimum payment rule found for WC loan {}, skipping reschedule", loan.getId()); + return; + } + final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, rule, rescheduleAction); + final Integer newFrequency = rescheduleAction.getFrequency() != null ? rescheduleAction.getFrequency() : rule.getFrequency(); + final DelinquencyFrequencyType newFreqType = rescheduleAction.getFrequencyType() != null ? rescheduleAction.getFrequencyType() + : rule.getFrequencyType(); + + final List periods = loanDelinquencyRangeScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + + WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod = null; + final List futurePeriods = new ArrayList<>(); + + for (final WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { + if (period.getMinPaymentCriteriaMet() != null) { + continue; + } + final boolean isCurrent = !period.getFromDate().isAfter(businessDate) && !period.getToDate().isBefore(businessDate); + final boolean isFuture = period.getFromDate().isAfter(businessDate); + + if (isCurrent) { + currentPeriod = period; + period.setExpectedAmount(newExpectedAmount); + period.setOutstandingAmount(newExpectedAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); + } else if (isFuture) { + futurePeriods.add(period); + } + } + + if (currentPeriod != null) { + loanDelinquencyRangeScheduleRepository.saveAndFlush(currentPeriod); + updateFuturePeriods(currentPeriod, futurePeriods, newExpectedAmount, newFrequency, newFreqType); + } + + evaluateExpiredPeriods(loan, businessDate); + + log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={}%, frequency={} {}", loan.getId(), + rescheduleAction.getMinimumPayment(), newFrequency, newFreqType); + } + + @Override + public void resumeActivePause(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction activePause, + final WorkingCapitalLoanDelinquencyAction resumeAction) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final LocalDate originalPauseEnd = activePause.getEndDate(); + final LocalDate resumeDate = resumeAction.getStartDate(); + + final WorkingCapitalLoanDelinquencyAction saved = loanDelinquencyActionRepository.saveAndFlush(activePause); + log.debug("Resumed WC loan delinquency pause {} for loan {}: shortened pause end from {} to {}", saved.getId(), loan.getId(), + originalPauseEnd, resumeDate); + + shrinkPeriodsForPause(loan, activePause.getStartDate(), originalPauseEnd, resumeDate); + recalculateDelinquencyAfterPauseResume(loan, businessDate); + } + + private void shrinkPeriodsForPause(final WorkingCapitalLoan loan, final LocalDate pauseStart, final LocalDate originalPauseEnd, + final LocalDate newPauseEnd) { + final long daysToRemove = ChronoUnit.DAYS.between(newPauseEnd, originalPauseEnd); + if (daysToRemove <= 0) { + return; + } + final List periods = loanDelinquencyRangeScheduleRepository + .findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + for (final WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { + if (period.getMinPaymentCriteriaMet() != null) { + continue; + } + if (!period.getToDate().isBefore(pauseStart)) { + period.setToDate(period.getToDate().minusDays(daysToRemove)); + } + if (period.getFromDate().isAfter(pauseStart)) { + period.setFromDate(period.getFromDate().minusDays(daysToRemove)); + } + } + loanDelinquencyRangeScheduleRepository.saveAll(periods); + log.debug("Shortened delinquency range schedule periods for WC loan {} by {} days due to pause resume [{} - {} -> {}]", + loan.getId(), daysToRemove, pauseStart, originalPauseEnd, newPauseEnd); + } + + private void recalculateDelinquencyAfterPauseResume(final WorkingCapitalLoan loan, final LocalDate businessDate) { + evaluateExpiredPeriods(loan, businessDate); + final WorkingCapitalLoanProduct product = loan.getLoanProduct(); + if (product == null || product.getDelinquencyBucket() == null) { + return; + } + delinquencyClassificationService.classifyDelinquency(loan, businessDate, product.getDelinquencyBucket()); + } + private DelinquencyMinimumPaymentPeriodAndRule getMinimumPaymentRule(WorkingCapitalLoan loan) { WorkingCapitalLoanProduct product = loan.getLoanProduct(); if (product == null) { @@ -264,18 +357,30 @@ private Optional findLatestRescheduleAction return loanDelinquencyActionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); } - private List findAllPauseActions(final Long loanId) { - return loanDelinquencyActionRepository.findByWorkingCapitalLoanIdOrderById(loanId).stream() - .filter(a -> DelinquencyAction.PAUSE.equals(a.getAction())).toList(); + private List findAllActions(final Long loanId) { + return loanDelinquencyActionRepository.findByWorkingCapitalLoanIdOrderById(loanId); } - private void applyRecordedPauses(final WorkingCapitalLoanDelinquencyRangeSchedule period, - final List pauseActions) { + private void applyRecordedPauses(final WorkingCapitalLoanDelinquencyRangeSchedule period, final WorkingCapitalLoan loan) { + final List recordedActions = findAllActions(loan.getId()); + if (period == null || recordedActions == null || recordedActions.isEmpty()) { + return; + } + final LocalDate periodFromDate = period.getFromDate(); + final LocalDate periodToDate = period.getToDate(); + if (periodFromDate == null || periodToDate == null) { + return; + } + final List pauseActions = recordedActions.stream() + .filter(action -> action != null && DelinquencyAction.PAUSE.equals(action.getAction())).toList(); for (final WorkingCapitalLoanDelinquencyAction pause : pauseActions) { final LocalDate pauseStart = pause.getStartDate(); - final LocalDate pauseEnd = pause.getEndDate(); + final LocalDate pauseEnd = resolveEffectivePauseEnd(pause, recordedActions); + if (pauseStart == null || pauseEnd == null || pauseEnd.isBefore(pauseStart)) { + continue; + } // Apply only if the pause overlaps this period's date range - if (pauseEnd.isAfter(period.getFromDate()) && !pauseStart.isAfter(period.getToDate())) { + if (pauseEnd.isAfter(periodFromDate) && !pauseStart.isAfter(periodToDate)) { final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd); period.setToDate(period.getToDate().plusDays(pauseDays)); if (period.getFromDate().isAfter(pauseStart)) { @@ -285,50 +390,15 @@ private void applyRecordedPauses(final WorkingCapitalLoanDelinquencyRangeSchedul } } - @Override - public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction rescheduleAction) { - final LocalDate businessDate = DateUtils.getBusinessLocalDate(); - final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); - if (rule == null) { - log.warn("No minimum payment rule found for WC loan {}, skipping reschedule", loan.getId()); - return; - } - final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, rule, rescheduleAction); - final Integer newFrequency = rescheduleAction.getFrequency() != null ? rescheduleAction.getFrequency() : rule.getFrequency(); - final DelinquencyFrequencyType newFreqType = rescheduleAction.getFrequencyType() != null ? rescheduleAction.getFrequencyType() - : rule.getFrequencyType(); + private LocalDate resolveEffectivePauseEnd(final WorkingCapitalLoanDelinquencyAction pause, + final List actions) { + final LocalDate pauseStart = pause.getStartDate(); + final LocalDate pauseEnd = pause.getEndDate(); - final List periods = loanDelinquencyRangeScheduleRepository - .findByLoanIdOrderByPeriodNumberAsc(loan.getId()); - - WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod = null; - final List futurePeriods = new ArrayList<>(); - - for (final WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { - if (period.getMinPaymentCriteriaMet() != null) { - continue; - } - final boolean isCurrent = !period.getFromDate().isAfter(businessDate) && !period.getToDate().isBefore(businessDate); - final boolean isFuture = period.getFromDate().isAfter(businessDate); - - if (isCurrent) { - currentPeriod = period; - period.setExpectedAmount(newExpectedAmount); - period.setOutstandingAmount(newExpectedAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); - } else if (isFuture) { - futurePeriods.add(period); - } - } - - if (currentPeriod != null) { - loanDelinquencyRangeScheduleRepository.saveAndFlush(currentPeriod); - updateFuturePeriods(currentPeriod, futurePeriods, newExpectedAmount, newFrequency, newFreqType); - } - - evaluateExpiredPeriods(loan, businessDate); - - log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={}%, frequency={} {}", loan.getId(), - rescheduleAction.getMinimumPayment(), newFrequency, newFreqType); + return actions.stream().filter(Objects::nonNull) + .filter(action -> DelinquencyAction.RESUME.equals(action.getAction()) && action.getStartDate() != null + && !action.getStartDate().isBefore(pauseStart) && action.getStartDate().isBefore(pauseEnd)) + .map(WorkingCapitalLoanDelinquencyAction::getStartDate).min(LocalDate::compareTo).orElse(pauseEnd); } private void updateFuturePeriods(final WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod, diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java index 6f3baf511e8..aa474b4c4fa 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyActionParseAndValidator.java @@ -27,12 +27,14 @@ 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 lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; @@ -52,6 +54,7 @@ @Component public class WorkingCapitalLoanDelinquencyActionParseAndValidator extends ParseAndValidator { + private static final String VALIDATION_RESOURCE = "workingCapitalLoanDelinquencyAction"; private static final String MINIMUM_PAYMENT = "minimumPayment"; private static final String MINIMUM_PAYMENT_TYPE = "minimumPaymentType"; private static final String FREQUENCY = "frequency"; @@ -62,50 +65,105 @@ public class WorkingCapitalLoanDelinquencyActionParseAndValidator extends ParseA public WorkingCapitalLoanDelinquencyAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, final List existing) { - final WorkingCapitalLoanDelinquencyAction parsedAction = parseCommand(command); - validateLoanIsActive(workingCapitalLoan); + final DataValidatorBuilder dataValidator = createValidator(); + final WorkingCapitalLoanDelinquencyAction parsedAction = parseCommand(command, dataValidator); + validateLoanIsActive(workingCapitalLoan, dataValidator); if (DelinquencyAction.PAUSE.equals(parsedAction.getAction())) { - validatePause(parsedAction, workingCapitalLoan, existing); + validatePause(parsedAction, workingCapitalLoan, existing, dataValidator); } else if (DelinquencyAction.RESCHEDULE.equals(parsedAction.getAction())) { - validateReschedule(parsedAction, workingCapitalLoan); + validateReschedule(parsedAction, workingCapitalLoan, dataValidator); + } else if (DelinquencyAction.RESUME.equals(parsedAction.getAction())) { + validateResume(parsedAction, existing, dataValidator); } + throwExceptionIfValidationWarningsExist(dataValidator); return parsedAction; } - private void validatePause(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan, + public WorkingCapitalLoanDelinquencyAction findActivePauseForResume(final List existing, + final LocalDate businessDate) { + return existing.stream().filter(action -> DelinquencyAction.PAUSE.equals(action.getAction())) + .filter(action -> !isPauseAlreadyResumed(action, existing)) + .filter(action -> !businessDate.isBefore(action.getStartDate()) && action.getEndDate().isAfter(businessDate)).findFirst() + .orElseThrow(() -> { + final DataValidatorBuilder dataValidator = createValidator(); + failParameterValidation(dataValidator, START_DATE, "resume.should.be.on.pause", + "Resume Delinquency Action can only be created during an active pause"); + return buildValidationException(dataValidator); + }); + } + + private boolean isPauseAlreadyResumed(final WorkingCapitalLoanDelinquencyAction pause, final List existing) { - validateBothDatesProvided(action); - validateStartBeforeEnd(action); - validateNotBeforeDisbursement(action, workingCapitalLoan); - validateNotInEvaluatedPeriod(action, workingCapitalLoan); - validateNoOverlap(action, existing); + return existing.stream().filter(action -> DelinquencyAction.RESUME.equals(action.getAction())).anyMatch( + resume -> !pause.getStartDate().isAfter(resume.getStartDate()) && !resume.getStartDate().isAfter(pause.getEndDate())); + } + + private void validatePause(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan, + final List existing, final DataValidatorBuilder dataValidator) { + validateBothDatesProvided(action, dataValidator); + validateStartBeforeEnd(action, dataValidator); + validateNotBeforeDisbursement(action, workingCapitalLoan, dataValidator); + validateNotInEvaluatedPeriod(action, workingCapitalLoan, dataValidator); + validateNoOverlap(action, existing, dataValidator); + } + + private void validateResume(final WorkingCapitalLoanDelinquencyAction action, final List existing, + final DataValidatorBuilder dataValidator) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + validateResumeStartDate(action, businessDate, dataValidator); + if (!dataValidator.hasError()) { + final WorkingCapitalLoanDelinquencyAction activePause = findActivePauseForResume(existing, businessDate); + validateResumeShortensActivePause(action, activePause, dataValidator); + } + } + + private void validateResumeStartDate(final WorkingCapitalLoanDelinquencyAction action, final LocalDate businessDate, + final DataValidatorBuilder dataValidator) { + dataValidator.reset().parameter(START_DATE).value(action.getStartDate()).notNull(); + if (action.getStartDate() != null && !action.getStartDate().equals(businessDate)) { + failParameterValidation(dataValidator, START_DATE, "resume.invalid.start.date", + "Start date of the Resume Delinquency action must be the current business date"); + } + } + + private void validateResumeShortensActivePause(final WorkingCapitalLoanDelinquencyAction resumeAction, + final WorkingCapitalLoanDelinquencyAction activePause, final DataValidatorBuilder dataValidator) { + if (!resumeAction.getStartDate().isAfter(activePause.getStartDate())) { + failParameterValidation(dataValidator, START_DATE, "resume.must.be.after.pause.start.date", + "Resume date must be after the active pause start date"); + } + if (!resumeAction.getStartDate().isBefore(activePause.getEndDate())) { + failParameterValidation(dataValidator, START_DATE, "resume.should.be.on.pause", + "Resume Delinquency Action can only be created during an active pause"); + } } - private void validateReschedule(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan) { - validateLoanIsDisbursed(workingCapitalLoan); - validateScheduleExists(workingCapitalLoan); + private void validateReschedule(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan, + final DataValidatorBuilder dataValidator) { + validateLoanIsDisbursed(workingCapitalLoan, dataValidator); + validateScheduleExists(workingCapitalLoan, dataValidator); final boolean hasPaymentGroup = action.getMinimumPayment() != null || action.getMinimumPaymentType() != null; final boolean hasFrequencyGroup = action.getFrequency() != null || action.getFrequencyType() != null; if (!hasPaymentGroup && !hasFrequencyGroup) { - raiseValidationError("wc-loan-delinquency-action-reschedule-no-change", + failGeneralValidation(dataValidator, "reschedule.no.change", "At least one of payment (minimumPayment + minimumPaymentType) or frequency (frequency + frequencyType) group must be provided"); } if (hasPaymentGroup) { - validateMinimumPaymentGroupProvided(action); + validateMinimumPaymentGroupProvided(action, dataValidator); } if (hasFrequencyGroup) { - validateFrequencyGroupProvided(action); + validateFrequencyGroupProvided(action, dataValidator); } } - private WorkingCapitalLoanDelinquencyAction parseCommand(final JsonCommand command) { + private WorkingCapitalLoanDelinquencyAction parseCommand(final JsonCommand command, final DataValidatorBuilder dataValidator) { final JsonElement json = command.parsedJson(); final WorkingCapitalLoanDelinquencyAction action = new WorkingCapitalLoanDelinquencyAction(); - action.setAction(extractAction(json)); + action.setAction(extractAction(json, dataValidator)); if (DelinquencyAction.PAUSE.equals(action.getAction())) { action.setStartDate(extractDate(json, START_DATE)); @@ -113,26 +171,32 @@ private WorkingCapitalLoanDelinquencyAction parseCommand(final JsonCommand comma } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { action.setStartDate(DateUtils.getBusinessLocalDate()); action.setMinimumPayment(extractBigDecimal(json, MINIMUM_PAYMENT)); - action.setMinimumPaymentType(extractMinimumPaymentType(json)); + action.setMinimumPaymentType(extractMinimumPaymentType(json, dataValidator)); action.setFrequency(extractInteger(json, FREQUENCY)); - action.setFrequencyType(extractFrequencyType(json)); + action.setFrequencyType(extractFrequencyType(json, dataValidator)); + } else if (DelinquencyAction.RESUME.equals(action.getAction())) { + action.setStartDate(extractDate(json, START_DATE)); } return action; } - private DelinquencyAction extractAction(final JsonElement json) { + private DelinquencyAction extractAction(final JsonElement json, final DataValidatorBuilder dataValidator) { final String actionString = jsonHelper.extractStringNamed(ACTION, json); + dataValidator.reset().parameter(ACTION).value(actionString).notBlank(); if (StringUtils.isEmpty(actionString)) { - raiseValidationError("wc-loan-delinquency-action-missing-action", "Delinquency Action must not be null or empty", ACTION); + return null; } if ("pause".equalsIgnoreCase(actionString)) { return DelinquencyAction.PAUSE; } else if ("reschedule".equalsIgnoreCase(actionString)) { return DelinquencyAction.RESCHEDULE; + } else if ("resume".equalsIgnoreCase(actionString)) { + return DelinquencyAction.RESUME; } - throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-action", - "Invalid Delinquency Action: " + actionString + ". Supported actions: pause, reschedule", ACTION))); + failParameterValidation(dataValidator, ACTION, "invalid.action", + "Invalid Delinquency Action: " + actionString + ". Supported actions: pause, reschedule, resume"); + return null; } private LocalDate extractDate(final JsonElement json, final String paramName) { @@ -155,7 +219,7 @@ private Integer extractInteger(final JsonElement json, final String paramName) { return null; } - private DelinquencyMinimumPaymentType extractMinimumPaymentType(final JsonElement json) { + private DelinquencyMinimumPaymentType extractMinimumPaymentType(final JsonElement json, final DataValidatorBuilder dataValidator) { final String value = jsonHelper.extractStringNamed(MINIMUM_PAYMENT_TYPE, json); if (StringUtils.isEmpty(value)) { return null; @@ -163,14 +227,13 @@ private DelinquencyMinimumPaymentType extractMinimumPaymentType(final JsonElemen try { return DelinquencyMinimumPaymentType.valueOf(value.toUpperCase()); } catch (IllegalArgumentException e) { - throw new PlatformApiDataValidationException( - List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-minimum-payment-type", - "Invalid minimum payment type: " + value + ". Supported: PERCENTAGE, FLAT", MINIMUM_PAYMENT_TYPE)), - e); + failParameterValidation(dataValidator, MINIMUM_PAYMENT_TYPE, "invalid.minimum.payment.type", + "Invalid minimum payment type: " + value + ". Supported: PERCENTAGE, FLAT"); + return null; } } - private DelinquencyFrequencyType extractFrequencyType(final JsonElement json) { + private DelinquencyFrequencyType extractFrequencyType(final JsonElement json, final DataValidatorBuilder dataValidator) { final String value = jsonHelper.extractStringNamed(FREQUENCY_TYPE, json); if (StringUtils.isEmpty(value)) { return null; @@ -178,92 +241,79 @@ private DelinquencyFrequencyType extractFrequencyType(final JsonElement json) { try { return DelinquencyFrequencyType.valueOf(value.toUpperCase()); } catch (IllegalArgumentException e) { - throw new PlatformApiDataValidationException( - List.of(ApiParameterError.parameterError("wc-loan-delinquency-action-invalid-frequency-type", - "Invalid frequency type: " + value + ". Supported: DAYS, WEEKS, MONTHS, YEARS", FREQUENCY_TYPE)), - e); + failParameterValidation(dataValidator, FREQUENCY_TYPE, "invalid.frequency.type", + "Invalid frequency type: " + value + ". Supported: DAYS, WEEKS, MONTHS, YEARS"); + return null; } } - private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan) { + private void validateLoanIsActive(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { if (!workingCapitalLoan.getLoanStatus().isActive()) { - raiseValidationError("wc-loan-delinquency-action-invalid-loan-state", + failGeneralValidation(dataValidator, "invalid.loan.state", "Delinquency actions can be created only for active Working Capital loans."); } } - private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan) { + private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { final boolean isDisbursed = workingCapitalLoan.getDisbursementDetails().stream() .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); if (!isDisbursed) { - raiseValidationError("wc-loan-delinquency-action-loan-not-disbursed", "Reschedule action requires the loan to be disbursed."); + failGeneralValidation(dataValidator, "loan.not.disbursed", "Reschedule action requires the loan to be disbursed."); } } - private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan) { + private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { final List periods = rangeScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); if (periods.isEmpty()) { - raiseValidationError("wc-loan-delinquency-action-no-schedule", - "Reschedule action requires an existing delinquency range schedule."); + failGeneralValidation(dataValidator, "no.schedule", "Reschedule action requires an existing delinquency range schedule."); } } - private void validateMinimumPaymentGroupProvided(final WorkingCapitalLoanDelinquencyAction action) { - if (action.getMinimumPayment() == null || action.getMinimumPayment().compareTo(BigDecimal.ZERO) <= 0) { - raiseValidationError("wc-loan-delinquency-action-invalid-minimum-payment", - "The parameter `minimumPayment` must be greater than 0", MINIMUM_PAYMENT); - } + private void validateMinimumPaymentGroupProvided(final WorkingCapitalLoanDelinquencyAction action, + final DataValidatorBuilder dataValidator) { + dataValidator.reset().parameter(MINIMUM_PAYMENT).value(action.getMinimumPayment()).notNull().positiveAmount(); if (action.getMinimumPaymentType() == null) { - raiseValidationError("wc-loan-delinquency-action-missing-minimum-payment-type", - "The parameter `minimumPaymentType` is mandatory when `minimumPayment` is provided", MINIMUM_PAYMENT_TYPE); + failParameterValidation(dataValidator, MINIMUM_PAYMENT_TYPE, "mandatory.when.minimum.payment.provided", + "The parameter `minimumPaymentType` is mandatory when `minimumPayment` is provided"); } } - private void validateFrequencyGroupProvided(final WorkingCapitalLoanDelinquencyAction action) { - if (action.getFrequency() == null || action.getFrequency() <= 0) { - raiseValidationError("wc-loan-delinquency-action-invalid-frequency", "The parameter `frequency` must be greater than 0", - FREQUENCY); - } + private void validateFrequencyGroupProvided(final WorkingCapitalLoanDelinquencyAction action, + final DataValidatorBuilder dataValidator) { + dataValidator.reset().parameter(FREQUENCY).value(action.getFrequency()).notNull().integerGreaterThanZero(); if (action.getFrequencyType() == null) { - raiseValidationError("wc-loan-delinquency-action-missing-frequency-type", - "The parameter `frequencyType` is mandatory when `frequency` is provided", FREQUENCY_TYPE); + failParameterValidation(dataValidator, FREQUENCY_TYPE, "mandatory.when.frequency.provided", + "The parameter `frequencyType` is mandatory when `frequency` is provided"); } } - private void validateBothDatesProvided(final WorkingCapitalLoanDelinquencyAction action) { - if (action.getStartDate() == null) { - raiseValidationError("wc-loan-delinquency-action-pause-startDate-cannot-be-blank", "The parameter `startDate` is mandatory", - START_DATE); - } - if (action.getEndDate() == null) { - raiseValidationError("wc-loan-delinquency-action-pause-endDate-cannot-be-blank", "The parameter `endDate` is mandatory", - END_DATE); - } + private void validateBothDatesProvided(final WorkingCapitalLoanDelinquencyAction action, final DataValidatorBuilder dataValidator) { + dataValidator.reset().parameter(START_DATE).value(action.getStartDate()).notNull(); + dataValidator.reset().parameter(END_DATE).value(action.getEndDate()).notNull(); } - private void validateStartBeforeEnd(final WorkingCapitalLoanDelinquencyAction action) { + private void validateStartBeforeEnd(final WorkingCapitalLoanDelinquencyAction action, final DataValidatorBuilder dataValidator) { if (action.getStartDate() != null && action.getEndDate() != null && !action.getStartDate().isBefore(action.getEndDate())) { - raiseValidationError("wc-loan-delinquency-action-invalid-start-date-and-end-date", - "Delinquency pause period must be at least one day"); + failGeneralValidation(dataValidator, "invalid.start.date.and.end.date", "Delinquency pause period must be at least one day"); } } private void validateNotBeforeDisbursement(final WorkingCapitalLoanDelinquencyAction action, - final WorkingCapitalLoan workingCapitalLoan) { + final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { if (action.getStartDate() == null) { return; } final LocalDate firstDisbursementDate = workingCapitalLoan.getDisbursementDetails().stream() .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).filter(Objects::nonNull).findFirst().orElse(null); if (firstDisbursementDate != null && firstDisbursementDate.isAfter(action.getStartDate())) { - raiseValidationError("wc-loan-delinquency-action-invalid-start-date", - "Start date of pause period must be after first disbursal date", START_DATE); + failParameterValidation(dataValidator, START_DATE, "must.be.after.first.disbursal.date", + "Start date of pause period must be after first disbursal date"); } } - private void validateNotInEvaluatedPeriod(final WorkingCapitalLoanDelinquencyAction action, - final WorkingCapitalLoan workingCapitalLoan) { + private void validateNotInEvaluatedPeriod(final WorkingCapitalLoanDelinquencyAction action, final WorkingCapitalLoan workingCapitalLoan, + final DataValidatorBuilder dataValidator) { if (action.getStartDate() == null) { return; } @@ -272,21 +322,20 @@ private void validateNotInEvaluatedPeriod(final WorkingCapitalLoanDelinquencyAct final boolean startsInEvaluatedPeriod = periods.stream().filter(p -> p.getMinPaymentCriteriaMet() != null) .anyMatch(p -> !action.getStartDate().isAfter(p.getToDate())); if (startsInEvaluatedPeriod) { - raiseValidationError("wc-loan-delinquency-action-pause-in-evaluated-period", - "Pause start date cannot fall within or before an already evaluated delinquency range period", START_DATE); + failParameterValidation(dataValidator, START_DATE, "pause.in.evaluated.period", + "Pause start date cannot fall within or before an already evaluated delinquency range period"); } } private void validateNoOverlap(final WorkingCapitalLoanDelinquencyAction parsed, - final List existing) { + final List existing, final DataValidatorBuilder dataValidator) { if (parsed.getStartDate() == null || parsed.getEndDate() == null) { return; } final boolean overlaps = existing.stream().filter(e -> DelinquencyAction.PAUSE.equals(e.getAction())) .anyMatch(e -> isOverlapping(parsed, e)); if (overlaps) { - raiseValidationError("wc-loan-delinquency-action-overlapping", - "Delinquency pause period cannot overlap with another pause period"); + failGeneralValidation(dataValidator, "overlapping", "Delinquency pause period cannot overlap with another pause period"); } } @@ -296,13 +345,25 @@ private boolean isOverlapping(final WorkingCapitalLoanDelinquencyAction parsed, || (parsed.getStartDate().isEqual(existing.getStartDate()) && parsed.getEndDate().isEqual(existing.getEndDate())); } - private void raiseValidationError(final String globalisationMessageCode, final String msg) throws PlatformApiDataValidationException { - throw new PlatformApiDataValidationException(List.of(ApiParameterError.generalError(globalisationMessageCode, msg))); + private DataValidatorBuilder createValidator() { + return new DataValidatorBuilder(new ArrayList<>()).resource(VALIDATION_RESOURCE); + } + + private void failParameterValidation(final DataValidatorBuilder dataValidator, final String parameter, final String errorCodeSuffix, + final String defaultUserMessage) { + dataValidator.getDataValidationErrors().add(ApiParameterError.parameterError( + "validation.msg." + VALIDATION_RESOURCE + "." + parameter + "." + errorCodeSuffix, defaultUserMessage, parameter)); + } + + private void failGeneralValidation(final DataValidatorBuilder dataValidator, final String errorCodeSuffix, + final String defaultUserMessage) { + dataValidator.getDataValidationErrors() + .add(ApiParameterError.generalError("validation.msg." + VALIDATION_RESOURCE + "." + errorCodeSuffix, defaultUserMessage)); } - private void raiseValidationError(final String globalisationMessageCode, final String msg, final String fieldName) - throws PlatformApiDataValidationException { - throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(globalisationMessageCode, msg, fieldName))); + private PlatformApiDataValidationException buildValidationException(final DataValidatorBuilder dataValidator) { + return new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidator.getDataValidationErrors()); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java index 12532080209..2b5453ad308 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java @@ -245,13 +245,13 @@ public void testInvalidActionTypeIsRejected() { final LocalDate disbursementDate = LocalDate.now(ZoneId.systemDefault()).minusDays(5); WorkingCapitalLoanDelinquencyActionHelper.activateLoan(loanId, disbursementDate); - // when - send action type "resume" which is not supported yet + // when - send unsupported action type // then - should fail with 400 CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, - () -> WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanId, "resume", disbursementDate, + () -> WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanId, "invalid", disbursementDate, disbursementDate.plusDays(10))); assertEquals(400, exception.getStatus()); - log.info("Expected 400 for unsupported action 'resume': {}", exception.getMessage()); + log.info("Expected 400 for unsupported action 'invalid': {}", exception.getMessage()); } /**