diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachDisable.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachDisable.feature index 60413f0a7d9..22bed69ebb6 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachDisable.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachDisable.feature @@ -54,8 +54,8 @@ Feature: Working Capital Breach Disable And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach disable with startDate "15 January 2026" Then Initiating a Working Capital loan breach disable with startDate "15 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: breach.already.disabled | + | httpCode | message | + | 400 | Breach evaluation is already disabled for this Working Capital loan. It must be enabled before disabling again. | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -73,8 +73,8 @@ Feature: Working Capital Breach Disable And Admin sets the business date to "15 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach enable with startDate "15 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: no.active.breach.disable.to.enable | + | httpCode | message | + | 400 | There is no active breach disable to enable for this Working Capital loan. | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -92,14 +92,14 @@ Feature: Working Capital Breach Disable And Admin sets the business date to "15 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach disable with startDate "10 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.be.current.business.date | + | httpCode | message | + | 400 | Start date of a disable or enable action must be the current business date | And Initiating a Working Capital loan breach enable with startDate "10 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.be.current.business.date | + | httpCode | message | + | 400 | Start date of a disable or enable action must be the current business date | And Initiating a Working Capital loan breach disable with startDate "15 January 2026" and endDate "20 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.not.be.provided.for.disable.or.enable | + | httpCode | message | + | 400 | End date must not be provided for a disable or enable action | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -118,11 +118,11 @@ Feature: Working Capital Breach Disable And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach disable with startDate "15 January 2026" Then Initiating a Working Capital loan breach pause with startDate "16 January 2026" and endDate "20 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: breach.is.disabled | + | httpCode | message | + | 400 | Breach pause, resume, reschedule and reset actions are not allowed while breach evaluation is disabled. | And Initiating a Working Capital loan breach reschedule with minimumPayment "50" "FLAT" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: breach.is.disabled | + | httpCode | message | + | 400 | Breach pause, resume, reschedule and reset actions are not allowed while breach evaluation is disabled. | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -163,8 +163,8 @@ Feature: Working Capital Breach Disable And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach disable with startDate "15 January 2026" Then Admin fails to create WC breach reset action with the following data: - | httpCode | errorMessage | - | 400 | Failed data validation due to: breach.is.disabled | + | httpCode | errorMessage | + | 400 | Breach pause, resume, reschedule and reset actions are not allowed while breach evaluation is disabled. | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -184,8 +184,8 @@ Feature: Working Capital Breach Disable And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "20 January 2026" And Admin initiate a Working Capital loan breach disable with startDate "15 January 2026" Then Initiating a Working Capital loan breach resume with startDate "15 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: breach.is.disabled | + | httpCode | message | + | 400 | Breach pause, resume, reschedule and reset actions are not allowed while breach evaluation is disabled. | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -208,8 +208,8 @@ Feature: Working Capital Breach Disable | action | startDate | endDate | | DISABLE | 2026-01-15 | | Then Initiating a Working Capital loan breach undo reset results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: breach.is.disabled | + | httpCode | message | + | 400 | Breach pause, resume, reschedule and reset actions are not allowed while breach evaluation is disabled. | When Admin closes the Working Capital loan with a full repayment on "15 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -359,8 +359,8 @@ Feature: Working Capital Breach Disable 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 Initiating a Working Capital loan breach disable with startDate "01 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: no.breach.configuration | + | httpCode | message | + | 400 | Breach actions require a breach configuration on the Working Capital loan. | When Admin closes the Working Capital loan with a full repayment on "01 January 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" @@ -374,8 +374,8 @@ Feature: Working Capital Breach Disable | 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 disable with startDate "01 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: loan.is.not.active | + | httpCode | message | + | 400 | Breach actions can be created only for active Working Capital loans. | @TestRailId:C85479 Scenario: Verify Working Capital breach enable/disable - UC17: Breach disable can be applied and retrieved by loan external id diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature index 46120811192..0c5de4db609 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -133,11 +133,11 @@ Feature: Working Capital Breach Pause And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" Then Initiating a Working Capital loan breach pause with startDate "20 January 2026" and endDate "30 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: overlapping.pause.periods | + | httpCode | message | + | 400 | Breach pause period cannot overlap with another pause period | 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 | + | httpCode | message | + | 400 | Breach pause period cannot overlap with another pause period | And Working Capital loan breach action has the following data: | action | startDate | endDate | | PAUSE | 2026-01-15 | 2026-01-25 | @@ -254,8 +254,8 @@ Feature: Working Capital Breach Pause 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 | + | httpCode | message | + | 400 | End date of pause period must be on or after the start date | 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 | @@ -285,8 +285,8 @@ Feature: Working Capital Breach Pause 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 | + | httpCode | message | + | 400 | Breach actions require a breach configuration on the Working Capital loan. | Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C85245 @@ -327,8 +327,8 @@ Feature: Working Capital Breach Pause | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" Then Initiating a Working Capital loan breach pause with startDate "15 January 2026" and endDate "25 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: loan.is.not.active | + | httpCode | message | + | 400 | Breach actions can be created only for active Working Capital loans. | @TestRailId:C85247 Scenario: Verify working capital loan breach pause - backdated payment resets breach flag of an already breached period @@ -377,8 +377,8 @@ Feature: Working Capital Breach Pause 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 | + | httpCode | message | + | 400 | Breach pause period cannot overlap with another pause period | # 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: @@ -594,8 +594,8 @@ Feature: Working Capital Breach Pause When Admin sets the business date to "20 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach resume with startDate "20 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: resume.not.during.active.pause | + | httpCode | message | + | 400 | Resume breach action can only be created during an active pause | Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" @TestRailId:C85253 @@ -613,8 +613,8 @@ Feature: Working Capital Breach Pause When Admin sets the business date to "10 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach resume with startDate "11 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.be.current.business.date | + | httpCode | message | + | 400 | Start date of a resume action must be the current business date | Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" @TestRailId:C85254 @@ -632,8 +632,8 @@ Feature: Working Capital Breach Pause When Admin sets the business date to "10 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach action "resume" with startDate "10 January 2026" and endDate "20 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.not.be.provided.for.resume | + | httpCode | message | + | 400 | End date must not be provided for a resume action | Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" @TestRailId:C85255 @@ -652,8 +652,8 @@ Feature: Working Capital Breach Pause And Admin runs inline COB job for Working Capital Loan by loanId And Admin initiate a Working Capital loan breach resume with startDate "10 January 2026" Then Initiating a Working Capital loan breach resume with startDate "10 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: resume.not.during.active.pause | + | httpCode | message | + | 400 | Resume breach action can only be created during an active pause | Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" @TestRailId:C85256 @@ -773,8 +773,8 @@ Feature: Working Capital Breach Pause When Admin sets the business date to "10 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach resume with startDate "09 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: must.be.current.business.date | + | httpCode | message | + | 400 | Start date of a resume action must be the current business date | Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" @TestRailId:C85260 @@ -791,8 +791,8 @@ Feature: Working Capital Breach Pause When Admin sets the business date to "10 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Initiating a Working Capital loan breach resume with startDate "10 January 2026" results an error with the following data: - | httpCode | message | - | 400 | Failed data validation due to: resume.not.during.active.pause | + | httpCode | message | + | 400 | Resume breach action can only be created during an active pause | Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" @TestRailId:C85261 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 23352864483..857e0e9901c 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -45,7 +45,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" 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 - When Admin sets the business date to "01 June 2026" + When Admin sets the business date to "15 May 2026" And Admin runs inline COB job for Working Capital Loan by loanId When Admin creates WC breach reschedule action with the following parameters: | frequency | frequencyType | @@ -56,9 +56,10 @@ Feature: Working Capital Breach Reschedule Action | 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 | true | - | 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 | + | 3 | 2026-05-01 | 2026-05-30 | 30 | 110.70 | 110.70 | null | true | + | 4 | 2026-05-31 | 2026-06-29 | 30 | 110.70 | 110.70 | null | true | + | 5 | 2026-06-30 | 2026-07-29 | 30 | 110.70 | 110.70 | null | true | + | 6 | 2026-07-30 | 2026-08-28 | 30 | 110.70 | 110.70 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85274 @@ -72,7 +73,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" 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 - When Admin sets the business date to "01 June 2026" + When Admin sets the business date to "15 May 2026" And Admin runs inline COB job for Working Capital Loan by loanId When Admin creates WC breach reschedule action with the following parameters: | minimumPayment | minimumPaymentType | frequency | frequencyType | @@ -83,9 +84,10 @@ Feature: Working Capital Breach Reschedule Action | 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 | true | - | 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 | + | 3 | 2026-05-01 | 2026-05-30 | 30 | 90 | 90 | null | true | + | 4 | 2026-05-31 | 2026-06-29 | 30 | 90 | 90 | null | true | + | 5 | 2026-06-30 | 2026-07-29 | 30 | 90 | 90 | null | true | + | 6 | 2026-07-30 | 2026-08-28 | 30 | 90 | 90 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85275 @@ -99,7 +101,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" 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 - When Admin sets the business date to "01 June 2026" + When Admin sets the business date to "15 May 2026" And Admin runs inline COB job for Working Capital Loan by loanId When Admin creates WC breach reschedule action with the following parameters: | minimumPayment | minimumPaymentType | frequency | frequencyType | @@ -113,9 +115,10 @@ Feature: Working Capital Breach Reschedule Action | 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 | true | - | 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 | + | 3 | 2026-05-01 | 2026-05-30 | 30 | 90 | 90 | null | true | + | 4 | 2026-05-31 | 2026-06-29 | 30 | 90 | 90 | null | true | + | 5 | 2026-06-30 | 2026-07-29 | 30 | 90 | 90 | null | true | + | 6 | 2026-07-30 | 2026-08-28 | 30 | 90 | 90 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85276 @@ -158,7 +161,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" 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 fails to create WC breach reschedule action with no parameters with error containing "At least one of payment (minimumPayment + minimumPaymentType) or frequency (frequency + frequencyType) group must be provided" Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C85278 @@ -186,7 +189,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" 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 - When Admin sets the business date to "01 June 2026" + When Admin sets the business date to "15 May 2026" And Admin runs inline COB job for Working Capital Loan by loanId When Admin creates WC breach reschedule action with the following parameters: | frequency | frequencyType | @@ -200,9 +203,10 @@ Feature: Working Capital Breach Reschedule Action | 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 | true | - | 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 | + | 3 | 2026-05-01 | 2026-05-30 | 30 | 90 | 90 | null | true | + | 4 | 2026-05-31 | 2026-06-29 | 30 | 90 | 90 | null | true | + | 5 | 2026-06-30 | 2026-07-29 | 30 | 90 | 90 | null | true | + | 6 | 2026-07-30 | 2026-08-28 | 30 | 90 | 90 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85280 @@ -273,7 +277,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2019" with "9000" amount and expected disbursement date on "01 January 2019" And Admin successfully disburse the Working Capital loan on "01 January 2019" with "9000" EUR transaction amount and "1000" discount amount And Admin runs inline COB job for Working Capital Loan by loanId - When Admin sets the business date to "10 March 2019" + When Admin sets the business date to "15 January 2019" 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 | 900.00 | 900.00 | null | null | @@ -286,10 +290,12 @@ Feature: Working Capital Breach Reschedule Action 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 | 2019-01-01 | 2019-03-31 | 90 | 900.00 | 900.00 | null | true | - | 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 | + | 1 | 2019-01-01 | 2019-01-30 | 30 | 900.00 | 900.00 | null | true | + | 2 | 2019-01-31 | 2019-03-01 | 30 | 900.00 | 900.00 | null | true | + | 3 | 2019-03-02 | 2019-03-31 | 30 | 900.00 | 900.00 | null | true | + | 4 | 2019-04-01 | 2019-04-30 | 30 | 900.00 | 900.00 | null | true | + | 5 | 2019-05-01 | 2019-05-30 | 30 | 900.00 | 900.00 | null | true | + | 6 | 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 @@ -305,7 +311,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2019" with "9000" amount and expected disbursement date on "01 January 2019" And Admin successfully disburse the Working Capital loan on "01 January 2019" with "9000" EUR transaction amount and "1000" discount amount And Admin runs inline COB job for Working Capital Loan by loanId - When Admin sets the business date to "10 March 2019" + When Admin sets the business date to "15 January 2019" 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 | 900.00 | 900.00 | null | null | @@ -318,10 +324,12 @@ Feature: Working Capital Breach Reschedule Action 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 | 2019-01-01 | 2019-03-31 | 90 | 500.00 | 500.00 | null | true | - | 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 | + | 1 | 2019-01-01 | 2019-01-30 | 30 | 500.00 | 500.00 | null | true | + | 2 | 2019-01-31 | 2019-03-01 | 30 | 500.00 | 500.00 | null | true | + | 3 | 2019-03-02 | 2019-03-31 | 30 | 500.00 | 500.00 | null | true | + | 4 | 2019-04-01 | 2019-04-30 | 30 | 500.00 | 500.00 | null | true | + | 5 | 2019-05-01 | 2019-05-30 | 30 | 500.00 | 500.00 | null | true | + | 6 | 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" @TestRailId:C85588 @@ -335,7 +343,7 @@ Feature: Working Capital Breach Reschedule Action And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" 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 - When Admin sets the business date to "01 June 2026" + When Admin sets the business date to "15 May 2026" And Admin runs inline COB job for Working Capital Loan by loanId When Admin creates WC breach reschedule action with the following parameters: | minimumPayment | minimumPaymentType | @@ -344,16 +352,351 @@ Feature: Working Capital Breach Reschedule Action | frequency | frequencyType | | 30 | DAYS | Then WC loan breach actions have the following data: - | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | - | RESCHEDULE | 01 June 2026 | 1 | PERCENTAGE | | | - | RESCHEDULE | 01 June 2026 | | | 30 | DAYS | + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 15 May 2026 | 1 | PERCENTAGE | | | + | RESCHEDULE | 15 May 2026 | | | 30 | DAYS | When Admin sets the business date to "15 August 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 | true | - | 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 | + | 3 | 2026-05-01 | 2026-05-30 | 30 | 90 | 90 | null | true | + | 4 | 2026-05-31 | 2026-06-29 | 30 | 90 | 90 | null | true | + | 5 | 2026-06-30 | 2026-07-29 | 30 | 90 | 90 | null | true | + | 6 | 2026-07-30 | 2026-08-28 | 30 | 90 | 90 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" + + @TestRailId:C93966 + Scenario: Verify breach reschedule - UC13: single reschedule changes both minimum payment and frequency + 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 | delinquencyGraceDays | + | 60 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" + # --- Repayment --- + And Customer makes repayment on "15 January 2026" with 300.0 transaction amount on Working Capital loan + 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-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-30 | 60 | 400.00 | 400.00 | null | null | + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 33.33 | PERCENTAGE | 30 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | null | + When Admin sets the business date to "01 May 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-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | true | + | 3 | 2026-04-01 | 2026-04-30 | 30 | 266.64 | 266.64 | null | true | + | 4 | 2026-05-01 | 2026-05-30 | 30 | 266.64 | 266.64 | null | null | + And WC loan breach actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | 30 | DAYS | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "01 May 2026" + + @TestRailId:C93967 + Scenario: Verify breach reschedule - UC14: frequency change is rejected when resulting period end date is in the past (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 Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 60 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" + # --- Repayment --- + And Customer makes repayment on "15 January 2026" with 300.0 transaction amount on Working Capital loan + 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-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-30 | 60 | 400.00 | 400.00 | null | null | + Then Admin fails to create WC breach reschedule action with minimumPayment 33 PERCENTAGE and frequency 30 DAYS with error containing "Frequency change results a breach period endDate before current businessDate is not allowed" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-30 | 60 | 400.00 | 400.00 | null | null | + And WC loan breach actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "15 April 2026" + + @TestRailId:C93968 + Scenario: Verify breach reschedule - UC15: multiple reschedules progressively reduce minimum payment + 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 | delinquencyGraceDays | + | 60 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" + # --- Repayment --- + And Customer makes repayment on "15 January 2026" with 300.0 transaction amount on Working Capital loan + When Admin sets the business date to "15 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 33.33 | PERCENTAGE | 30 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | null | + When Admin sets the business date to "03 April 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 20 | PERCENTAGE | 30 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | true | + | 3 | 2026-04-01 | 2026-04-30 | 30 | 160.00 | 160.00 | null | null | + And WC loan breach actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | 30 | DAYS | + | RESCHEDULE | 03 April 2026 | 20 | PERCENTAGE | 30 | DAYS | + When Admin sets the business date to "01 May 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-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | true | + | 3 | 2026-04-01 | 2026-04-30 | 30 | 160.00 | 160.00 | null | true | + | 4 | 2026-05-01 | 2026-05-30 | 30 | 160.00 | 160.00 | null | null | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "01 May 2026" + + @TestRailId:C93969 + Scenario: Verify breach reschedule - UC16: accepted when the recalculated period end date equals the current business date + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 60 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" + # --- Repayment --- + And Customer makes repayment on "15 January 2026" with 300.0 transaction amount on Working Capital loan + When Admin sets the business date to "31 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-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-30 | 60 | 400.00 | 400.00 | null | null | + Then Admin fails to create WC breach reschedule action with minimumPayment 33 PERCENTAGE and frequency 29 DAYS with error containing "Frequency change results a breach period endDate before current businessDate is not allowed" + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 33.33 | PERCENTAGE | 30 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | null | + When Admin sets the business date to "01 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-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-03-31 | 30 | 266.64 | 266.64 | null | true | + | 3 | 2026-04-01 | 2026-04-30 | 30 | 266.64 | 266.64 | null | null | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "01 April 2026" + + @TestRailId:C93970 + Scenario: Verify breach reschedule - UC17: re-dates the current period from its start plus the new frequency and keeps an overlapping pause 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 Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 60 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" + # --- Repayment --- + And Customer makes repayment on "15 January 2026" with 300.0 transaction amount on Working Capital loan + 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-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-30 | 60 | 400.00 | 400.00 | null | null | + # --- Breach pause --- + And Admin initiate a Working Capital loan breach pause with startDate "10 March 2026" and endDate "19 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-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-05-10 | 70 | 400.00 | 400.00 | null | null | + When Admin sets the business date to "10 April 2026" + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | frequency | frequencyType | + | 33.33 | PERCENTAGE | 90 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-06-09 | 100 | 266.64 | 266.64 | null | null | + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 30 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-10 | 40 | 266.64 | 266.64 | null | null | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "10 April 2026" + + @TestRailId:C93971 + Scenario: Verify breach reschedule - UC18: validates the recalculated period end date against the resumed pause length + 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 | delinquencyGraceDays | + | 60 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" + # --- Repayment --- + And Customer makes repayment on "15 January 2026" with 300.0 transaction amount on Working Capital loan + 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-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-30 | 60 | 400.00 | 400.00 | null | null | + # --- Breach pause --- + And Admin initiate a Working Capital loan breach pause with startDate "10 March 2026" and endDate "19 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-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-05-10 | 70 | 400.00 | 400.00 | null | null | + # --- Breach resume --- + And Admin initiate a Working Capital loan breach resume with startDate "15 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-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-05-06 | 66 | 400.00 | 400.00 | null | null | + When Admin sets the business date to "15 April 2026" + Then Admin fails to create WC breach reschedule action with minimumPayment 33 PERCENTAGE and frequency 35 DAYS with error containing "Frequency change results a breach period endDate before current businessDate is not allowed" + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 39 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 100.00 | null | true | + | 2 | 2026-03-02 | 2026-04-15 | 45 | 400.00 | 400.00 | null | null | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "15 April 2026" + + @TestRailId:C93972 + Scenario: Verify breach reschedule - UC19: reschedule applied during an active pause extends the current period by the full pause and the new frequency + 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 | delinquencyGraceDays | + | 30 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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-01-30 | 30 | 400.00 | 400.00 | null | null | + When Admin sets the business date to "05 January 2026" + # --- Breach pause --- + And Admin initiate a Working Capital loan breach pause with startDate "05 January 2026" and endDate "24 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-02-19 | 50 | 400.00 | 400.00 | null | null | + When Admin sets the business date to "14 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 60 | DAYS | + Then 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 | 400.00 | 400.00 | null | null | + And Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-05 | 2026-01-24 | + | RESCHEDULE | 2026-01-14 | | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "14 January 2026" + + @TestRailId:C93973 + Scenario: Verify breach reschedule - UC20: reschedule to a longer frequency followed by a pause extends the rescheduled period + 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 | delinquencyGraceDays | + | 30 | DAYS | PERCENTAGE | 50 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "05 January 2026" + # --- Breach reschedule --- + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 60 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-03-01 | 60 | 400.00 | 400.00 | null | null | + When Admin sets the business date to "10 January 2026" + # --- Breach pause --- + And Admin initiate a Working Capital loan breach pause with startDate "10 January 2026" and endDate "29 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-21 | 80 | 400.00 | 400.00 | null | null | + And Working Capital loan breach action has the following data: + | action | startDate | endDate | + | RESCHEDULE | 2026-01-05 | | + | PAUSE | 2026-01-10 | 2026-01-29 | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReset.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReset.feature index 6323fd6287c..8354459f545 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReset.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReset.feature @@ -359,7 +359,7 @@ Feature: Working Capital Breach Reset and Undo Reset And Admin runs inline COB job for Working Capital Loan by loanId When Admin sets the business date to "15 April 2026" And Admin creates WC breach reset action - Then Admin fails to create WC breach reset action with error containing "reset.already.exists.in.current.period" + Then Admin fails to create WC breach reset action with error containing "A breach reset already exists in the current breach period." Then Admin closes the Working Capital loan with a full repayment on "15 April 2026" @TestRailId:C85436 @@ -424,7 +424,7 @@ Feature: Working Capital Breach Reset and Undo Reset When Admin sets the business date to "03 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId When Admin sets the business date to "15 April 2026" - Then Admin fails to create WC breach undo reset action with error containing "no.breach.reset.to.undo" + Then Admin fails to create WC breach undo reset action with error containing "There is no active breach reset to undo for this Working Capital loan." Then Admin closes the Working Capital loan with a full repayment on "15 April 2026" @TestRailId:C85438 @@ -449,7 +449,7 @@ Feature: Working Capital Breach Reset and Undo Reset When Admin sets the business date to "15 April 2026" And Admin creates WC breach reset action And Admin creates WC breach undo reset action - Then Admin fails to create WC breach undo reset action with error containing "no.breach.reset.to.undo" + Then Admin fails to create WC breach undo reset action with error containing "There is no active breach reset to undo for this Working Capital loan." Then Admin closes the Working Capital loan with a full repayment on "15 April 2026" @TestRailId:C85439 @@ -463,7 +463,7 @@ Feature: Working Capital Breach Reset and Undo Reset | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | | 01 January 2026 | 01 January 2026 | 800 | 10000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" - Then Admin fails to create WC breach "" action with error containing "loan.is.not.active" + Then Admin fails to create WC breach "" action with error containing "Breach actions can be created only for active Working Capital loans." When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @@ -485,7 +485,7 @@ Feature: Working Capital Breach Reset and Undo Reset | 01 January 2026 | 01 January 2026 | 800 | 10000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount - Then Admin fails to create WC breach reset action with error containing "no.breach.schedule" + Then Admin fails to create WC breach reset action with error containing "Breach action requires an existing breach schedule." And Admin runs inline COB job for Working Capital Loan by loanId Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @@ -503,7 +503,7 @@ Feature: Working Capital Breach Reset and Undo Reset When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" 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" - Then Admin fails to create WC breach reset action with error containing "no.breach.evaluation.period" + Then Admin fails to create WC breach reset action with error containing "There is no breach evaluation period covering the current business date." Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" @TestRailId:C85442 @@ -517,7 +517,7 @@ Feature: Working Capital Breach Reset and Undo Reset And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin fails to create WC breach "" action with error containing "no.breach.configuration" + Then Admin fails to create WC breach "" action with error containing "Breach actions require a breach configuration on the Working Capital loan." Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" Examples: diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature index 7bfb13f904d..47457c14472 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachStartDateType.feature @@ -116,6 +116,38 @@ Feature: Working Capital Breach Start Date Type | breachStartDate | | 2026-01-06 | + @TestRailId:C93977 + Scenario: Verify breach start date type - UC3.1: breachGraceDays shifts properly when breachStartType is DISBURSEMENT + 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 | breachStartType | + | 3 | DAYS | FLAT | 100 | 5 | DISBURSEMENT | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 10 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 "10 January 2026" + When Admin sets the business date to "10 January 2026" + And Admin successfully disburse the Working Capital loan on "10 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # Schedule fromDate will be set at disbursement date 10 Jan + 5 grace days = 15 Jan but no breach will occur yet + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-15 | 2026-01-17 | 3 | 100 | 100 | null | null | + And Working capital loan account has the correct data: + | breachStartDate | + | null | + # Schedule starts at disbursement date 10 Jan + 5 grace days = 15 Jan + When Admin sets the business date to "20 January 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-15 | 2026-01-17 | 3 | 100 | 100 | null | true | + | 2 | 2026-01-18 | 2026-01-20 | 3 | 100 | 100 | null | null | + And Working capital loan account has the correct data: + | breachStartDate | + | 2026-01-15 | + @TestRailId:C89776 Scenario: Verify breach start date type - UC4: loan-level DISBURSEMENT override wins over LOAN_CREATION product default When Admin sets the business date to "01 January 2026" diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature index a6ea1e9954c..38f81a93d66 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -67,11 +67,12 @@ Feature: Working Capital Delinquency Reschedule Action | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | - | 6 | 31 May 2026 | 29 June 2026 | 300 | 0 | 300 | false | - | 7 | 30 June 2026 | 14 July 2026 | 300 | 0 | 300 | false | - | 8 | 15 July 2026 | 29 July 2026 | 300 | 0 | 300 | false | - | 9 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | - | 10 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | + | 6 | 31 May 2026 | 14 June 2026 | 300 | 0 | 300 | false | + | 7 | 15 June 2026 | 29 June 2026 | 300 | 0 | 300 | false | + | 8 | 30 June 2026 | 14 July 2026 | 300 | 0 | 300 | false | + | 9 | 15 July 2026 | 29 July 2026 | 300 | 0 | 300 | false | + | 10 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | + | 11 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C74497 @@ -100,11 +101,12 @@ Feature: Working Capital Delinquency Reschedule Action | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | - | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | false | - | 7 | 30 June 2026 | 14 July 2026 | 200 | 0 | 200 | false | - | 8 | 15 July 2026 | 29 July 2026 | 200 | 0 | 200 | false | - | 9 | 30 July 2026 | 13 August 2026 | 200 | 0 | 200 | false | - | 10 | 14 August 2026 | 28 August 2026 | 200 | 0 | 200 | | + | 6 | 31 May 2026 | 14 June 2026 | 200 | 0 | 200 | false | + | 7 | 15 June 2026 | 29 June 2026 | 200 | 0 | 200 | false | + | 8 | 30 June 2026 | 14 July 2026 | 200 | 0 | 200 | false | + | 9 | 15 July 2026 | 29 July 2026 | 200 | 0 | 200 | false | + | 10 | 30 July 2026 | 13 August 2026 | 200 | 0 | 200 | false | + | 11 | 14 August 2026 | 28 August 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C74498 @@ -339,14 +341,15 @@ Feature: Working Capital Delinquency Reschedule Action | 1 | PERCENTAGE | 15 | DAYS | Then WC loan delinquency range schedule has the following periods: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 1 | 01 January 2026 | 30 January 2026 | 100 | 0 | 100 | | + | 1 | 01 January 2026 | 15 January 2026 | 100 | 0 | 100 | | When Admin sets the business date to "01 March 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule has the following periods: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 1 | 01 January 2026 | 30 January 2026 | 100 | 0 | 100 | false | - | 2 | 31 January 2026 | 14 February 2026 | 100 | 0 | 100 | false | - | 3 | 15 February 2026 | 01 March 2026 | 100 | 0 | 100 | | + | 1 | 01 January 2026 | 15 January 2026 | 100 | 0 | 100 | false | + | 2 | 16 January 2026 | 30 January 2026 | 100 | 0 | 100 | false | + | 3 | 31 January 2026 | 14 February 2026 | 100 | 0 | 100 | false | + | 4 | 15 February 2026 | 01 March 2026 | 100 | 0 | 100 | | Then Admin closes the Working Capital loan with a full repayment on "01 March 2026" @TestRailId:C74507 @@ -375,11 +378,12 @@ Feature: Working Capital Delinquency Reschedule Action | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | - | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | false | - | 7 | 30 June 2026 | 13 July 2026 | 200 | 0 | 200 | false | - | 8 | 14 July 2026 | 27 July 2026 | 200 | 0 | 200 | false | - | 9 | 28 July 2026 | 10 August 2026 | 200 | 0 | 200 | false | - | 10 | 11 August 2026 | 24 August 2026 | 200 | 0 | 200 | | + | 6 | 31 May 2026 | 13 June 2026 | 200 | 0 | 200 | false | + | 7 | 14 June 2026 | 27 June 2026 | 200 | 0 | 200 | false | + | 8 | 28 June 2026 | 11 July 2026 | 200 | 0 | 200 | false | + | 9 | 12 July 2026 | 25 July 2026 | 200 | 0 | 200 | false | + | 10 | 26 July 2026 | 08 August 2026 | 200 | 0 | 200 | false | + | 11 | 09 August 2026 | 22 August 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C74508 @@ -655,7 +659,7 @@ Feature: Working Capital Delinquency Reschedule Action Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | | 1 | 2026-01-01 | 2026-03-01 | 400 | 100 | - | 2 | 2026-03-02 | 2026-04-30 | 266.64 | 266.64 | + | 2 | 2026-03-02 | 2026-03-31 | 266.64 | 266.64 | Then WC loan delinquency actions have the following data: | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | 30 | DAYS | @@ -673,8 +677,9 @@ Feature: Working Capital Delinquency Reschedule Action Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | breach | | 1 | 2026-01-01 | 2026-03-01 | 400 | 100 | true | - | 2 | 2026-03-02 | 2026-04-30 | 266.64 | 266.64 | true | - | 3 | 2026-05-01 | 2026-05-30 | 266.64 | 266.64 | null | + | 2 | 2026-03-02 | 2026-03-31 | 266.64 | 266.64 | true | + | 3 | 2026-04-01 | 2026-04-30 | 266.64 | 266.64 | true | + | 4 | 2026-05-01 | 2026-05-30 | 266.64 | 266.64 | null | Then Admin closes the Working Capital loan with a full repayment on "04 May 2026" @TestRailId:C85450 @@ -712,7 +717,7 @@ Feature: Working Capital Delinquency Reschedule Action Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | | 1 | 2026-01-01 | 2026-03-01 | 400 | 100 | - | 2 | 2026-03-02 | 2026-04-30 | 266.64 | 266.64 | + | 2 | 2026-03-02 | 2026-03-31 | 266.64 | 266.64 | Then WC loan delinquency actions have the following data: | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | 30 | DAYS | @@ -737,7 +742,8 @@ Feature: Working Capital Delinquency Reschedule Action Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | | 1 | 2026-01-01 | 2026-03-01 | 400 | 100 | - | 2 | 2026-03-02 | 2026-04-30 | 160.0 | 160.0 | + | 2 | 2026-03-02 | 2026-03-31 | 266.64 | 266.64 | + | 3 | 2026-04-01 | 2026-04-30 | 160.0 | 160.0 | Then WC loan delinquency actions have the following data: | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | 30 | DAYS | @@ -768,9 +774,10 @@ Feature: Working Capital Delinquency Reschedule Action Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | | 1 | 2026-01-01 | 2026-03-01 | 400 | 100 | - | 2 | 2026-03-02 | 2026-04-30 | 160.0 | 160.0 | - | 3 | 2026-05-01 | 2026-05-30 | 160.0 | 160.0 | - | 4 | 2026-05-31 | 2026-06-29 | 80.0 | 80.0 | + | 2 | 2026-03-02 | 2026-03-31 | 266.64 | 266.64 | + | 3 | 2026-04-01 | 2026-04-30 | 160.0 | 160.0 | + | 4 | 2026-05-01 | 2026-05-30 | 160.0 | 160.0 | + | 5 | 2026-05-31 | 2026-06-29 | 80.0 | 80.0 | Then WC loan delinquency actions have the following data: | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | 30 | DAYS | @@ -904,7 +911,7 @@ Feature: Working Capital Delinquency Reschedule Action Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" @TestRailId:C85488 - Scenario: Verify reschedule right after disbursement keeps action history and does not shift period dates + Scenario: Verify reschedule right after disbursement keeps action history and re-dates the current period When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 1 MONTHS and minimumPayment 5 FLAT @@ -914,10 +921,9 @@ Feature: Working Capital Delinquency Reschedule Action | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 800 | 8000 | 1 | 0.0 | When Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount - # Reschedule on the disbursement date (after the initial COB) is allowed. The current period - # keeps its boundaries from the original 1 MONTHS product config (01 Jan - 31 Jan) - a - # reschedule only updates the current period's expected amount; - # the new 30 DAYS frequency applies from the next generated period onward + # Reschedule on the disbursement date (after the initial COB) is allowed. Since this action + # carries a frequency group, the current period is re-dated from its own fromDate using the + # new 30 DAYS frequency (01 Jan - 30 Jan), not left at the original 1 MONTHS boundary When Admin runs inline COB job for Working Capital Loan When Admin creates WC delinquency reschedule action with the following parameters: | minimumPayment | minimumPaymentType | frequency | frequencyType | @@ -929,10 +935,10 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "15 March 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: - | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 1 | 01 January 2026 | 31 January 2026 | 200 | 0 | 200 | false | - | 2 | 01 February 2026 | 02 March 2026 | 200 | 0 | 200 | false | - | 3 | 03 March 2026 | 01 April 2026 | 200 | 0 | 200 | | + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | + | 3 | 02 March 2026 | 31 March 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" @TestRailId:C85489 @@ -960,10 +966,10 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "15 March 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: - | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 1 | 01 January 2026 | 31 January 2026 | 200 | 0 | 200 | false | - | 2 | 01 February 2026 | 02 March 2026 | 200 | 0 | 200 | false | - | 3 | 03 March 2026 | 01 April 2026 | 200 | 0 | 200 | | + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | + | 3 | 02 March 2026 | 31 March 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" @TestRailId:C85490 @@ -994,10 +1000,10 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "15 March 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: - | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 1 | 01 January 2026 | 31 January 2026 | 200 | 0 | 200 | false | - | 2 | 01 February 2026 | 02 March 2026 | 200 | 0 | 200 | false | - | 3 | 03 March 2026 | 01 April 2026 | 200 | 0 | 200 | | + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | + | 3 | 02 March 2026 | 31 March 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" @TestRailId:C85491 @@ -1057,11 +1063,12 @@ Feature: Working Capital Delinquency Reschedule Action | 3 | 02 March 2026 | 31 March 2026 | 300 | 0 | 300 | false | | 4 | 01 April 2026 | 30 April 2026 | 300 | 0 | 300 | false | | 5 | 01 May 2026 | 30 May 2026 | 300 | 0 | 300 | false | - | 6 | 31 May 2026 | 29 June 2026 | 300 | 0 | 300 | false | - | 7 | 30 June 2026 | 14 July 2026 | 300 | 0 | 300 | false | - | 8 | 15 July 2026 | 29 July 2026 | 300 | 0 | 300 | false | - | 9 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | - | 10 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | + | 6 | 31 May 2026 | 14 June 2026 | 300 | 0 | 300 | false | + | 7 | 15 June 2026 | 29 June 2026 | 300 | 0 | 300 | false | + | 8 | 30 June 2026 | 14 July 2026 | 300 | 0 | 300 | false | + | 9 | 15 July 2026 | 29 July 2026 | 300 | 0 | 300 | false | + | 10 | 30 July 2026 | 13 August 2026 | 300 | 0 | 300 | false | + | 11 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85574 @@ -1098,10 +1105,10 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "15 March 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: - | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 1 | 01 January 2026 | 31 January 2026 | 266.64 | 0 | 266.64 | false | - | 2 | 01 February 2026 | 02 March 2026 | 266.64 | 0 | 266.64 | false | - | 3 | 03 March 2026 | 01 April 2026 | 266.64 | 0 | 266.64 | | + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 266.64 | 0 | 266.64 | false | + | 2 | 31 January 2026 | 01 March 2026 | 266.64 | 0 | 266.64 | false | + | 3 | 02 March 2026 | 31 March 2026 | 266.64 | 0 | 266.64 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" @TestRailId:C85493 @@ -1367,7 +1374,7 @@ Feature: Working Capital Delinquency Reschedule Action Then Admin closes the Working Capital loan with a full repayment on "20 March 2026" @TestRailId:C85501 - Scenario: Verify same date reschedules changing frequency apply the latest frequency to new periods + Scenario: Verify same date reschedules changing frequency re-date the current period using the latest frequency When Admin sets the business date to "01 January 2026" When Admin creates a client with random data When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 25 PERCENTAGE @@ -1394,16 +1401,17 @@ Feature: Working Capital Delinquency Reschedule Action | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | - | 3 | 02 March 2026 | 31 March 2026 | 266.64 | 0 | 266.64 | | + | 3 | 02 March 2026 | 16 March 2026 | 266.64 | 0 | 266.64 | | When Admin sets the business date to "17 April 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | - | 3 | 02 March 2026 | 31 March 2026 | 266.64 | 0 | 266.64 | false | - | 4 | 01 April 2026 | 15 April 2026 | 266.64 | 0 | 266.64 | false | - | 5 | 16 April 2026 | 30 April 2026 | 266.64 | 0 | 266.64 | | + | 3 | 02 March 2026 | 16 March 2026 | 266.64 | 0 | 266.64 | false | + | 4 | 17 March 2026 | 31 March 2026 | 266.64 | 0 | 266.64 | false | + | 5 | 01 April 2026 | 15 April 2026 | 266.64 | 0 | 266.64 | false | + | 6 | 16 April 2026 | 30 April 2026 | 266.64 | 0 | 266.64 | | Then Admin closes the Working Capital loan with a full repayment on "17 April 2026" @TestRailId:C85502 @@ -1710,15 +1718,17 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "17 March 2026" When Admin runs inline COB job for Working Capital Loan # Frequency-only reschedule: the payment group must inherit the FLAT 150 from the latest action - # that set it - not the 33.33 percent from the first action and not the product's 25 percent + # that set it - not the 33.33 percent from the first action and not the product's 25 percent. + # Frequency 30 DAYS matches the current period length, so its boundary stays 31 March and the + # assertion isolates payment group inheritance from re-dating When Admin creates WC delinquency reschedule action with the following parameters: | frequency | frequencyType | - | 15 | DAYS | + | 30 | DAYS | Then WC loan delinquency actions have the following data: | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | | | | RESCHEDULE | 16 March 2026 | 150 | FLAT | | | - | RESCHEDULE | 17 March 2026 | | | 15 | DAYS | + | RESCHEDULE | 17 March 2026 | | | 30 | DAYS | When Admin sets the business date to "17 April 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: @@ -1726,8 +1736,7 @@ Feature: Working Capital Delinquency Reschedule Action | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | false | | 3 | 02 March 2026 | 31 March 2026 | 150 | 0 | 150 | false | - | 4 | 01 April 2026 | 15 April 2026 | 150 | 0 | 150 | false | - | 5 | 16 April 2026 | 30 April 2026 | 150 | 0 | 150 | | + | 4 | 01 April 2026 | 30 April 2026 | 150 | 0 | 150 | | Then Admin closes the Working Capital loan with a full repayment on "17 April 2026" @TestRailId:C85584 @@ -1825,8 +1834,9 @@ Feature: Working Capital Delinquency Reschedule Action | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | | 1 | 01 January 2026 | 30 January 2026 | 200 | 0 | 200 | false | | 2 | 31 January 2026 | 16 March 2026 | 200 | 0 | 200 | false | - | 3 | 17 March 2026 | 15 April 2026 | 200 | 0 | 200 | false | - | 4 | 16 April 2026 | 30 April 2026 | 200 | 0 | 200 | | + | 3 | 17 March 2026 | 31 March 2026 | 200 | 0 | 200 | false | + | 4 | 01 April 2026 | 15 April 2026 | 200 | 0 | 200 | false | + | 5 | 16 April 2026 | 30 April 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "20 April 2026" @TestRailId:C85587 @@ -1861,6 +1871,81 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "17 April 2026" When Admin runs inline COB job for Working Capital Loan Then WC loan delinquency range schedule periods have specific data: - | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | - | 4 | 01 April 2026 | 30 April 2026 | 150 | 0 | 150 | | + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 4 | 01 April 2026 | 30 April 2026 | 150 | 0 | 150 | | Then Admin closes the Working Capital loan with a full repayment on "17 April 2026" + + @TestRailId:C93975 + Scenario: Verify delinquency reschedule frequency change is rejected when resulting period end date is in the past + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "20 January 2026" + When Admin runs inline COB job for Working Capital Loan + Then Admin fails to create WC delinquency reschedule action with error containing "Frequency change results a delinquency period endDate before current businessDate is not allowed" and the following parameters: + | frequency | frequencyType | + | 15 | DAYS | + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 300 | 0 | 300 | | + Then Admin fails to create WC delinquency reschedule action with error containing "Frequency change results a delinquency period endDate before current businessDate is not allowed" and the following parameters: + | frequency | frequencyType | + | 19 | DAYS | + # --- Delinquency reschedule --- + When Admin creates WC delinquency reschedule action with the following parameters: + | frequency | frequencyType | + | 20 | DAYS | + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 20 January 2026 | 300 | 0 | 300 | false | + | 2 | 21 January 2026 | 09 February 2026 | 300 | 0 | 300 | | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "20 January 2026" + + @TestRailId:C93975 + Scenario: Verify delinquency reschedule validates the recalculated period end date against the resumed pause length + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates WC Delinquency Bucket with frequency 30 DAYS and minimumPayment 3 PERCENTAGE + When Admin creates a new Working Capital Loan Product with delinquency bucket + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_DELINQUENCY | 01 January 2026 | 01 January 2026 | 10000 | 10000 | 1 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "10 January 2026" + When Admin runs inline COB job for Working Capital Loan + # --- Delinquency pause --- + And Admin initiate a Working Capital loan delinquency pause with startDate "10 January 2026" and endDate "19 January 2026" + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 09 February 2026 | 300 | 0 | 300 | | + When Admin sets the business date to "15 January 2026" + When Admin runs inline COB job for Working Capital Loan + # --- Delinquency resume --- + And Admin initiate a Working Capital loan delinquency resume with startDate "15 January 2026" + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 05 February 2026 | 300 | 0 | 300 | | + When Admin sets the business date to "01 February 2026" + Then Admin fails to create WC delinquency reschedule action with error containing "Frequency change results a delinquency period endDate before current businessDate is not allowed" and the following parameters: + | frequency | frequencyType | + | 25 | DAYS | + # --- Delinquency reschedule --- + When Admin creates WC delinquency reschedule action with the following parameters: + | frequency | frequencyType | + | 26 | DAYS | + Then WC loan delinquency range schedule has the following periods: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 01 February 2026 | 300 | 0 | 300 | false | + | 2 | 02 February 2026 | 27 February 2026 | 300 | 0 | 300 | | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "01 February 2026" diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachPauseUtils.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachPauseUtils.java new file mode 100644 index 00000000000..293729f4029 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachPauseUtils.java @@ -0,0 +1,93 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.domain; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class WorkingCapitalLoanBreachPauseUtils { + + private WorkingCapitalLoanBreachPauseUtils() {} + + /** + * Returns whether a new inclusive pause period shares at least one day with an existing one. Touching periods + * (where one ends on the day the other starts) are treated as overlapping. + */ + public static boolean inclusivePausePeriodsOverlap(final LocalDate parsedPauseStart, final LocalDate parsedPauseEnd, + final LocalDate existingPauseStart, final LocalDate existingPauseEnd) { + if (parsedPauseStart == null || parsedPauseEnd == null || existingPauseStart == null || existingPauseEnd == null) { + return false; + } + return !parsedPauseStart.isAfter(existingPauseEnd) && !parsedPauseEnd.isBefore(existingPauseStart); + } + + /** + * A resumed pause effectively ends on the (inclusive) resume date, so a later pause may start the next day. + */ + public static LocalDate resolveEffectivePauseEnd(final WorkingCapitalLoanBreachAction pause, + final List actions) { + final LocalDate pauseStart = pause.getStartDate(); + final LocalDate pauseEnd = pause.getEndDate(); + if (pauseStart == null || pauseEnd == null) { + return pauseEnd; + } + return actions.stream().filter(Objects::nonNull) + .filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction()) && action.getStartDate() != null + && !action.getStartDate().isBefore(pauseStart) && !action.getStartDate().isAfter(pauseEnd)) + .map(WorkingCapitalLoanBreachAction::getStartDate).min(Comparator.naturalOrder()).orElse(pauseEnd); + } + + /** + * Extends an inclusive period end date by every recorded pause overlapping the period, mirroring the pause replay + * performed by the schedule rebuild. + */ + public static LocalDate extendToDateByRecordedPauses(final LocalDate fromDate, final LocalDate baseToDate, + final List actions) { + if (fromDate == null || baseToDate == null || actions == null) { + return baseToDate; + } + final List pauses = actions.stream().filter(Objects::nonNull) + .filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction()) && action.getStartDate() != null) + .sorted(Comparator.comparing(WorkingCapitalLoanBreachAction::getStartDate)).toList(); + LocalDate toDate = baseToDate; + for (final WorkingCapitalLoanBreachAction pause : pauses) { + final LocalDate pauseStart = pause.getStartDate(); + final LocalDate pauseEnd = resolveEffectivePauseEnd(pause, actions); + if (pauseEnd == null || pauseEnd.isBefore(fromDate) || pauseStart.isAfter(toDate)) { + continue; + } + toDate = toDate.plusDays(calculatePauseExtensionDays(pauseStart, pauseEnd)); + } + return toDate; + } + + /** + * Inclusive pause length: both start and end dates count as paused days. + */ + public static long calculatePauseExtensionDays(final LocalDate pauseStart, final LocalDate pauseEnd) { + if (pauseStart == null || pauseEnd == null || pauseStart.isAfter(pauseEnd)) { + return 0L; + } + return ChronoUnit.DAYS.between(pauseStart, pauseEnd) + 1; + } + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java index 7389869113a..73e91d1b043 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java @@ -41,4 +41,25 @@ public static Optional resolveEvaluationPeriod .max(Comparator.comparingInt(period -> period.getPeriodNumber() != null ? period.getPeriodNumber() : Integer.MIN_VALUE)); } + public static LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType) { + return switch (frequencyType) { + case DAYS -> fromDate.plusDays(frequency - 1); + case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1); + case MONTHS -> fromDate.plusMonths(frequency).minusDays(1); + case YEARS -> fromDate.plusYears(frequency).minusDays(1); + }; + } + + /** + * End date a period gets when a reschedule re-dates it: the new frequency applied from the period start, extended + * by the recorded pauses that overlap it. Shared by the reschedule validator and the schedule service so the check + * cannot drift from the mutation it guards. + */ + public static LocalDate calculateRescheduledToDate(final LocalDate fromDate, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType, final List actions) { + return WorkingCapitalLoanBreachPauseUtils.extendToDateByRecordedPauses(fromDate, + calculateToDate(fromDate, frequency, frequencyType), actions); + } + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyPauseUtils.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyPauseUtils.java index 3c8ec2765e8..20f0d79eb39 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyPauseUtils.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyPauseUtils.java @@ -58,6 +58,27 @@ public static LocalDate resolveEffectivePauseEnd(final WorkingCapitalLoanDelinqu .map(WorkingCapitalLoanDelinquencyAction::getStartDate).min(Comparator.naturalOrder()).orElse(pauseEnd); } + /** + * Extends an inclusive period end date by every recorded pause overlapping the period, mirroring the pause replay + * performed by the schedule rebuild. + */ + public static LocalDate extendToDateByRecordedPauses(final LocalDate fromDate, final LocalDate baseToDate, + final List actions) { + if (fromDate == null || baseToDate == null || actions == null) { + return baseToDate; + } + final long pauseDays = actions.stream().filter(Objects::nonNull) + .filter(action -> DelinquencyAction.PAUSE.equals(action.getAction()) && action.getStartDate() != null).mapToLong(pause -> { + final LocalDate pauseStart = pause.getStartDate(); + final LocalDate pauseEnd = resolveEffectivePauseEnd(pause, actions); + if (pauseEnd == null || pauseEnd.isBefore(fromDate) || pauseStart.isAfter(baseToDate)) { + return 0L; + } + return calculatePauseExtensionDays(pauseStart, pauseEnd); + }).sum(); + return baseToDate.plusDays(pauseDays); + } + /** * Inclusive pause length: both start and end dates count as paused days. */ diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.java new file mode 100644 index 00000000000..079e6791d3c --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.java @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.domain; + +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; + +public final class WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils { + + private WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils() {} + + public static LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency, + final DelinquencyFrequencyType frequencyType) { + return switch (frequencyType) { + case DAYS -> fromDate.plusDays(frequency - 1); + case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1); + case MONTHS -> fromDate.plusMonths(frequency).minusDays(1); + case YEARS -> fromDate.plusYears(frequency).minusDays(1); + }; + } + + /** + * End date a period gets when a reschedule re-dates it: the new frequency applied from the period start, extended + * by the recorded pauses that overlap it. Shared by the reschedule validator and the schedule service so the check + * cannot drift from the mutation it guards. + */ + public static LocalDate calculateRescheduledToDate(final LocalDate fromDate, final Integer frequency, + final DelinquencyFrequencyType frequencyType, final List actions) { + return WorkingCapitalLoanDelinquencyPauseUtils.extendToDateByRecordedPauses(fromDate, + calculateToDate(fromDate, frequency, frequencyType), actions); + } + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java index 4361f8ff56d..af38cb09144 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java @@ -23,6 +23,8 @@ import java.util.Optional; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface WorkingCapitalLoanBreachScheduleRepository extends JpaRepository { @@ -41,4 +43,22 @@ Optional findByLoanIdAndFromDateLessThanEqualA LocalDate transactionDate, LocalDate transactionDate1); Optional findTopByLoanIdAndBreachTrueOrderByFromDateAsc(Long loanId); + + @Query(""" + SELECT s FROM WorkingCapitalLoanBreachSchedule s + WHERE s.loan.id = :loanId + AND s.breach IS NULL + AND s.fromDate <= :businessDate + AND s.toDate >= :businessDate + ORDER BY s.periodNumber DESC""") + List findCurrentOpenPeriodsOrderByPeriodNumberDesc(@Param("loanId") Long loanId, + @Param("businessDate") LocalDate businessDate); + + @Query(""" + SELECT s FROM WorkingCapitalLoanBreachSchedule s + WHERE s.loan.id = :loanId + AND s.fromDate > :businessDate + ORDER BY s.periodNumber ASC""") + List findFuturePeriodsOrderByPeriodNumberAsc(@Param("loanId") Long loanId, + @Param("businessDate") LocalDate businessDate); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java index bce76536cfa..b96ec06f667 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyRangeScheduleRepository.java @@ -61,6 +61,26 @@ List findOpenPeriodsExceedingRemaini Optional findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(Long loanId, LocalDate date, LocalDate date2); + @Query(""" + SELECT s FROM WorkingCapitalLoanDelinquencyRangeSchedule s + WHERE s.loan.id = :loanId + AND s.minPaymentCriteriaMet IS NULL + AND s.fromDate <= :businessDate + AND s.toDate >= :businessDate + ORDER BY s.periodNumber DESC""") + List findCurrentOpenPeriodsOrderByPeriodNumberDesc(@Param("loanId") Long loanId, + @Param("businessDate") LocalDate businessDate); + + @Query(""" + SELECT s FROM WorkingCapitalLoanDelinquencyRangeSchedule s + WHERE s.loan.id = :loanId + AND s.fromDate > :businessDate + ORDER BY s.periodNumber ASC""") + List findFuturePeriodsOrderByPeriodNumberAsc(@Param("loanId") Long loanId, + @Param("businessDate") LocalDate businessDate); + + boolean existsByLoanId(Long loanId); + List findByLoanIdAndToDateLessThanEqualAndMinPaymentCriteriaMetIsNull(Long loanId, LocalDate businessDate); 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 d5ad1ef8bff..0ae394d0511 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 @@ -62,7 +62,7 @@ public CommandProcessingResult createBreachAction(final Long workingCapitalLoanI || WorkingCapitalLoanBreachActionType.RESUME.equals(breachAction.getAction())) { breachScheduleService.recalculatePeriodsForPauses(workingCapitalLoan); } else if (WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(breachAction.getAction())) { - breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan); + breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan, breachAction); } else if (WorkingCapitalLoanBreachActionType.RESET.equals(breachAction.getAction())) { breachResetService.resetBreach(workingCapitalLoan, saved); } else if (WorkingCapitalLoanBreachActionType.UNDO_RESET.equals(breachAction.getAction())) { 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 48e08bbe93a..624c83bd9b9 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 @@ -21,9 +21,12 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import java.util.Optional; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; public interface WorkingCapitalLoanBreachScheduleService { @@ -45,9 +48,19 @@ public interface WorkingCapitalLoanBreachScheduleService { /** * Recalculates the schedule from the effective reschedule parameters resolved from the persisted RESCHEDULE - * actions; a newly created reschedule action must therefore be saved before this is called. + * actions; a newly created reschedule action must therefore be saved before this is called. When {@code action} + * carries a frequency group, the current open period is also re-dated: its toDate is recalculated from its fromDate + * and the new frequency, extended by the recorded pauses that overlap the period. */ - void rescheduleMinimumPayment(WorkingCapitalLoan loan); + void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction action); + + /** + * Returns the toDate {@link #rescheduleMinimumPayment} would set on the current open period for the given + * frequency, or empty when there is no current open period. Exposed so validation and the re-date itself share one + * implementation. + */ + Optional calculateRescheduledCurrentPeriodToDate(Long loanId, Integer frequency, + WorkingCapitalLoanPeriodFrequencyType frequencyType); 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 7153daac792..da9d03e996d 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 @@ -38,7 +38,9 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachPauseUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachScheduleEvaluationUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanBreachScheduleMapper; @@ -80,7 +82,8 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) { final LocalDate fromDate = anchorDateOptional.get().plusDays(getBreachGraceDays(loan)); final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); - final LocalDate toDate = calculateToDate(fromDate, params.frequency(), params.frequencyType()); + final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, params.frequency(), + params.frequencyType()); final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, params); final WorkingCapitalLoanBreachSchedule period = createPeriod(loan, 1, fromDate, toDate, minPaymentAmount); @@ -116,7 +119,8 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca WorkingCapitalLoanBreachSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); - final LocalDate newToDate = calculateToDate(newFromDate, effectiveFrequency, effectiveFreqType); + final LocalDate newToDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(newFromDate, effectiveFrequency, + effectiveFreqType); final WorkingCapitalLoanBreachSchedule nextPeriod = createPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate, newToDate, minPaymentAmount); @@ -213,7 +217,7 @@ public List retrieveBreachSchedule(final L } @Override - public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachAction action) { final LocalDate businessDate = DateUtils.getBusinessLocalDate(); final Optional breachOpt = getBreachConfig(loan); if (breachOpt.isEmpty()) { @@ -224,34 +228,25 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { final BigDecimal newMinPaymentAmount = calculateMinPaymentAmount(loan, params); final Integer newFrequency = params.frequency(); final WorkingCapitalLoanPeriodFrequencyType newFreqType = params.frequencyType(); - - final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); - - WorkingCapitalLoanBreachSchedule currentPeriod = null; - final List futurePeriods = new ArrayList<>(); - - for (final WorkingCapitalLoanBreachSchedule period : periods) { - if (period.getBreach() != 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.setBaseMinPaymentAmount(newMinPaymentAmount); - period.setMinPaymentAmount(newMinPaymentAmount); - period.setOutstandingAmount(newMinPaymentAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); - period.setNearBreach(null); - } else if (isFuture) { - futurePeriods.add(period); + final boolean frequencyProvided = action.getFrequency() != null; + + findCurrentOpenPeriod(loan.getId(), businessDate).ifPresent(currentPeriod -> { + currentPeriod.setBaseMinPaymentAmount(newMinPaymentAmount); + if (frequencyProvided) { + final LocalDate newToDate = resolveRescheduledToDate(loan.getId(), currentPeriod, action.getFrequency(), + action.getFrequencyType()); + currentPeriod.setToDate(newToDate); + currentPeriod.setNumberOfDays((int) ChronoUnit.DAYS.between(currentPeriod.getFromDate(), newToDate) + 1); } - } - - if (currentPeriod != null) { + currentPeriod.setMinPaymentAmount(newMinPaymentAmount); + currentPeriod.setOutstandingAmount(newMinPaymentAmount.subtract(currentPeriod.getPaidAmount()).max(BigDecimal.ZERO)); + currentPeriod.setNearBreach(null); repository.saveAndFlush(currentPeriod); + + final List futurePeriods = repository.findFuturePeriodsOrderByPeriodNumberAsc(loan.getId(), + businessDate); updateFuturePeriods(currentPeriod, futurePeriods, newMinPaymentAmount, newFrequency, newFreqType); - } + }); evaluateExpiredBreaches(loan, businessDate); recalculatePastDueAmount(loan); @@ -260,6 +255,23 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { params.minimumPayment(), params.minimumPaymentType(), newFrequency, newFreqType); } + @Override + public Optional calculateRescheduledCurrentPeriodToDate(final Long loanId, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType) { + return findCurrentOpenPeriod(loanId, DateUtils.getBusinessLocalDate()) + .map(currentPeriod -> resolveRescheduledToDate(loanId, currentPeriod, frequency, frequencyType)); + } + + private Optional findCurrentOpenPeriod(final Long loanId, final LocalDate businessDate) { + return repository.findCurrentOpenPeriodsOrderByPeriodNumberDesc(loanId, businessDate).stream().findFirst(); + } + + private LocalDate resolveRescheduledToDate(final Long loanId, final WorkingCapitalLoanBreachSchedule currentPeriod, + final Integer frequency, final WorkingCapitalLoanPeriodFrequencyType frequencyType) { + return WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateRescheduledToDate(currentPeriod.getFromDate(), frequency, + frequencyType, breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId)); + } + @Override public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) { final Optional breachOpt = getBreachConfig(loan); @@ -278,7 +290,8 @@ public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) { LocalDate fromDate = periods.getFirst().getFromDate(); for (final WorkingCapitalLoanBreachSchedule period : periods) { period.setFromDate(fromDate); - period.setToDate(calculateToDate(fromDate, effectiveFrequency, effectiveFreqType)); + period.setToDate( + WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, effectiveFrequency, effectiveFreqType)); applyRecordedPauses(period, effectivePauses); recomputeBreach(period, businessDate); fromDate = period.getToDate().plusDays(1); @@ -348,20 +361,11 @@ private void recomputeBreach(final WorkingCapitalLoanBreachSchedule period, fina private List findEffectivePauses(final Long loanId) { final List actions = breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId); - final List resumes = actions.stream() - .filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction())).toList(); return actions.stream().filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) .sorted(Comparator.comparing(WorkingCapitalLoanBreachAction::getStartDate)) - .map(pause -> new EffectivePause(pause.getStartDate(), effectivePauseEnd(pause, resumes))).toList(); - } - - private LocalDate effectivePauseEnd(final WorkingCapitalLoanBreachAction pause, final List resumes) { - // Resume shortens the pause to end on the resume date. Pause start and end dates are inclusive, so the resume - // date itself becomes the effective (inclusive) end and is still treated as a paused day. - return resumes.stream() - .filter(resume -> !pause.getStartDate().isAfter(resume.getStartDate()) - && !resume.getStartDate().isAfter(pause.getEndDate())) - .map(WorkingCapitalLoanBreachAction::getStartDate).min(Comparator.naturalOrder()).orElse(pause.getEndDate()); + .map(pause -> new EffectivePause(pause.getStartDate(), + WorkingCapitalLoanBreachPauseUtils.resolveEffectivePauseEnd(pause, actions))) + .toList(); } private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, final List pauses) { @@ -370,7 +374,7 @@ private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, final LocalDate pauseEnd = pause.endDate(); // Apply only if the pause overlaps this period's date range if (!pauseEnd.isBefore(period.getFromDate()) && !pauseStart.isAfter(period.getToDate())) { - final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd) + 1; + final long pauseDays = WorkingCapitalLoanBreachPauseUtils.calculatePauseExtensionDays(pauseStart, pauseEnd); period.setToDate(period.getToDate().plusDays(pauseDays)); if (period.getFromDate().isAfter(pauseStart)) { period.setFromDate(period.getFromDate().plusDays(pauseDays)); @@ -425,16 +429,6 @@ private Optional resolveBreachAnchorDate(final WorkingCapitalLoan loa return loanRepository.findFirstActualDisbursementDate(loan.getId()); } - private LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency, - final WorkingCapitalLoanPeriodFrequencyType frequencyType) { - return switch (frequencyType) { - case DAYS -> fromDate.plusDays(frequency - 1); - case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1); - case MONTHS -> fromDate.plusMonths(frequency).minusDays(1); - case YEARS -> fromDate.plusYears(frequency).minusDays(1); - }; - } - private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, final EffectiveBreachRescheduleParams params) { final BigDecimal breachAmount = params.minimumPayment(); if (breachAmount == null) { @@ -494,7 +488,7 @@ private void updateFuturePeriods(final WorkingCapitalLoanBreachSchedule currentP LocalDate fromDate = currentPeriod.getToDate().plusDays(1); for (final WorkingCapitalLoanBreachSchedule period : existingFuturePeriods) { - final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType); + final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, frequency, frequencyType); periodNumber++; period.setPeriodNumber(periodNumber); 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 a7f01093b41..91fadbec05b 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 @@ -81,7 +81,7 @@ public CommandProcessingResult createDelinquencyAction(final Long workingCapital rangeScheduleService.reprocessDelinquencySchedule(workingCapitalLoan); } } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { - rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan); + rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); rangeScheduleService.reprocessDelinquencySchedule(workingCapitalLoan); } else if (DelinquencyAction.RESUME.equals(action.getAction())) { final WorkingCapitalLoanDelinquencyAction activePause = validator.findActivePauseForResume(existing, 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 aedcd7de5ad..f17edee10e7 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 @@ -21,6 +21,8 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import java.util.Optional; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; @@ -54,10 +56,19 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleService { /** * Re-derives the base expectation of the current period and the boundaries of future periods from the effective * reschedule parameters resolved from the persisted RESCHEDULE actions; a newly created reschedule action must - * therefore be saved before this is called. Amounts, the remaining-balance cap and expired-period evaluation are - * left to {@link #reprocessDelinquencySchedule(WorkingCapitalLoan)}, which the caller must invoke afterwards. + * therefore be saved before this is called. When {@code action} carries a frequency group, the current open period + * is also re-dated: its toDate is recalculated from its fromDate and the new frequency, extended by the recorded + * pauses that overlap the period. Amounts, the remaining-balance cap and expired-period evaluation are left to + * {@link #reprocessDelinquencySchedule(WorkingCapitalLoan)}, which the caller must invoke afterwards. */ - void rescheduleMinimumPayment(WorkingCapitalLoan loan); + void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction action); + + /** + * Returns the toDate {@link #rescheduleMinimumPayment} would set on the current open period for the given + * frequency, or empty when there is no current open period. Exposed so validation and the re-date itself share one + * implementation. + */ + Optional calculateRescheduledCurrentPeriodToDate(Long loanId, Integer frequency, DelinquencyFrequencyType frequencyType); 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 daabba4f3c2..4a0377e2c2d 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 @@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyPauseUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleMapper; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; @@ -80,7 +81,8 @@ public void generateInitialPeriod(WorkingCapitalLoan loan) { } final EffectiveDelinquencyRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), rule); - final LocalDate toDate = calculateToDate(fromDate, params.frequency(), params.frequencyType()); + final LocalDate toDate = WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.calculateToDate(fromDate, params.frequency(), + params.frequencyType()); final WorkingCapitalLoanDelinquencyRangeSchedule period = buildPeriod(loan, 1, fromDate, toDate, calculateExpectedAmount(loan, params)); @@ -113,7 +115,8 @@ public List generateNextPeriodIfNeed WorkingCapitalLoanDelinquencyRangeSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); - final LocalDate newToDate = calculateToDate(newFromDate, params.frequency(), params.frequencyType()); + final LocalDate newToDate = WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.calculateToDate(newFromDate, + params.frequency(), params.frequencyType()); final WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = buildPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate, newToDate, expectedAmount); applyRecordedPauses(nextPeriod, loan); @@ -310,7 +313,7 @@ public List retrieveRangeSchedul } @Override - public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction action) { final LocalDate businessDate = DateUtils.getBusinessLocalDate(); final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); if (rule == null) { @@ -319,37 +322,44 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { } final EffectiveDelinquencyRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), rule); final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, params); + final boolean frequencyProvided = action.getFrequency() != null; - 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.setBaseExpectedAmount(newExpectedAmount); - } else if (isFuture) { - futurePeriods.add(period); + findCurrentOpenPeriod(loan.getId(), businessDate).ifPresent(currentPeriod -> { + if (frequencyProvided) { + final LocalDate newToDate = resolveRescheduledToDate(loan.getId(), currentPeriod, action.getFrequency(), + action.getFrequencyType()); + currentPeriod.setToDate(newToDate); } - } - - if (currentPeriod != null) { + currentPeriod.setBaseExpectedAmount(newExpectedAmount); loanDelinquencyRangeScheduleRepository.saveAndFlush(currentPeriod); + + final List futurePeriods = loanDelinquencyRangeScheduleRepository + .findFuturePeriodsOrderByPeriodNumberAsc(loan.getId(), businessDate); updateFuturePeriods(currentPeriod, futurePeriods, newExpectedAmount, params.frequency(), params.frequencyType()); - } + }); log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={} {}, frequency={} {}", loan.getId(), params.minimumPayment(), params.minimumPaymentType(), params.frequency(), params.frequencyType()); } + @Override + public Optional calculateRescheduledCurrentPeriodToDate(final Long loanId, final Integer frequency, + final DelinquencyFrequencyType frequencyType) { + return findCurrentOpenPeriod(loanId, DateUtils.getBusinessLocalDate()) + .map(currentPeriod -> resolveRescheduledToDate(loanId, currentPeriod, frequency, frequencyType)); + } + + private Optional findCurrentOpenPeriod(final Long loanId, final LocalDate businessDate) { + return loanDelinquencyRangeScheduleRepository.findCurrentOpenPeriodsOrderByPeriodNumberDesc(loanId, businessDate).stream() + .findFirst(); + } + + private LocalDate resolveRescheduledToDate(final Long loanId, final WorkingCapitalLoanDelinquencyRangeSchedule currentPeriod, + final Integer frequency, final DelinquencyFrequencyType frequencyType) { + return WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.calculateRescheduledToDate(currentPeriod.getFromDate(), frequency, + frequencyType, findAllActions(loanId)); + } + @Override public void resumeActivePause(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction activePause, final WorkingCapitalLoanDelinquencyAction resumeAction) { @@ -471,15 +481,6 @@ private DelinquencyMinimumPaymentPeriodAndRule getMinimumPaymentRule(WorkingCapi return minimumPaymentPeriodAndRuleRepository.findByBucketId(bucket.getId()).orElse(null); } - private LocalDate calculateToDate(LocalDate fromDate, Integer frequency, DelinquencyFrequencyType frequencyType) { - return switch (frequencyType) { - case DAYS -> fromDate.plusDays(frequency - 1); - case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1); - case MONTHS -> fromDate.plusMonths(frequency).minusDays(1); - case YEARS -> fromDate.plusYears(frequency).minusDays(1); - }; - } - private BigDecimal calculateExpectedAmount(final WorkingCapitalLoan loan, final EffectiveDelinquencyRescheduleParams params) { final BigDecimal principal = loan.getApprovedPrincipal(); if (principal == null || params.minimumPayment() == null) { @@ -630,7 +631,8 @@ private void updateFuturePeriods(final WorkingCapitalLoanDelinquencyRangeSchedul LocalDate fromDate = currentPeriod.getToDate().plusDays(1); for (final WorkingCapitalLoanDelinquencyRangeSchedule period : existingFuturePeriods) { - final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType); + final LocalDate toDate = WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils.calculateToDate(fromDate, frequency, + frequencyType); periodNumber++; period.setPeriodNumber(periodNumber); 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 a4de267b403..b234dc7644f 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 @@ -39,6 +39,7 @@ 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.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; @@ -47,6 +48,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachPauseUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachScheduleEvaluationUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; @@ -54,6 +56,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanActiveBreachResetResolver; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanBreachScheduleService; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; @@ -63,6 +66,7 @@ @Component public class WorkingCapitalLoanBreachActionParseAndValidator extends ParseAndValidator { + private static final String VALIDATION_RESOURCE = "workingCapitalLoanBreachAction"; private static final String PAUSE_ACTION = "pause"; private static final String RESCHEDULE_ACTION = "reschedule"; private static final String RESUME_ACTION = "resume"; @@ -75,10 +79,11 @@ public class WorkingCapitalLoanBreachActionParseAndValidator extends ParseAndVal private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; private final WorkingCapitalLoanActiveBreachResetResolver activeBreachResetResolver; private final WorkingCapitalLoanBreachActionRepository breachActionRepository; + private final WorkingCapitalLoanBreachScheduleService breachScheduleService; public WorkingCapitalLoanBreachAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, final List existing) { - final DataValidatorBuilder dataValidator = new DataValidatorBuilder(new ArrayList<>()).resource("workingCapitalLoanBreachAction"); + final DataValidatorBuilder dataValidator = new DataValidatorBuilder(new ArrayList<>()).resource(VALIDATION_RESOURCE); final JsonElement json = command.parsedJson(); final String actionString = jsonHelper.extractStringNamed(ACTION, json); @@ -162,16 +167,19 @@ private WorkingCapitalLoanBreachAction parseAndValidateResume(final JsonElement final LocalDate endDate = extractDate(json, END_DATE); if (endDate != null) { - dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.not.be.provided.for.resume"); + failParameterValidation(dataValidator, END_DATE, "must.not.be.provided.for.resume", + "End date must not be provided for a resume action"); } final LocalDate businessDate = DateUtils.getBusinessLocalDate(); if (resumeDate != null && !resumeDate.isEqual(businessDate)) { - dataValidator.reset().parameter(START_DATE).value(resumeDate).failWithCode("must.be.current.business.date"); + failParameterValidation(dataValidator, START_DATE, "must.be.current.business.date", + "Start date of a resume action must be the current business date"); } if (resumeDate != null && findActivePause(resumeDate, existing).isEmpty()) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("resume.not.during.active.pause"); + failGeneralValidation(dataValidator, "resume.not.during.active.pause", + "Resume breach action can only be created during an active pause"); } throwExceptionIfValidationWarningsExist(dataValidator); @@ -190,10 +198,11 @@ private WorkingCapitalLoanBreachAction parseAndValidateReset(final JsonElement j final List periods = breachScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); if (periods.isEmpty()) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.schedule"); + failGeneralValidation(dataValidator, "no.breach.schedule", "Breach action requires an existing breach schedule."); } if (WorkingCapitalLoanBreachScheduleEvaluationUtils.resolveEvaluationPeriod(periods, resetDate).isEmpty()) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.evaluation.period"); + failGeneralValidation(dataValidator, "no.breach.evaluation.period", + "There is no breach evaluation period covering the current business date."); } validateNoResetInCurrentPeriod(workingCapitalLoan, resetDate, periods, dataValidator); @@ -212,7 +221,8 @@ private WorkingCapitalLoanBreachAction parseAndValidateUndoReset(final WorkingCa validateScheduleExists(workingCapitalLoan, dataValidator); final boolean hasActiveReset = activeBreachResetResolver.hasActiveReset(workingCapitalLoan.getId()); if (!hasActiveReset) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.reset.to.undo"); + failGeneralValidation(dataValidator, "no.breach.reset.to.undo", + "There is no active breach reset to undo for this Working Capital loan."); } throwExceptionIfValidationWarningsExist(dataValidator); @@ -231,12 +241,14 @@ private WorkingCapitalLoanBreachAction parseAndValidateDisableOrEnable(final Jso final LocalDate endDate = extractDate(json, END_DATE); if (endDate != null) { - dataValidator.reset().parameter(END_DATE).value(endDate).failWithCode("must.not.be.provided.for.disable.or.enable"); + failParameterValidation(dataValidator, END_DATE, "must.not.be.provided.for.disable.or.enable", + "End date must not be provided for a disable or enable action"); } final LocalDate businessDate = DateUtils.getBusinessLocalDate(); if (startDate != null && !startDate.isEqual(businessDate)) { - dataValidator.reset().parameter(START_DATE).value(startDate).failWithCode("must.be.current.business.date"); + failParameterValidation(dataValidator, START_DATE, "must.be.current.business.date", + "Start date of a disable or enable action must be the current business date"); } final boolean isEnable = ENABLE_ACTION.equalsIgnoreCase(actionString); @@ -254,9 +266,11 @@ private WorkingCapitalLoanBreachAction parseAndValidateDisableOrEnable(final Jso private void validateDisableState(final DataValidatorBuilder dataValidator, final Long loanId, final boolean isEnable) { final boolean alreadyDisabled = breachActionRepository.isBreachDisabledAsOf(loanId, DateUtils.getBusinessLocalDate()); if (isEnable && !alreadyDisabled) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.active.breach.disable.to.enable"); + failGeneralValidation(dataValidator, "no.active.breach.disable.to.enable", + "There is no active breach disable to enable for this Working Capital loan."); } else if (!isEnable && alreadyDisabled) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("breach.already.disabled"); + failGeneralValidation(dataValidator, "breach.already.disabled", + "Breach evaluation is already disabled for this Working Capital loan. It must be enabled before disabling again."); } } @@ -304,7 +318,8 @@ private WorkingCapitalBreachAmountCalculationType extractMinimumPaymentType(fina try { return WorkingCapitalBreachAmountCalculationType.valueOf(value.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { - dataValidator.reset().parameter(MINIMUM_PAYMENT_TYPE).value(value).failWithCode("invalid.minimumPaymentType"); + failParameterValidation(dataValidator, MINIMUM_PAYMENT_TYPE, "invalid.minimumPaymentType", + "Invalid minimum payment type: " + value + ". Supported: PERCENTAGE, FLAT"); return null; } } @@ -317,33 +332,38 @@ private WorkingCapitalLoanPeriodFrequencyType extractFrequencyType(final JsonEle try { return WorkingCapitalLoanPeriodFrequencyType.valueOf(value.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { - dataValidator.reset().parameter(FREQUENCY_TYPE).value(value).failWithCode("invalid.frequencyType"); + failParameterValidation(dataValidator, FREQUENCY_TYPE, "invalid.frequencyType", + "Invalid frequency type: " + value + ". Supported: DAYS, WEEKS, MONTHS, YEARS"); return null; } } private void validateLoanIsActive(final DataValidatorBuilder dataValidator, final WorkingCapitalLoan workingCapitalLoan) { if (!workingCapitalLoan.getLoanStatus().isActive()) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.is.not.active"); + failGeneralValidation(dataValidator, "loan.is.not.active", + "Breach actions can be created only for active Working Capital loans."); } } 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"); + failGeneralValidation(dataValidator, "no.breach.configuration", + "Breach actions require a breach configuration on the Working Capital loan."); } } private void validateBreachNotDisabled(final DataValidatorBuilder dataValidator, final Long loanId) { if (breachActionRepository.isBreachDisabledAsOf(loanId, DateUtils.getBusinessLocalDate())) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("breach.is.disabled"); + failGeneralValidation(dataValidator, "breach.is.disabled", + "Breach pause, resume, reschedule and reset actions are not allowed while breach evaluation is disabled."); } } 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"); + failParameterValidation(dataValidator, END_DATE, "must.be.on.or.after.startDate", + "End date of pause period must be on or after the start date"); } } @@ -386,25 +406,14 @@ private void validateNoOverlap(final DataValidatorBuilder dataValidator, final L return; } final boolean overlaps = existing.stream().filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction())) - .anyMatch(pause -> isOverlapping(startDate, endDate, pause.getStartDate(), effectivePauseEnd(pause, existing))); + .anyMatch(pause -> WorkingCapitalLoanBreachPauseUtils.inclusivePausePeriodsOverlap(startDate, endDate, pause.getStartDate(), + WorkingCapitalLoanBreachPauseUtils.resolveEffectivePauseEnd(pause, existing))); if (overlaps) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("overlapping.pause.periods"); + failGeneralValidation(dataValidator, "overlapping.pause.periods", + "Breach pause period cannot overlap with another pause period"); } } - private LocalDate effectivePauseEnd(final WorkingCapitalLoanBreachAction pause, final List existing) { - // A resumed pause effectively ends on the (inclusive) resume date, so a later pause may start the next day - return existing.stream().filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction())) - .map(WorkingCapitalLoanBreachAction::getStartDate) - .filter(resumeDate -> !pause.getStartDate().isAfter(resumeDate) && !resumeDate.isAfter(pause.getEndDate())) - .min(LocalDate::compareTo).orElse(pause.getEndDate()); - } - - private boolean isOverlapping(final LocalDate startDate, final LocalDate endDate, final LocalDate otherStart, - final LocalDate otherEnd) { - return !startDate.isAfter(otherEnd) && !otherStart.isAfter(endDate); - } - private void validateReschedule(final WorkingCapitalLoanBreachAction action, final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { validateLoanIsDisbursed(workingCapitalLoan, dataValidator); @@ -414,36 +423,66 @@ private void validateReschedule(final WorkingCapitalLoanBreachAction action, fin final boolean hasFrequencyGroup = action.getFrequency() != null || action.getFrequencyType() != null; if (!hasPaymentGroup && !hasFrequencyGroup) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("reschedule.no.change.parameters"); + failGeneralValidation(dataValidator, "reschedule.no.change.parameters", + "At least one of payment (minimumPayment + minimumPaymentType) or frequency (frequency + frequencyType) group must be provided"); } if (hasPaymentGroup) { validateMinimumPaymentGroupProvided(action, dataValidator); } if (hasFrequencyGroup) { validateFrequencyGroupProvided(action, dataValidator); + if (action.getFrequency() != null && action.getFrequency() > 0 && action.getFrequencyType() != null) { + validateFrequencyDoesNotEndBeforeBusinessDate(action, workingCapitalLoan, dataValidator); + } } } + /** + * Rejects a frequency change whose resulting period end date falls before the current business date. The candidate + * end date comes from the breach schedule service, so it is the very date the re-date would persist. + */ + private void validateFrequencyDoesNotEndBeforeBusinessDate(final WorkingCapitalLoanBreachAction action, + final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final Optional candidateToDate = breachScheduleService + .calculateRescheduledCurrentPeriodToDate(workingCapitalLoan.getId(), action.getFrequency(), action.getFrequencyType()); + if (candidateToDate.filter(toDate -> toDate.isBefore(businessDate)).isPresent()) { + failGeneralValidation(dataValidator, "reschedule.frequency.results.endDate.before.businessDate", + "Frequency change results a breach period endDate before current businessDate is not allowed"); + } + } + + 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 validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { final boolean isDisbursed = workingCapitalLoan.getDisbursementDetails().stream() .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); if (!isDisbursed) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.not.disbursed"); + failGeneralValidation(dataValidator, "loan.not.disbursed", "Breach action requires the loan to be disbursed."); } } private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { - final List periods = breachScheduleRepository - .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); - if (periods.isEmpty()) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.breach.schedule"); + if (!breachScheduleRepository.existsByLoanId(workingCapitalLoan.getId())) { + failGeneralValidation(dataValidator, "no.breach.schedule", "Breach action requires an existing breach schedule."); } } private void validateMinimumPaymentGroupProvided(final WorkingCapitalLoanBreachAction action, final DataValidatorBuilder dataValidator) { if (action.getMinimumPayment() == null || action.getMinimumPayment().compareTo(BigDecimal.ZERO) <= 0) { - dataValidator.reset().parameter(MINIMUM_PAYMENT).value(action.getMinimumPayment()).failWithCode("must.be.greater.than.zero"); + failParameterValidation(dataValidator, MINIMUM_PAYMENT, "must.be.greater.than.zero", + "The parameter `minimumPayment` must be greater than zero"); } if (action.getMinimumPaymentType() == null) { dataValidator.reset().parameter(MINIMUM_PAYMENT_TYPE).value(action.getMinimumPaymentType()).notNull(); @@ -475,7 +514,8 @@ private void validateNoResetInCurrentPeriod(final WorkingCapitalLoan workingCapi final boolean resetExistsInPeriod = activeBreachResetResolver.existsActiveResetInPeriod(workingCapitalLoan.getId(), fromDate, toDate); if (resetExistsInPeriod) { - dataValidator.reset().failWithCodeNoParameterAddedToErrorCode("reset.already.exists.in.current.period"); + failGeneralValidation(dataValidator, "reset.already.exists.in.current.period", + "A breach reset already exists in the current breach period."); } } } 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 c6d4bda4b31..6f58860624f 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 @@ -31,6 +31,7 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -47,10 +48,10 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyPauseUtils; -import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanDelinquencyRangeScheduleService; import org.springframework.stereotype.Component; @RequiredArgsConstructor @@ -67,6 +68,7 @@ public class WorkingCapitalLoanDelinquencyActionParseAndValidator extends ParseA private final FromJsonHelper jsonHelper; private final WorkingCapitalLoanDelinquencyRangeScheduleRepository rangeScheduleRepository; private final WorkingCapitalLoanDelinquencyActionRepository actionRepository; + private final WorkingCapitalLoanDelinquencyRangeScheduleService rangeScheduleService; public WorkingCapitalLoanDelinquencyAction validateAndParse(final JsonCommand command, final WorkingCapitalLoan workingCapitalLoan, final List existing) { @@ -250,6 +252,24 @@ private void validateReschedule(final WorkingCapitalLoanDelinquencyAction action } if (hasFrequencyGroup) { validateFrequencyGroupProvided(action, dataValidator); + if (action.getFrequency() != null && action.getFrequency() > 0 && action.getFrequencyType() != null) { + validateFrequencyDoesNotEndBeforeBusinessDate(action, workingCapitalLoan, dataValidator); + } + } + } + + /** + * Rejects a frequency change whose resulting period end date falls before the current business date. The candidate + * end date comes from the range schedule service, so it is the very date the re-date would persist. + */ + private void validateFrequencyDoesNotEndBeforeBusinessDate(final WorkingCapitalLoanDelinquencyAction action, + final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final Optional candidateToDate = rangeScheduleService.calculateRescheduledCurrentPeriodToDate(workingCapitalLoan.getId(), + action.getFrequency(), action.getFrequencyType()); + if (candidateToDate.filter(toDate -> toDate.isBefore(businessDate)).isPresent()) { + failGeneralValidation(dataValidator, "reschedule.frequency.results.endDate.before.businessDate", + "Frequency change results a delinquency period endDate before current businessDate is not allowed"); } } @@ -380,9 +400,7 @@ private void validateLoanIsDisbursed(final WorkingCapitalLoan workingCapitalLoan } private void validateScheduleExists(final WorkingCapitalLoan workingCapitalLoan, final DataValidatorBuilder dataValidator) { - final List periods = rangeScheduleRepository - .findByLoanIdOrderByPeriodNumberAsc(workingCapitalLoan.getId()); - if (periods.isEmpty()) { + if (!rangeScheduleRepository.existsByLoanId(workingCapitalLoan.getId())) { failGeneralValidation(dataValidator, "no.schedule", "Reschedule action requires an existing delinquency range schedule."); } } diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyDisableValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyDisableValidatorTest.java index 3a6a075a6c1..433a302fba6 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyDisableValidatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanDelinquencyDisableValidatorTest.java @@ -45,6 +45,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanDelinquencyRangeScheduleService; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -70,6 +71,8 @@ class WorkingCapitalLoanDelinquencyDisableValidatorTest { @Mock private WorkingCapitalLoanDelinquencyActionRepository actionRepository; @Mock + private WorkingCapitalLoanDelinquencyRangeScheduleService rangeScheduleService; + @Mock private WorkingCapitalLoan loan; @Mock private WorkingCapitalLoanProductRelatedDetails productRelatedDetails; @@ -82,7 +85,7 @@ class WorkingCapitalLoanDelinquencyDisableValidatorTest { @BeforeEach void setUp() { validator = new WorkingCapitalLoanDelinquencyActionParseAndValidator(new FromJsonHelper(), rangeScheduleRepository, - actionRepository); + actionRepository, rangeScheduleService); ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); final HashMap businessDates = new HashMap<>(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanBreachActionTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanBreachActionTest.java index c306db49b0f..9dee59d250b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanBreachActionTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanBreachActionTest.java @@ -178,7 +178,7 @@ public void testResumeOutsidePauseWindowDoesNotSetEffectiveEndDate() { CallFailedRuntimeException ex = breachActionHelper.resumeExpectingFailure(loanIdHolder[0], "2026-07-20"); assertEquals(400, ex.getStatus()); assertNotNull(ex.getDeveloperMessage()); - assertTrue(ex.getDeveloperMessage().contains("resume.not.during.active.pause")); + assertTrue(ex.getDeveloperMessage().contains("Resume breach action can only be created during an active pause")); final List actions = breachActionHelper.retrieveBreachActions(loanIdHolder[0]); final WorkingCapitalLoanBreachActionData pause = actions.stream()