From 2cde43ab5042917f0345b3c5ff0277bb17137f5b Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Wed, 8 Jul 2026 17:20:04 +0300 Subject: [PATCH 1/2] FINERACT-2455: WC - Inherit partial reschedule params from prior actions and cap delinquency minimum payment at remaining balance --- .../WorkingCapitalBreachActionStepDef.java | 8 +- .../WorkingCapitalBreachReschedule.feature | 38 ++- ...orkingCapitalDelinquencyReschedule.feature | 296 ++++++++++++++++-- .../WorkingCapitalLoanUndoTransaction.feature | 2 +- ...InternalWorkingCapitalLoanApiResource.java | 13 +- .../domain/WorkingCapitalLoanBalance.java | 10 + ...ngCapitalLoanDelinquencyRangeSchedule.java | 3 + ...kingCapitalLoanBreachActionRepository.java | 9 +- ...apitalLoanDelinquencyActionRepository.java | 3 +- ...oanDelinquencyRangeScheduleRepository.java | 10 + .../EffectiveBreachRescheduleParams.java | 32 ++ .../EffectiveDelinquencyRescheduleParams.java | 32 ++ ...pitalLoanBreachActionWriteServiceImpl.java | 2 +- ...rkingCapitalLoanBreachScheduleService.java | 7 +- ...gCapitalLoanBreachScheduleServiceImpl.java | 103 +++--- ...LoanDelinquencyActionWriteServiceImpl.java | 5 +- ...alLoanDelinquencyRangeScheduleService.java | 8 +- ...anDelinquencyRangeScheduleServiceImpl.java | 201 ++++++------ ...ngCapitalLoanWritePlatformServiceImpl.java | 19 +- .../module-changelog-master.xml | 1 + ...cy_range_schedule_base_expected_amount.xml | 50 +++ 21 files changed, 649 insertions(+), 203 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveBreachRescheduleParams.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveDelinquencyRescheduleParams.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0051_wc_delinquency_range_schedule_base_expected_amount.xml diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java index aa9782c8b49..b6ee123cf5c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBreachActionStepDef.java @@ -339,11 +339,15 @@ private void verifyActionField(final WorkingCapitalLoanBreachActionData actual, assertThat(actual.getAction().name()).as(label).isEqualTo(expected); } case "startDate" -> assertThat(actual.getStartDate()).as(label).isEqualTo(LocalDate.parse(expected, DATE_FORMAT)); - case "minimumPayment" -> assertThat(actual.getMinimumPayment()).as(label).isEqualByComparingTo(new BigDecimal(expected)); + case "minimumPayment" -> + verifyOptionalField(expected, v -> assertThat(actual.getMinimumPayment()).as(label).isEqualByComparingTo(new BigDecimal(v)), + () -> assertThat(actual.getMinimumPayment()).as(label).isNull()); case "minimumPaymentType" -> verifyOptionalField(expected, v -> assertThat(String.valueOf(actual.getMinimumPaymentType())).as(label).isEqualTo(v), () -> assertThat(actual.getMinimumPaymentType()).as(label).isNull()); - case "frequency" -> assertThat(actual.getFrequency()).as(label).isEqualTo(Integer.parseInt(expected)); + case "frequency" -> + verifyOptionalField(expected, v -> assertThat(actual.getFrequency()).as(label).isEqualTo(Integer.parseInt(v)), + () -> assertThat(actual.getFrequency()).as(label).isNull()); case "frequencyType" -> verifyOptionalField(expected, v -> assertThat(String.valueOf(actual.getFrequencyType())).as(label).isEqualTo(v), () -> assertThat(actual.getFrequencyType()).as(label).isNull()); 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 268d704ddd3..af573699f3a 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -175,7 +175,7 @@ Feature: Working Capital Breach Reschedule Action Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C85279 - Scenario: Verify breach reschedule - UC8: payment-only reschedule after frequency reschedule falls back to product breach frequency + Scenario: Verify breach reschedule - UC8: payment-only reschedule after frequency reschedule inherits the previously rescheduled frequency When Admin sets the business date to "01 January 2026" And Admin creates a client with random data And Admin creates a new Working Capital Loan Product with breachId and overrides enabled @@ -200,7 +200,8 @@ Feature: Working Capital Breach Reschedule Action | 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-08-31 | 62 | 90 | 90 | null | null | + | 4 | 2026-07-01 | 2026-07-30 | 30 | 90 | 90 | null | true | + | 5 | 2026-07-31 | 2026-08-29 | 30 | 90 | 90 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" @TestRailId:C85280 @@ -321,3 +322,36 @@ Feature: Working Capital Breach Reschedule Action | 3 | 2019-05-01 | 2019-05-30 | 30 | 500.00 | 500.00 | null | true | | 4 | 2019-05-31 | 2019-06-29 | 30 | 500.00 | 500.00 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 June 2019" + + Scenario: Verify breach reschedule inherits each partial group from the latest action that set it + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with breachId and overrides enabled + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 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" + 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" + 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 | + | 1 | PERCENTAGE | + When Admin creates WC breach reschedule action with the following parameters: + | 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 | + 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 | + Then Admin closes the Working Capital loan with a full repayment on "15 August 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 a7194821692..8f18dd0f993 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -444,7 +444,7 @@ Feature: Working Capital Delinquency Reschedule Action Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C74511 - Scenario: Verify that reschedule supports minimumPayment over 100 percent + Scenario: Verify that a reschedule minimumPayment over 100 percent is capped at the remaining balance 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 @@ -460,7 +460,7 @@ Feature: Working Capital Delinquency Reschedule Action | 200 | PERCENTAGE | 30 | 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 | 20000 | 0 | 20000 | | + | 1 | 01 January 2026 | 30 January 2026 | 10000 | 0 | 10000 | | Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C74512 @@ -935,9 +935,6 @@ Feature: Working Capital Delinquency Reschedule Action | 3 | 03 March 2026 | 01 April 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" - # Asserts transitive partial-reschedule inheritance - not implemented yet, follow-up story PS-3199 - # (related to C76658 & C76659) - @Skip @TestRailId:C85489 Scenario: Verify partial reschedules in 2 steps on the same day inherit parameters from each other When Admin sets the business date to "01 January 2026" @@ -963,15 +960,12 @@ 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 | 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 | | + | 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 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" - # Asserts transitive partial-reschedule inheritance - not implemented yet, follow-up story PS-3199 - # (related to C76658 & C76659) - @Skip @TestRailId:C85490 Scenario: Verify partial reschedules in 2 steps on different days inherit parameters from each other When Admin sets the business date to "01 January 2026" @@ -1000,13 +994,12 @@ 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 | 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 | | + | 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 | | Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" - @Skip @TestRailId:C85491 Scenario: Verify that reschedule with payment group only keeps original frequency When Admin sets the business date to "01 January 2026" @@ -1038,7 +1031,6 @@ Feature: Working Capital Delinquency Reschedule Action | 8 | 30 July 2026 | 28 August 2026 | 100 | 0 | 100 | | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" - @Skip @TestRailId:C85492 Scenario: Verify that reschedule with frequency group only keeps original payment When Admin sets the business date to "01 January 2026" @@ -1072,6 +1064,45 @@ Feature: Working Capital Delinquency Reschedule Action | 10 | 14 August 2026 | 28 August 2026 | 300 | 0 | 300 | | Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" + Scenario: Verify that three partial reschedules inherit each parameter group from the latest action that set it + 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 + 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 | 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 + When Admin sets the business date to "02 January 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 25 | PERCENTAGE | + When Admin sets the business date to "03 January 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | frequency | frequencyType | + | 30 | DAYS | + When Admin sets the business date to "04 January 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 33.33 | PERCENTAGE | + Then WC loan delinquency actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 02 January 2026 | 25 | PERCENTAGE | | | + | RESCHEDULE | 03 January 2026 | | | 30 | DAYS | + | RESCHEDULE | 04 January 2026 | 33.33 | PERCENTAGE | | | + 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 | | + Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + @TestRailId:C85493 Scenario: Verify backdated repayment after reschedule is re-evaluated against rescheduled periods When Admin sets the business date to "01 January 2026" @@ -1410,6 +1441,231 @@ Feature: Working Capital Delinquency Reschedule Action When Admin sets the business date to "01 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 | 266.64 | 0 | 266.64 | | + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 4 | 01 April 2026 | 30 April 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "01 April 2026" + + Scenario: Verify that a delinquency period expects only the remaining balance when it is below the minimum payment + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 700 transaction amount on Working Capital loan + When Admin sets the business date to "28 February 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 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | | + Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + 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 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 100 | 0 | true | + + Scenario: Verify that a reschedule-based minimum payment is capped at the remaining balance + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 700 transaction amount on Working Capital loan + When Admin sets the business date to "28 February 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 500 | FLAT | + Then WC loan delinquency actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 28 February 2026 | 500 | FLAT | | | + 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 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | | + Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + + Scenario: Verify that undoing a repayment restores the uncapped minimum payment expectation + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 700 transaction amount on Working Capital loan + When Admin sets the business date to "28 February 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 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | | + When Customer undo "1"th working capital transaction made on "15 January 2026" + 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 | | + Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + + Scenario: Verify that a past-due period beyond the remaining balance is met once the balance is fully paid + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 June 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 | 200 | 0 | 200 | false | + | 4 | 01 April 2026 | 30 April 2026 | 200 | 0 | 200 | false | + | 5 | 01 May 2026 | 30 May 2026 | 200 | 0 | 200 | false | + | 6 | 31 May 2026 | 29 June 2026 | 200 | 0 | 200 | | + Then Admin closes the Working Capital loan with a full repayment on "15 June 2026" + 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 | 200 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 200 | 0 | true | + | 3 | 02 March 2026 | 31 March 2026 | 200 | 200 | 0 | true | + | 4 | 01 April 2026 | 30 April 2026 | 200 | 200 | 0 | true | + | 5 | 01 May 2026 | 30 May 2026 | 0 | 0 | 0 | true | + | 6 | 31 May 2026 | 29 June 2026 | 0 | 0 | 0 | true | + + Scenario: Verify that the remaining balance cap on a discounted loan uses the principal plus discount base + 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 + 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 | 800 | 8000 | 1 | 100 | + When Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and "100" discount 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 "100" discount amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 750 transaction amount on Working Capital loan + When Admin sets the business date to "28 February 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 | 225 | 750 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 150 | 0 | 150 | | + Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 225 | 750 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 150 | 150 | 0 | true | + + Scenario: Verify that a backdated repayment reprocess recalculates the remaining balance cap + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "28 February 2026" + When Admin runs inline COB job for Working Capital Loan + And Customer makes repayment on "28 February 2026" with 50 transaction amount on 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 | 50 | 150 | false | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | | + And Customer makes repayment on "15 January 2026" with 700 transaction amount on 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 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 50 | 50 | | + Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + 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 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 100 | 0 | true | + + Scenario: Verify that adding a discount after disbursement raises the remaining balance cap + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 200 | PERCENTAGE | + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 800 | 0 | 800 | | + And Admin adds Discount fee with "100" amount on Working Capital loan account for last disbursement + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 900 | 0 | 900 | | + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 900 | 900 | 0 | true | + + Scenario: Verify that a discount adjustment and its undo re-derive the remaining balance cap + 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 + 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 | 800 | 8000 | 1 | 100 | + When Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and "100" discount 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 "100" discount amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 700 transaction amount on Working Capital loan + When Admin sets the business date to "28 February 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 | 225 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | | + And Admin loads discount fee transaction from Working Capital loan for adjustment + And Admin adds Discount fee adjustment with "100" amount on transaction date "28 February 2026" on Working Capital loan account for last discount + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 225 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | | + When Admin undo the last Discount fee adjustment on Working Capital loan account + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 225 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | | + Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 225 | 700 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 200 | 200 | 0 | true | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanUndoTransaction.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanUndoTransaction.feature index 550a897bf1f..b38e9f2687a 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanUndoTransaction.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanUndoTransaction.feature @@ -512,7 +512,7 @@ Feature: Working Capital Loan Undo Transaction 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 | 700 | 0 | true | - | 2 | 31 January 2026 | 01 March 2026 | 200 | 200 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 200 | 0 | true | When Customer undo "1"th working capital transaction made on "30 January 2026" Then WC loan delinquency range schedule periods have specific data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/InternalWorkingCapitalLoanApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/InternalWorkingCapitalLoanApiResource.java index e730ce51585..670272f05ae 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/InternalWorkingCapitalLoanApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/InternalWorkingCapitalLoanApiResource.java @@ -41,8 +41,10 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanAmortizationScheduleWriteService; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanDelinquencyRangeScheduleService; @@ -61,6 +63,7 @@ public class InternalWorkingCapitalLoanApiResource implements InitializingBean { private final WorkingCapitalLoanAmortizationScheduleWriteService writeService; private final WorkingCapitalLoanRepository loanRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleService rangeScheduleService; @Override @@ -98,7 +101,8 @@ public void generateAmortizationSchedule(@PathParam("loanId") @Parameter(descrip @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Activate a Working Capital Loan (testing only)", description = """ - Sets the WC loan status to ACTIVE and records a disbursement detail with the given date. + Sets the WC loan status to ACTIVE, records a disbursement detail with the given date and + initializes the loan balance as a real disbursement would. Also generates the initial delinquency range schedule period if a delinquency bucket is configured. DO NOT USE THIS IN PRODUCTION! Disbursement must go through the proper disbursement flow.""") @@ -120,6 +124,13 @@ public Response activateLoan(@PathParam("loanId") @Parameter(description = "loan loan.setLoanStatus(LoanStatus.ACTIVE); loanRepository.saveAndFlush(loan); + // The balance must reflect the faked disbursement (as the real disbursement flow does), + // otherwise the schedule generation caps the period to the zero remaining balance. + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loanId) + .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); + balance.applyDisbursement(loan.getApprovedPrincipal()); + balanceRepository.saveAndFlush(balance); + rangeScheduleService.generateInitialPeriod(loan); log.info("Activated WC loan {} with disbursement date {} (TEST ONLY)", loanId, disbursementDate); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java index dce547ecfe3..2bf4741084e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java @@ -27,10 +27,12 @@ import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; import java.math.BigDecimal; +import java.util.Optional; import lombok.Getter; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; /** * Stores all balances of a working capital loan (one row per loan). Updated from allocations; accounting depends on @@ -102,6 +104,14 @@ public static WorkingCapitalLoanBalance createFor(final WorkingCapitalLoan loan) return balance; } + public void applyDisbursement(final BigDecimal disbursedAmount) { + final BigDecimal discount = Optional.ofNullable(wcLoan.getLoanProductRelatedDetails()) + .map(WorkingCapitalLoanProductRelatedDetails::getDiscount).orElse(BigDecimal.ZERO); + this.totalDiscountFee = discount; + this.principal = disbursedAmount.add(discount); + this.overpaymentAmount = BigDecimal.ZERO; + } + public BigDecimal getPrincipalOutstanding() { return MathUtil.subtract(getPrincipal(), getPrincipalPaid()).max(BigDecimal.ZERO); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java index 72a6d70cd92..1ecc8b50c76 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanDelinquencyRangeSchedule.java @@ -61,6 +61,9 @@ public class WorkingCapitalLoanDelinquencyRangeSchedule extends AbstractAuditabl @Column(name = "expected_amount", scale = 6, precision = 19) private BigDecimal expectedAmount; + @Column(name = "base_expected_amount", scale = 6, precision = 19) + private BigDecimal baseExpectedAmount; + @Column(name = "paid_amount", scale = 6, precision = 19) private BigDecimal paidAmount; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java index f3d27e4711f..ebdbdeccf86 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachActionRepository.java @@ -20,7 +20,6 @@ import java.time.LocalDate; import java.util.List; -import java.util.Optional; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType; import org.springframework.data.jpa.repository.JpaRepository; @@ -31,13 +30,13 @@ public interface WorkingCapitalLoanBreachActionRepository extends JpaRepository< List findByWorkingCapitalLoanIdOrderById(Long workingCapitalLoanId); - Optional findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, + List findByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, WorkingCapitalLoanBreachActionType action); @Query(""" - select case when count(action) > 0 then true else false end from WorkingCapitalLoanBreachAction action - where action.action = org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType.DISABLE - and action.workingCapitalLoan.id = :loanId and action.startDate <= :date and (action.endDate IS NULL OR action.endDate >= :date) + SELECT CASE WHEN COUNT(action) > 0 THEN TRUE ELSE FALSE END FROM WorkingCapitalLoanBreachAction action + WHERE action.action = org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType.DISABLE + AND action.workingCapitalLoan.id = :loanId AND action.startDate <= :date AND (action.endDate IS NULL OR action.endDate >= :date) """) boolean isBreachDisabledAsOf(@Param("loanId") Long loanId, @Param("date") LocalDate date); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java index aab24c89763..951ae686674 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java @@ -19,7 +19,6 @@ package org.apache.fineract.portfolio.workingcapitalloan.repository; import java.util.List; -import java.util.Optional; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.springframework.data.jpa.repository.JpaRepository; @@ -30,7 +29,7 @@ public interface WorkingCapitalLoanDelinquencyActionRepository extends JpaReposi List findByWorkingCapitalLoanIdOrderById(Long workingCapitalLoanId); - Optional findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, + List findByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, DelinquencyAction action); } 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 d6a83496129..c6dab6297f4 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 @@ -46,6 +46,16 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleRepository List findPastOpenPeriodsForRepayment(@Param("loanId") Long loanId, @Param("transactionDate") LocalDate transactionDate); + @Query(""" + SELECT s FROM WorkingCapitalLoanDelinquencyRangeSchedule s + WHERE s.loan.id = :loanId + AND (s.minPaymentCriteriaMet IS NULL OR s.minPaymentCriteriaMet = FALSE) + AND s.expectedAmount IS NOT NULL + AND s.expectedAmount > COALESCE(s.paidAmount, 0) + :principalOutstanding + ORDER BY s.periodNumber ASC""") + List findOpenPeriodsExceedingRemainingBalanceCap(@Param("loanId") Long loanId, + @Param("principalOutstanding") BigDecimal principalOutstanding); + Optional findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(Long loanId, LocalDate date, LocalDate date2); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveBreachRescheduleParams.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveBreachRescheduleParams.java new file mode 100644 index 00000000000..d65e70c01d8 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveBreachRescheduleParams.java @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.math.BigDecimal; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; + +/** + * Breach reschedule parameters resolved per group: each group ({@code minimumPayment}+{@code minimumPaymentType}, or + * {@code frequency}+{@code frequencyType}) inherits independently from the latest RESCHEDULE action that set it, + * falling back to the product breach configuration only if no action ever set it. + */ +record EffectiveBreachRescheduleParams(BigDecimal minimumPayment, WorkingCapitalBreachAmountCalculationType minimumPaymentType, + Integer frequency, WorkingCapitalLoanPeriodFrequencyType frequencyType) { +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveDelinquencyRescheduleParams.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveDelinquencyRescheduleParams.java new file mode 100644 index 00000000000..3ad2390b7a5 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/EffectiveDelinquencyRescheduleParams.java @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.math.BigDecimal; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentType; + +/** + * Reschedule parameters resolved per group: each group ({@code minimumPayment}+{@code minimumPaymentType}, or + * {@code frequency}+{@code frequencyType}) inherits independently from the latest RESCHEDULE action that set it, + * falling back to the product configuration only if no action ever set it. + */ +record EffectiveDelinquencyRescheduleParams(BigDecimal minimumPayment, DelinquencyMinimumPaymentType minimumPaymentType, Integer frequency, + DelinquencyFrequencyType frequencyType) { +} 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 0ae394d0511..d5ad1ef8bff 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, breachAction); + breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan); } 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 b9f154209b5..5085a5a052a 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 @@ -23,7 +23,6 @@ import java.util.List; 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; public interface WorkingCapitalLoanBreachScheduleService { @@ -44,7 +43,11 @@ public interface WorkingCapitalLoanBreachScheduleService { void evaluateBreach(WorkingCapitalLoan loan, LocalDate businessDate); - void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction rescheduleAction); + /** + * 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. + */ + void rescheduleMinimumPayment(WorkingCapitalLoan loan); 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 8dd75d1baf1..6bb858d1369 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 @@ -75,12 +75,9 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) { } final LocalDate fromDate = disbursementDateOptional.get().plusDays(getBreachGraceDays(loan)); - final WorkingCapitalBreach breach = breachOpt.get(); - final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); - final Integer effectiveFrequency = resolveFrequency(latestReschedule.orElse(null), breach); - final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = resolveFrequencyType(latestReschedule.orElse(null), breach); - final LocalDate toDate = calculateToDate(fromDate, effectiveFrequency, effectiveFreqType); - final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach, latestReschedule.orElse(null)); + final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); + final LocalDate toDate = calculateToDate(fromDate, params.frequency(), params.frequencyType()); + final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, params); final WorkingCapitalLoanBreachSchedule period = createPeriod(loan, 1, fromDate, toDate, minPaymentAmount); applyRecordedPauses(period, findEffectivePauses(loan.getId())); @@ -101,15 +98,14 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca } final Optional latestPeriodOpt = repository.findTopByLoanIdOrderByPeriodNumberDesc(loan.getId()); - if (latestPeriodOpt.isEmpty()) { + if (latestPeriodOpt.isEmpty() || latestPeriodOpt.get().getToDate().isAfter(businessDate)) { return; } - final WorkingCapitalBreach breach = breachOpt.get(); - final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); - final Integer effectiveFrequency = resolveFrequency(latestReschedule.orElse(null), breach); - final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = resolveFrequencyType(latestReschedule.orElse(null), breach); - final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, breach, latestReschedule.orElse(null)); + final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); + final Integer effectiveFrequency = params.frequency(); + final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = params.frequencyType(); + final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, params); final List effectivePauses = findEffectivePauses(loan.getId()); final List newPeriods = new ArrayList<>(); @@ -231,17 +227,17 @@ public List retrieveBreachSchedule(final L } @Override - public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachAction rescheduleAction) { + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { final LocalDate businessDate = DateUtils.getBusinessLocalDate(); final Optional breachOpt = getBreachConfig(loan); if (breachOpt.isEmpty()) { log.warn("No breach configuration found for WC loan {}, skipping reschedule", loan.getId()); return; } - final WorkingCapitalBreach breach = breachOpt.get(); - final BigDecimal newMinPaymentAmount = calculateMinPaymentAmount(loan, breach, rescheduleAction); - final Integer newFrequency = resolveFrequency(rescheduleAction, breach); - final WorkingCapitalLoanPeriodFrequencyType newFreqType = resolveFrequencyType(rescheduleAction, breach); + final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); + final BigDecimal newMinPaymentAmount = calculateMinPaymentAmount(loan, params); + final Integer newFrequency = params.frequency(); + final WorkingCapitalLoanPeriodFrequencyType newFreqType = params.frequencyType(); final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); @@ -273,7 +269,7 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final Workin evaluateExpiredBreaches(loan, businessDate); log.debug("Rescheduled breach schedule for WC loan {}: new minimumPayment={} {}, frequency={} {}", loan.getId(), - rescheduleAction.getMinimumPayment(), rescheduleAction.getMinimumPaymentType(), newFrequency, newFreqType); + params.minimumPayment(), params.minimumPaymentType(), newFrequency, newFreqType); } @Override @@ -286,10 +282,9 @@ public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) { if (periods.isEmpty()) { return; } - final WorkingCapitalBreach breach = breachOpt.get(); - final Optional latestReschedule = findLatestRescheduleAction(loan.getId()); - final Integer effectiveFrequency = resolveFrequency(latestReschedule.orElse(null), breach); - final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = resolveFrequencyType(latestReschedule.orElse(null), breach); + final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); + final Integer effectiveFrequency = params.frequency(); + final WorkingCapitalLoanPeriodFrequencyType effectiveFreqType = params.frequencyType(); final List effectivePauses = findEffectivePauses(loan.getId()); final LocalDate businessDate = DateUtils.getBusinessLocalDate(); LocalDate fromDate = periods.getFirst().getFromDate(); @@ -451,16 +446,13 @@ private LocalDate calculateToDate(final LocalDate fromDate, final Integer freque }; } - private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, final WorkingCapitalBreach breach, - final WorkingCapitalLoanBreachAction rescheduleOverride) { - final BigDecimal effectiveBreachAmount = resolveBreachAmount(rescheduleOverride, breach); - if (effectiveBreachAmount == null) { + private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, final EffectiveBreachRescheduleParams params) { + final BigDecimal breachAmount = params.minimumPayment(); + if (breachAmount == null) { return BigDecimal.ZERO; } - final WorkingCapitalBreachAmountCalculationType effectiveCalculationType = resolveBreachAmountCalculationType(rescheduleOverride, - breach); - if (WorkingCapitalBreachAmountCalculationType.FLAT.equals(effectiveCalculationType)) { - return effectiveBreachAmount; + if (WorkingCapitalBreachAmountCalculationType.FLAT.equals(params.minimumPaymentType())) { + return breachAmount; } final BigDecimal principal = loan.getApprovedPrincipal(); if (principal == null) { @@ -468,44 +460,25 @@ private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, fina } final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() : null; final BigDecimal base = discount != null ? principal.add(discount) : principal; - final BigDecimal rawAmount = MathUtil.percentageOf(base, effectiveBreachAmount, MoneyHelper.getMathContext()); + final BigDecimal rawAmount = MathUtil.percentageOf(base, breachAmount, MoneyHelper.getMathContext()); return Money.of(loan.getLoanProductRelatedDetails().getCurrency(), rawAmount).getAmount(); } - private Optional findLatestRescheduleAction(final Long loanId) { - return breachActionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, - WorkingCapitalLoanBreachActionType.RESCHEDULE); - } - - private Integer resolveFrequency(final WorkingCapitalLoanBreachAction rescheduleOverride, final WorkingCapitalBreach breach) { - if (rescheduleOverride != null && rescheduleOverride.getFrequency() != null) { - return rescheduleOverride.getFrequency(); - } - return breach.getBreachFrequency(); - } - - private WorkingCapitalLoanPeriodFrequencyType resolveFrequencyType(final WorkingCapitalLoanBreachAction rescheduleOverride, - final WorkingCapitalBreach breach) { - if (rescheduleOverride != null && rescheduleOverride.getFrequencyType() != null) { - return rescheduleOverride.getFrequencyType(); - } - return breach.getBreachFrequencyType(); - } - - private BigDecimal resolveBreachAmount(final WorkingCapitalLoanBreachAction rescheduleOverride, final WorkingCapitalBreach breach) { - if (rescheduleOverride != null && rescheduleOverride.getMinimumPayment() != null) { - return rescheduleOverride.getMinimumPayment(); - } - return breach.getBreachAmount(); - } - - private WorkingCapitalBreachAmountCalculationType resolveBreachAmountCalculationType( - final WorkingCapitalLoanBreachAction rescheduleOverride, final WorkingCapitalBreach breach) { - if (rescheduleOverride != null && rescheduleOverride.getMinimumPaymentType() != null) { - return rescheduleOverride.getMinimumPaymentType(); - } - return breach.getBreachAmountCalculationType() != null ? breach.getBreachAmountCalculationType() - : WorkingCapitalBreachAmountCalculationType.PERCENTAGE; + private EffectiveBreachRescheduleParams resolveEffectiveRescheduleParams(final Long loanId, final WorkingCapitalBreach breach) { + final List reschedules = breachActionRepository + .findByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, WorkingCapitalLoanBreachActionType.RESCHEDULE); + final Optional latestWithPayment = reschedules.stream() + .filter(action -> action.getMinimumPayment() != null).findFirst(); + final Optional latestWithFrequency = reschedules.stream() + .filter(action -> action.getFrequency() != null).findFirst(); + + return new EffectiveBreachRescheduleParams( + latestWithPayment.map(WorkingCapitalLoanBreachAction::getMinimumPayment).orElse(breach.getBreachAmount()), + latestWithPayment.map(WorkingCapitalLoanBreachAction::getMinimumPaymentType) + .or(() -> Optional.ofNullable(breach.getBreachAmountCalculationType())) + .orElse(WorkingCapitalBreachAmountCalculationType.PERCENTAGE), + latestWithFrequency.map(WorkingCapitalLoanBreachAction::getFrequency).orElse(breach.getBreachFrequency()), + latestWithFrequency.map(WorkingCapitalLoanBreachAction::getFrequencyType).orElse(breach.getBreachFrequencyType())); } private void evaluateExpiredBreaches(final WorkingCapitalLoan loan, final LocalDate businessDate) { 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 505d6b10c63..3beede89040 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 @@ -44,7 +44,6 @@ public class WorkingCapitalLoanDelinquencyActionWriteServiceImpl implements Work private final WorkingCapitalLoanDelinquencyActionRepository actionRepository; private final WorkingCapitalLoanDelinquencyActionParseAndValidator validator; private final WorkingCapitalLoanDelinquencyRangeScheduleService rangeScheduleService; - private final WorkingCapitalLoanDelinquencyRangeScheduleService delinquencyRangeScheduleService; @Transactional @Override @@ -63,8 +62,8 @@ public CommandProcessingResult createDelinquencyAction(final Long workingCapital if (DelinquencyAction.PAUSE.equals(action.getAction())) { rangeScheduleService.extendPeriodsForPause(workingCapitalLoan, action.getStartDate(), action.getEndDate()); } else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) { - rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action); - delinquencyRangeScheduleService.reprocessDelinquencySchedule(workingCapitalLoan); + rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan); + rangeScheduleService.reprocessDelinquencySchedule(workingCapitalLoan); } else if (DelinquencyAction.RESUME.equals(action.getAction())) { final WorkingCapitalLoanDelinquencyAction activePause = validator.findActivePauseForResume(existing, DateUtils.getBusinessLocalDate()); 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 f99d19e901e..de0153f46be 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 @@ -41,7 +41,13 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleService { void extendPeriodsForPause(WorkingCapitalLoan loan, LocalDate pauseStart, LocalDate pauseEnd); - void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction rescheduleAction); + /** + * 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. + */ + void rescheduleMinimumPayment(WorkingCapitalLoan loan); 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 0a27c97196f..0a1f6360a6d 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 @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.data.TransactionDateAndAmountHolder; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyPauseUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; @@ -77,18 +78,11 @@ public void generateInitialPeriod(WorkingCapitalLoan loan) { log.warn("No anchor date found for WC loan {}, skipping initial period generation", loan.getId()); return; } - LocalDate toDate = calculateToDate(fromDate, rule.getFrequency(), rule.getFrequencyType()); - BigDecimal expectedAmount = calculateExpectedAmount(loan, rule, null); - WorkingCapitalLoanDelinquencyRangeSchedule period = new WorkingCapitalLoanDelinquencyRangeSchedule(); - period.setLoan(loan); - period.setPeriodNumber(1); - period.setFromDate(fromDate); - period.setToDate(toDate); - period.setExpectedAmount(expectedAmount); - period.setPaidAmount(BigDecimal.ZERO); - period.setOutstandingAmount(expectedAmount); - period.setMinPaymentCriteriaMet(null); + final EffectiveDelinquencyRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), rule); + final LocalDate toDate = calculateToDate(fromDate, params.frequency(), params.frequencyType()); + final WorkingCapitalLoanDelinquencyRangeSchedule period = buildPeriod(loan, 1, fromDate, toDate, + calculateExpectedAmount(loan, params)); loanDelinquencyRangeScheduleRepository.saveAndFlush(period); log.debug("Generated initial delinquency range schedule period for WC loan {}", loan.getId()); @@ -108,30 +102,19 @@ public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate busine final Optional latestPeriodOpt = loanDelinquencyRangeScheduleRepository .findTopByLoanIdOrderByPeriodNumberDesc(loan.getId()); - if (latestPeriodOpt.isEmpty()) { + if (latestPeriodOpt.isEmpty() || latestPeriodOpt.get().getToDate().isAfter(businessDate)) { return; } - final WorkingCapitalLoanDelinquencyAction effectiveReschedule = resolveEffectiveRescheduleParams(loan.getId(), rule); - final Integer effectiveFrequency = effectiveReschedule.getFrequency(); - final DelinquencyFrequencyType effectiveFreqType = effectiveReschedule.getFrequencyType(); + final EffectiveDelinquencyRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), rule); + final BigDecimal expectedAmount = calculateExpectedAmount(loan, params); WorkingCapitalLoanDelinquencyRangeSchedule latestPeriod = latestPeriodOpt.get(); while (!latestPeriod.getToDate().isAfter(businessDate)) { final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); - final LocalDate newToDate = calculateToDate(newFromDate, effectiveFrequency, effectiveFreqType); - final BigDecimal expectedAmount = calculateExpectedAmount(loan, rule, effectiveReschedule); - - final WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = new WorkingCapitalLoanDelinquencyRangeSchedule(); - nextPeriod.setLoan(loan); - nextPeriod.setPeriodNumber(latestPeriod.getPeriodNumber() + 1); - nextPeriod.setFromDate(newFromDate); - nextPeriod.setToDate(newToDate); - nextPeriod.setExpectedAmount(expectedAmount); - nextPeriod.setPaidAmount(BigDecimal.ZERO); - nextPeriod.setOutstandingAmount(expectedAmount); - nextPeriod.setMinPaymentCriteriaMet(null); - + final LocalDate newToDate = calculateToDate(newFromDate, params.frequency(), params.frequencyType()); + final WorkingCapitalLoanDelinquencyRangeSchedule nextPeriod = buildPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate, + newToDate, expectedAmount); applyRecordedPauses(nextPeriod, loan); latestPeriod = loanDelinquencyRangeScheduleRepository.saveAndFlush(nextPeriod); @@ -142,6 +125,7 @@ public void generateNextPeriodIfNeeded(WorkingCapitalLoan loan, LocalDate busine @Override public void applyRepayment(WorkingCapitalLoan loan, LocalDate transactionDate, BigDecimal amount) { allocateRepayment(loan, transactionDate, amount); + applyRemainingBalanceCap(loan); delinquencyClassificationService.instantClassifyDelinquency(loan, transactionDate); } @@ -168,6 +152,9 @@ public void reprocessDelinquencySchedule(final WorkingCapitalLoan loan) { }); } + // One cap pass suffices here: the cascade pays oldest-first, so only the trailing period(s) can + // still exceed the balance once the whole replay is done. + applyRemainingBalanceCap(loan); evaluateExpiredPeriods(loan, businessDate); delinquencyClassificationService.classifyDelinquency(loan, businessDate); } @@ -176,6 +163,11 @@ private List resetAllPeriodsForRepro final List periods = loanDelinquencyRangeScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(loanId); for (final WorkingCapitalLoanDelinquencyRangeSchedule period : periods) { + // Reset to the uncapped base first so the cap re-derives cleanly from the replayed balance + // (null only for periods created before this column existed). + if (period.getBaseExpectedAmount() != null) { + period.setExpectedAmount(period.getBaseExpectedAmount()); + } period.setPaidAmount(BigDecimal.ZERO); period.setOutstandingAmount(period.getExpectedAmount()); period.setMinPaymentCriteriaMet(null); @@ -230,6 +222,7 @@ public void evaluateExpiredPeriods(WorkingCapitalLoan loan, LocalDate businessDa List unevaluatedPeriods = loanDelinquencyRangeScheduleRepository .findByLoanIdAndToDateLessThanEqualAndMinPaymentCriteriaMetIsNull(loan.getId(), businessDate); for (WorkingCapitalLoanDelinquencyRangeSchedule period : unevaluatedPeriods) { + capPeriodToRemainingBalance(period, loan); boolean criteriaMet = period.getPaidAmount().compareTo(period.getExpectedAmount()) >= 0; period.setMinPaymentCriteriaMet(criteriaMet); loanDelinquencyRangeScheduleRepository.saveAndFlush(period); @@ -246,18 +239,15 @@ public List retrieveRangeSchedul } @Override - public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyAction rescheduleAction) { + public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) { final LocalDate businessDate = DateUtils.getBusinessLocalDate(); final DelinquencyMinimumPaymentPeriodAndRule rule = getMinimumPaymentRule(loan); if (rule == null) { log.warn("No minimum payment rule found for WC loan {}, skipping reschedule", loan.getId()); return; } - final WorkingCapitalLoanDelinquencyAction effectiveReschedule = resolveEffectiveRescheduleParams(loan.getId(), rule); - final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, rule, effectiveReschedule); - final Integer newFrequency = effectiveReschedule.getFrequency() != null ? effectiveReschedule.getFrequency() : rule.getFrequency(); - final DelinquencyFrequencyType newFreqType = effectiveReschedule.getFrequencyType() != null ? effectiveReschedule.getFrequencyType() - : rule.getFrequencyType(); + final EffectiveDelinquencyRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), rule); + final BigDecimal newExpectedAmount = calculateExpectedAmount(loan, params); final List periods = loanDelinquencyRangeScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(loan.getId()); @@ -274,8 +264,7 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final Workin if (isCurrent) { currentPeriod = period; - period.setExpectedAmount(newExpectedAmount); - period.setOutstandingAmount(newExpectedAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO)); + period.setBaseExpectedAmount(newExpectedAmount); } else if (isFuture) { futurePeriods.add(period); } @@ -283,13 +272,11 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final Workin if (currentPeriod != null) { loanDelinquencyRangeScheduleRepository.saveAndFlush(currentPeriod); - updateFuturePeriods(currentPeriod, futurePeriods, newExpectedAmount, newFrequency, newFreqType); + updateFuturePeriods(currentPeriod, futurePeriods, newExpectedAmount, params.frequency(), params.frequencyType()); } - evaluateExpiredPeriods(loan, businessDate); - - log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={}%, frequency={} {}", loan.getId(), - effectiveReschedule.getMinimumPayment(), newFrequency, newFreqType); + log.debug("Rescheduled delinquency range schedule for WC loan {}: new minimumPayment={} {}, frequency={} {}", loan.getId(), + params.minimumPayment(), params.minimumPaymentType(), params.frequency(), params.frequencyType()); } @Override @@ -380,71 +367,112 @@ private LocalDate calculateToDate(LocalDate fromDate, Integer frequency, Delinqu }; } - private BigDecimal calculateExpectedAmount(final WorkingCapitalLoan loan, final DelinquencyMinimumPaymentPeriodAndRule rule, - final WorkingCapitalLoanDelinquencyAction rescheduleOverride) { + private BigDecimal calculateExpectedAmount(final WorkingCapitalLoan loan, final EffectiveDelinquencyRescheduleParams params) { final BigDecimal principal = loan.getApprovedPrincipal(); - if (principal == null) { - return BigDecimal.ZERO; - } - - final BigDecimal effectiveMinimumPayment = resolveMinimumPayment(rescheduleOverride, rule); - final DelinquencyMinimumPaymentType effectivePaymentType = resolveMinimumPaymentType(rescheduleOverride, rule); - if (effectiveMinimumPayment == null) { + if (principal == null || params.minimumPayment() == null) { return BigDecimal.ZERO; } final BigDecimal rawAmount; - if (DelinquencyMinimumPaymentType.FLAT.equals(effectivePaymentType)) { - rawAmount = effectiveMinimumPayment; + if (DelinquencyMinimumPaymentType.FLAT.equals(params.minimumPaymentType())) { + rawAmount = params.minimumPayment(); } else { final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() : null; final BigDecimal base = discount != null ? principal.add(discount) : principal; - rawAmount = MathUtil.percentageOf(base, effectiveMinimumPayment, MoneyHelper.getMathContext()); + rawAmount = MathUtil.percentageOf(base, params.minimumPayment(), MoneyHelper.getMathContext()); } return Money.of(loan.getLoanProductRelatedDetails().getCurrency(), rawAmount).getAmount(); } - private BigDecimal resolveMinimumPayment(final WorkingCapitalLoanDelinquencyAction rescheduleOverride, - final DelinquencyMinimumPaymentPeriodAndRule rule) { - if (rescheduleOverride != null && rescheduleOverride.getMinimumPayment() != null) { - return rescheduleOverride.getMinimumPayment(); - } - return rule != null ? rule.getMinimumPayment() : null; + private WorkingCapitalLoanDelinquencyRangeSchedule buildPeriod(final WorkingCapitalLoan loan, final int periodNumber, + final LocalDate fromDate, final LocalDate toDate, final BigDecimal expectedAmount) { + final WorkingCapitalLoanDelinquencyRangeSchedule period = new WorkingCapitalLoanDelinquencyRangeSchedule(); + period.setLoan(loan); + period.setPeriodNumber(periodNumber); + period.setFromDate(fromDate); + period.setToDate(toDate); + resetPeriodExpectation(period, expectedAmount); + capPeriodToRemainingBalance(period, loan); + return period; } - private DelinquencyMinimumPaymentType resolveMinimumPaymentType(final WorkingCapitalLoanDelinquencyAction rescheduleOverride, - final DelinquencyMinimumPaymentPeriodAndRule rule) { - if (rescheduleOverride != null && rescheduleOverride.getMinimumPaymentType() != null) { - return rescheduleOverride.getMinimumPaymentType(); + private void resetPeriodExpectation(final WorkingCapitalLoanDelinquencyRangeSchedule period, final BigDecimal expectedAmount) { + period.setBaseExpectedAmount(expectedAmount); + period.setExpectedAmount(expectedAmount); + period.setPaidAmount(BigDecimal.ZERO); + period.setOutstandingAmount(expectedAmount); + period.setMinPaymentCriteriaMet(null); + } + + private BigDecimal principalOutstanding(final WorkingCapitalLoan loan) { + final WorkingCapitalLoanBalance balance = loan.getBalance(); + return balance != null ? balance.getPrincipalOutstanding() : null; + } + + private void capPeriodToRemainingBalance(final WorkingCapitalLoanDelinquencyRangeSchedule period, final WorkingCapitalLoan loan) { + final BigDecimal principalOutstanding = principalOutstanding(loan); + if (principalOutstanding != null) { + capPeriodToRemainingBalance(period, principalOutstanding); } - return rule != null && rule.getMinimumPaymentType() != null ? rule.getMinimumPaymentType() - : DelinquencyMinimumPaymentType.PERCENTAGE; } - private Optional findLatestRescheduleAction(final Long loanId) { - return loanDelinquencyActionRepository.findTopByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); + private void applyRemainingBalanceCap(final WorkingCapitalLoan loan) { + final BigDecimal principalOutstanding = principalOutstanding(loan); + if (principalOutstanding == null) { + return; + } + final List cappedPeriods = loanDelinquencyRangeScheduleRepository + .findOpenPeriodsExceedingRemainingBalanceCap(loan.getId(), principalOutstanding); + if (cappedPeriods.isEmpty()) { + return; + } + cappedPeriods.forEach(period -> capPeriodToRemainingBalance(period, principalOutstanding)); + loanDelinquencyRangeScheduleRepository.saveAll(cappedPeriods); } - private WorkingCapitalLoanDelinquencyAction resolveEffectiveRescheduleParams(final Long loanId, - final DelinquencyMinimumPaymentPeriodAndRule rule) { - final WorkingCapitalLoanDelinquencyAction latestReschedule = findLatestRescheduleAction(loanId).orElse(null); - final WorkingCapitalLoanDelinquencyAction effective = new WorkingCapitalLoanDelinquencyAction(); - if (latestReschedule != null && latestReschedule.getMinimumPayment() != null) { - effective.setMinimumPayment(latestReschedule.getMinimumPayment()); - effective.setMinimumPaymentType(latestReschedule.getMinimumPaymentType()); - } else { - effective.setMinimumPayment(rule.getMinimumPayment()); - effective.setMinimumPaymentType(rule.getMinimumPaymentType()); + /** + * Shrinks the period's expected amount to at most what the customer can still owe against it + * ({@code paidAmount + principalOutstanding}). Reaching zero outstanding this way counts as the minimum payment + * criteria being met (the remaining balance is fully paid) - including for a period the repayment cascade skipped + * because the debt ran out before reaching it. + */ + private boolean capPeriodToRemainingBalance(final WorkingCapitalLoanDelinquencyRangeSchedule period, + final BigDecimal principalOutstanding) { + if (Boolean.TRUE.equals(period.getMinPaymentCriteriaMet()) || period.getExpectedAmount() == null) { + return false; } - if (latestReschedule != null && latestReschedule.getFrequency() != null) { - effective.setFrequency(latestReschedule.getFrequency()); - effective.setFrequencyType(latestReschedule.getFrequencyType()); - } else { - effective.setFrequency(rule.getFrequency()); - effective.setFrequencyType(rule.getFrequencyType()); + final BigDecimal paidAmount = MathUtil.nullToZero(period.getPaidAmount()); + final BigDecimal cap = paidAmount.add(principalOutstanding); + if (period.getExpectedAmount().compareTo(cap) <= 0) { + return false; + } + period.setExpectedAmount(cap); + final BigDecimal outstanding = cap.subtract(paidAmount).max(BigDecimal.ZERO); + period.setOutstandingAmount(outstanding); + if (outstanding.signum() == 0) { + period.setMinPaymentCriteriaMet(true); + period.setDelinquentAmount(BigDecimal.ZERO); + period.setDelinquentDays(0L); } - return effective; + return true; + } + + private EffectiveDelinquencyRescheduleParams resolveEffectiveRescheduleParams(final Long loanId, + final DelinquencyMinimumPaymentPeriodAndRule rule) { + final List reschedules = loanDelinquencyActionRepository + .findByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, DelinquencyAction.RESCHEDULE); + final Optional latestWithPayment = reschedules.stream() + .filter(action -> action.getMinimumPayment() != null).findFirst(); + final Optional latestWithFrequency = reschedules.stream() + .filter(action -> action.getFrequency() != null).findFirst(); + + return new EffectiveDelinquencyRescheduleParams( + latestWithPayment.map(WorkingCapitalLoanDelinquencyAction::getMinimumPayment).orElse(rule.getMinimumPayment()), + latestWithPayment.map(WorkingCapitalLoanDelinquencyAction::getMinimumPaymentType) + .or(() -> Optional.ofNullable(rule.getMinimumPaymentType())).orElse(DelinquencyMinimumPaymentType.PERCENTAGE), + latestWithFrequency.map(WorkingCapitalLoanDelinquencyAction::getFrequency).orElse(rule.getFrequency()), + latestWithFrequency.map(WorkingCapitalLoanDelinquencyAction::getFrequencyType).orElse(rule.getFrequencyType())); } private List findAllActions(final Long loanId) { @@ -493,10 +521,7 @@ private void updateFuturePeriods(final WorkingCapitalLoanDelinquencyRangeSchedul period.setPeriodNumber(periodNumber); period.setFromDate(fromDate); period.setToDate(toDate); - period.setExpectedAmount(expectedAmount); - period.setPaidAmount(BigDecimal.ZERO); - period.setOutstandingAmount(expectedAmount); - period.setMinPaymentCriteriaMet(null); + resetPeriodExpectation(period, expectedAmount); fromDate = toDate.plusDays(1); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 885c02ee037..a155d439b1f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -521,6 +521,8 @@ public CommandProcessingResult makeDiscountFee(Long loanId, JsonCommand command) txnExternalId, amount, relatedDisbursementTransaction.getTransactionDate(), classification, paymentDetail); updateBalanceForDiscountChange(loan, amount, false); + // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. + delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); loanRepository.saveAndFlush(loan); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(discountTransaction.getId()) @@ -591,6 +593,8 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); updateBalanceForDiscountChange(loan, amount, true); + // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. + delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); handleStateChanges(loan, transactionDate); triggerInlineAmortizationIfLoanClosed(loan, transactionDate); @@ -692,6 +696,8 @@ private CommandProcessingResult undoDiscountFeeAdjustment(final WorkingCapitalLo amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); updateBalanceForDiscountChange(loan, adjustmentTransaction.getTransactionAmount().negate(), true); + // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. + delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); loanRepository.saveAndFlush(loan); final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); @@ -1068,16 +1074,9 @@ private PaymentDetail createAndPersistPaymentDetailFromCommand(final JsonCommand } private void updateBalanceOnDisburse(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount) { - WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()).orElse(null); - if (balance == null) { - balance = WorkingCapitalLoanBalance.createFor(loan); - } - final BigDecimal discount = loan.getLoanProductRelatedDetails() != null && loan.getLoanProductRelatedDetails().getDiscount() != null - ? loan.getLoanProductRelatedDetails().getDiscount() - : BigDecimal.ZERO; - balance.setTotalDiscountFee(discount); - balance.setPrincipal(disbursedAmount.add(discount)); - balance.setOverpaymentAmount(BigDecimal.ZERO); + final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) + .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); + balance.applyDisbursement(disbursedAmount); this.balanceRepository.saveAndFlush(balance); } diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 553e9d265a0..3960d225792 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -72,4 +72,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0051_wc_delinquency_range_schedule_base_expected_amount.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0051_wc_delinquency_range_schedule_base_expected_amount.xml new file mode 100644 index 00000000000..301c3b3c553 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0051_wc_delinquency_range_schedule_base_expected_amount.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + base_expected_amount IS NULL + + + + From de17f96326f7dda04476d2b66b3536ea0b15f9e3 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Wed, 15 Jul 2026 11:45:21 +0200 Subject: [PATCH 2/2] FINERACT-2455: added e2e test for validation of WC - Inherit partial reschedule params from prior actions and cap delinquency minimum payment at remaining balance --- .../WorkingCapitalBreachReschedule.feature | 1 + ...orkingCapitalDelinquencyReschedule.feature | 195 ++++++++++++++++++ 2 files changed, 196 insertions(+) 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 af573699f3a..67e44a96aba 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -323,6 +323,7 @@ Feature: Working Capital Breach Reschedule Action | 4 | 2019-05-31 | 2019-06-29 | 30 | 500.00 | 500.00 | null | null | Then Admin closes the Working Capital loan with a full repayment on "15 June 2019" + @TestRailId:C85588 Scenario: Verify breach reschedule inherits each partial group from the latest action that set it When Admin sets the business date to "01 January 2026" And Admin creates a client with random data 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 8f18dd0f993..a6ea1e9954c 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature @@ -1064,6 +1064,7 @@ Feature: Working Capital Delinquency Reschedule Action | 10 | 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 Scenario: Verify that three partial reschedules inherit each parameter group from the latest action that set it When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1445,6 +1446,7 @@ Feature: Working Capital Delinquency Reschedule Action | 4 | 01 April 2026 | 30 April 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "01 April 2026" + @TestRailId:C85575 Scenario: Verify that a delinquency period expects only the remaining balance when it is below the minimum payment When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1470,6 +1472,7 @@ Feature: Working Capital Delinquency Reschedule Action | 1 | 01 January 2026 | 30 January 2026 | 200 | 700 | 0 | true | | 2 | 31 January 2026 | 01 March 2026 | 100 | 100 | 0 | true | + @TestRailId:C85576 Scenario: Verify that a reschedule-based minimum payment is capped at the remaining balance When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1497,6 +1500,7 @@ Feature: Working Capital Delinquency Reschedule Action | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | | Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + @TestRailId:C85577 Scenario: Verify that undoing a repayment restores the uncapped minimum payment expectation When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1523,6 +1527,7 @@ Feature: Working Capital Delinquency Reschedule Action | 2 | 31 January 2026 | 01 March 2026 | 200 | 0 | 200 | | Then Admin closes the Working Capital loan with a full repayment on "28 February 2026" + @TestRailId:C85578 Scenario: Verify that a past-due period beyond the remaining balance is met once the balance is fully paid When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1554,6 +1559,7 @@ Feature: Working Capital Delinquency Reschedule Action | 5 | 01 May 2026 | 30 May 2026 | 0 | 0 | 0 | true | | 6 | 31 May 2026 | 29 June 2026 | 0 | 0 | 0 | true | + @TestRailId:C85579 Scenario: Verify that the remaining balance cap on a discounted loan uses the principal plus discount base When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1579,6 +1585,7 @@ Feature: Working Capital Delinquency Reschedule Action | 1 | 01 January 2026 | 30 January 2026 | 225 | 750 | 0 | true | | 2 | 31 January 2026 | 01 March 2026 | 150 | 150 | 0 | true | + @TestRailId:C85580 Scenario: Verify that a backdated repayment reprocess recalculates the remaining balance cap When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1608,6 +1615,7 @@ Feature: Working Capital Delinquency Reschedule Action | 1 | 01 January 2026 | 30 January 2026 | 200 | 700 | 0 | true | | 2 | 31 January 2026 | 01 March 2026 | 100 | 100 | 0 | true | + @TestRailId:C85581 Scenario: Verify that adding a discount after disbursement raises the remaining balance cap When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1634,6 +1642,7 @@ Feature: Working Capital Delinquency Reschedule Action | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | | 1 | 01 January 2026 | 30 January 2026 | 900 | 900 | 0 | true | + @TestRailId:C85582 Scenario: Verify that a discount adjustment and its undo re-derive the remaining balance cap When Admin sets the business date to "01 January 2026" When Admin creates a client with random data @@ -1669,3 +1678,189 @@ Feature: Working Capital Delinquency Reschedule Action | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | | 1 | 01 January 2026 | 30 January 2026 | 225 | 700 | 0 | true | | 2 | 31 January 2026 | 01 March 2026 | 200 | 200 | 0 | true | + + @TestRailId:C85583 + Scenario: Verify partial reschedules inherit the payment group across a minimum payment type switch + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 March 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 33.33 | PERCENTAGE | + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | expectedAmount | minPaymentCriteriaMet | + | 3 | 266.64 | | + When Admin sets the business date to "16 March 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 150 | FLAT | + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | expectedAmount | minPaymentCriteriaMet | + | 3 | 150 | | + 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 + When Admin creates WC delinquency reschedule action with the following parameters: + | frequency | frequencyType | + | 15 | 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 | + 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 | 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 | | + Then Admin closes the Working Capital loan with a full repayment on "17 April 2026" + + @TestRailId:C85584 + Scenario: Verify a delinquency tag is lifted when the remaining balance cap marks skipped periods as met + When Admin sets the business date to "01 January 2026" + When Admin creates a client with random data + When Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "31 January 2026" + And Admin runs inline COB job for Working Capital Loan + # Period 1 (expected 3 = 3 percent of 100) is 1 day past due - tagged D00 + Then Delinquency Tag History for Working Capital loan has lines: + | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + | 1 | 2026-01-31 | | D00 | 1 | 30 | + When Admin sets the business date to "15 February 2026" + And Customer makes repayment on "15 February 2026" with 1 transaction amount on Working Capital loan + # The backdated repayment fully pays the loan; reprocess caps period 2 to the zero remaining + # balance, marks it met, and the classification lifts the D00 tag from period 1 + And Customer makes repayment on "10 January 2026" with 99 transaction amount on Working Capital loan + Then Delinquency Tag History for Working Capital loan has lines: + | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + | 1 | 2026-01-31 | 2026-02-15 | D00 | 1 | 30 | + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 01 January 2026 | 30 January 2026 | 3 | 99 | 0 | true | + | 2 | 31 January 2026 | 01 March 2026 | 1 | 1 | 0 | true | + + @TestRailId:C85585 + Scenario: Verify delinquent amount and days are reported from the capped expectation + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 700 transaction amount on Working Capital loan + When Admin sets the business date to "15 April 2026" + When Admin runs inline COB job for Working Capital Loan + # Remaining balance is 100, so the past-due periods expect (and report as delinquent) 100 - not + # the uncapped 200 minimum payment + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 01 January 2026 | 30 January 2026 | 200 | 700 | 0 | true | 0 | 0 | + | 2 | 31 January 2026 | 01 March 2026 | 100 | 0 | 100 | false | 100 | 45 | + | 3 | 02 March 2026 | 31 March 2026 | 100 | 0 | 100 | false | 100 | 15 | + | 4 | 01 April 2026 | 30 April 2026 | 100 | 0 | 100 | | | | + Then Admin closes the Working Capital loan with a full repayment on "15 April 2026" + Then WC loan delinquency range schedule periods have specific data: + | periodNumber | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | + | 1 | 200 | 700 | 0 | true | + | 2 | 100 | 100 | 0 | true | + | 3 | 0 | 0 | 0 | true | + | 4 | 0 | 0 | 0 | true | + + @TestRailId:C85586 + Scenario: Verify pause-shifted period boundaries survive a partial frequency-only reschedule + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "01 March 2026" + When Admin runs inline COB job for Working Capital Loan + And Admin initiate a Working Capital loan delinquency pause with startDate "01 March 2026" and endDate "15 March 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "20 March 2026" + When Admin runs inline COB job for Working Capital Loan + # Frequency-only reschedule: payment group falls back to the product's 25 percent (no prior + # reschedule); the pause-shifted boundaries of periods 2 and 3 must survive the rebuild + When Admin creates WC delinquency reschedule action with the following parameters: + | frequency | frequencyType | + | 15 | DAYS | + Then WC loan has both PAUSE and RESCHEDULE delinquency actions + Then WC loan delinquency actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | PAUSE | 01 March 2026 | | | | | + | RESCHEDULE | 20 March 2026 | | | 15 | DAYS | + When Admin sets the business date to "20 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 | 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 | | + Then Admin closes the Working Capital loan with a full repayment on "20 April 2026" + + @TestRailId:C85587 + Scenario: Verify the later of two same-day payment-only reschedules wins within the payment group + 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 + 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 | 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 + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "15 March 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 33.33 | PERCENTAGE | + When Admin creates WC delinquency reschedule action with the following parameters: + | minimumPayment | minimumPaymentType | + | 150 | FLAT | + Then WC loan delinquency actions have the following data: + | action | startDate | minimumPayment | minimumPaymentType | frequency | frequencyType | + | RESCHEDULE | 15 March 2026 | 33.33 | PERCENTAGE | | | + | RESCHEDULE | 15 March 2026 | 150 | FLAT | | | + 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 | 150 | 0 | 150 | | + 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 | | + Then Admin closes the Working Capital loan with a full repayment on "17 April 2026"