diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalNearBreachEvaluation.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalNearBreachEvaluation.feature index 576b6a2fb6d..3cea76654f3 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalNearBreachEvaluation.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalNearBreachEvaluation.feature @@ -14,9 +14,8 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # Breach period 1: 01 Jan -> 31 Mar (90 days), minPayment=900 - # Near breach eval date: 01 Jan + 60 = 02 Mar 2026 - # No payment made -> outstanding% = 100% > 33.33% -> near breach + # Period 1: 01-01 -> 03-31, freq=60d -> 1 eval at 03-02 (cumulative required = 33.33% of 900 = 299.97) + # No payment by 03-02 -> cumulative paid=0 < 299.97 -> trigger Y When Admin sets the business date to "03 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -36,11 +35,11 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # Pay 700 before evaluation date -> outstanding = 200, outstanding% = 200/900 = 22.22% < 33.33% + # Period 1: 01-01 -> 03-31, freq=60d -> 1 eval at 03-02 (cumulative required = 299.97) + # Pay 700 on 15 Feb -> cumulative paid by 03-02 = 700 >= 299.97 -> not trigger + # After period end (31 Mar) -> nearBreach=false; outstanding=200>0 -> breach=true When Admin sets the business date to "15 February 2026" - And Admin makes Internal Payment "700.0" on "2026-02-15" - # After eval date (02 Mar), outstanding% = 22.22% which is NOT > 33.33% -> no near breach - # After breach period end (31 Mar), near breach = false + And Customer makes repayment on "15 February 2026" with 700.0 transaction amount on Working Capital loan When Admin sets the business date to "01 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -81,7 +80,7 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # Eval date passes (02 Mar), no payment -> near breach = true + # No payment by 03-02 -> cumulative paid=0 < 299.97 -> trigger Y at eval 03-02 When Admin sets the business date to "03 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -89,7 +88,7 @@ Feature: Working Capital Near Breach Evaluation | 1 | 2026-01-01 | 2026-03-31 | 900.00 | 900.00 | true | null | # Now pay full amount - near breach must stay true (immutable) When Admin sets the business date to "15 March 2026" - And Admin makes Internal Payment "900.0" on "2026-03-15" + And Customer makes repayment on "15 March 2026" with 900.0 transaction amount on Working Capital loan When Admin sets the business date to "01 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -97,36 +96,6 @@ Feature: Working Capital Near Breach Evaluation | 1 | 2026-01-01 | 2026-03-31 | 900.00 | 0.00 | true | false | | 2 | 2026-04-01 | 2026-06-30 | 900.00 | 900.00 | null | null | - @TestRailId:C76639 - Scenario: Verify near breach false when payment keeps outstanding below threshold across all eval points - When Admin sets the business date to "01 January 2026" - And Admin creates a client with random data - And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: - | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | - | 3 | MONTHS | FLAT | 900 | 30 | DAYS | 50 | | - And Admin creates a working capital loan using created product with the following data: - | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | - | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | - And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" - When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount - And Admin runs inline COB job for Working Capital Loan by loanId - # threshold=50%, minPayment=900 - # Pay 500 before first eval -> outstanding=400, outstanding%=44.44% < 50% -> no near breach at any eval - When Admin sets the business date to "20 January 2026" - And Admin makes Internal Payment "500.0" on "2026-01-20" - When Admin sets the business date to "01 February 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 | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-31 | 900.00 | 400.00 | null | null | - # After period end: all eval points passed, none triggered -> nearBreach=false, breach=true (outstanding 400 > 0) - When Admin sets the business date to "01 April 2026" - And Admin runs inline COB job for Working Capital Loan by loanId - Then Working Capital loan breach schedule has the following data: - | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-03-31 | 900.00 | 400.00 | false | true | - | 2 | 2026-04-01 | 2026-06-30 | 900.00 | 900.00 | null | null | - @TestRailId:C76640 Scenario: Verify near breach evaluation before eval date - near breach stays null When Admin sets the business date to "01 January 2026" @@ -140,7 +109,7 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # Eval date = 01 Jan + 60 = 02 Mar 2026. Run COB before that -> near breach stays null + # freq=60d -> 1 eval at 03-02. COB on 01 Mar -> evalDate not yet passed -> nearBreach stays null When Admin sets the business date to "01 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -160,9 +129,9 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # minPayment = 10% of 9000 = 900. Breach period: 01 Jan -> 28 Feb (2 months - 1 day) - # Near breach eval dates: 01 Jan + 2 weeks = 15 Jan, 29 Jan, 12 Feb, 26 Feb - # threshold=50%, required=450. No payment -> outstanding%=100% > 50% -> near breach at first eval (15 Jan) + # Period 1: 01-01 -> 02-28 (2 months -1 day), minPayment=10% of 9000=900 + # freq=2 weeks -> 4 evals: 01-15, 01-29, 02-12, 02-26 (step required = 50% of 900 = 450) + # No payment by eval#1 -> cumulative paid=0 < 450 -> trigger Y at eval#1 When Admin sets the business date to "16 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -182,10 +151,11 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # threshold=50%, minPayment=900 -> boundary = 450 (50% of 900) - # Pay exactly 450 -> outstanding=450, outstanding%=50% = threshold -> NOT > threshold -> no near breach + # freq=60d -> 1 eval at 03-02 (cumulative required = 50% of 900 = 450) + # Pay 450 on 15 Jan -> cumulative paid=450; strict less-than means 450 is NOT below 450 -> not trigger + # After period end -> nearBreach=false; outstanding=450>0 -> breach=true When Admin sets the business date to "15 January 2026" - And Admin makes Internal Payment "450.0" on "2026-01-15" + And Customer makes repayment on "15 January 2026" with 450.0 transaction amount on Working Capital loan When Admin sets the business date to "01 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -206,13 +176,13 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId - # Period 1: 01 Jan -> 31 Jan, minPayment=500, eval date=16 Jan - # No payment in period 1 -> outstanding%=100% > 50% -> nearBreach=true - # Period 2: 01 Feb -> 28 Feb, minPayment=500, eval date=16 Feb + # Period 1: 01-01 -> 01-31, freq=15d -> 1 applicable eval at 01-16; 01-31 is the breach due date and excluded. + # No payment in P1 by eval#1 -> cumulative paid=0 < 250 -> nearBreach=true at eval#1 + # Period 2: 02-01 -> 02-28, 1 eval at 02-16; pay 300 in P2 -> cumulative paid=300 >= 250 -> not trigger # Run COB first so period 2 is generated, then pay 300 in period 2 When Admin sets the business date to "05 February 2026" And Admin runs inline COB job for Working Capital Loan by loanId - And Admin makes Internal Payment "300.0" on "2026-02-05" + And Customer makes repayment on "05 February 2026" with 300.0 transaction amount on Working Capital loan When Admin sets the business date to "01 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -234,6 +204,8 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId + # graceDays=10 -> Period 1: 01-11 -> 04-10; freq=60d -> 1 eval at 03-12 (cumulative required = 33.33% of 900 = 299.97) + # No payment by 03-12 -> cumulative paid=0 < 299.97 -> trigger Y When Admin sets the business date to "13 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -253,6 +225,8 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "500" discount amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "500" discount amount And Admin runs inline COB job for Working Capital Loan by loanId + # minPayment = 10% of (9000 + 500 discount) = 950; freq=30d -> 1 eval at 01-31 (cumulative required = 50% of 950 = 475) + # No payment by 01-31 -> cumulative paid=0 < 475 -> trigger Y When Admin sets the business date to "01 February 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -269,8 +243,8 @@ Feature: Working Capital Near Breach Evaluation | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | | 1 | MONTHS | FLAT | 500 | 29 | DAYS | 50 | | And Admin creates a working capital loan using created product with the following data: - | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | - | 01 February 2026 | 01 February 2026 | 9000 | 100000 | 18 | 0 | + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 February 2026 | 01 February 2026 | 9000 | 100000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 February 2026" with "9000" amount and expected disbursement date on "01 February 2026" When Admin successfully disburse the Working Capital loan on "01 February 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId @@ -283,7 +257,7 @@ Feature: Working Capital Near Breach Evaluation | 3 | 2026-04-01 | 2026-04-30 | 500.00 | 500.00 | null | null | @TestRailId:C76647 - Scenario: Verify near breach eval date exactly on period end - both evaluated in same COB run + Scenario: Verify near breach is not evaluated when eval date falls exactly on breach due date When Admin sets the business date to "01 January 2026" And Admin creates a client with random data And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: @@ -295,11 +269,15 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 02-28 (2 months). freq=58d -> candidate eval at 02-28 == toDate (breach due date). + # Per spec: no near-breach evaluation on breach due date -> eval excluded. Period has zero applicable eval points. + # Close-out at period end: no near-breach detected -> nearBreach=false (last-value contract; never null after period end). + # Breach evaluation at period end: outstanding=500>0 -> breach=true. When Admin sets the business date to "01 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | - | 1 | 2026-01-01 | 2026-02-28 | 500.00 | 500.00 | true | true | + | 1 | 2026-01-01 | 2026-02-28 | 500.00 | 500.00 | false | true | | 2 | 2026-03-01 | 2026-04-30 | 500.00 | 500.00 | null | null | @TestRailId:C76648 @@ -315,12 +293,15 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId + # freq=60d -> 1 eval at 03-02 (cumulative required = 50% of 900 = 450) + # 3 payments by 03-02: 200(10 Jan) + 150(25 Jan) + 200(15 Feb) = 550 >= 450 -> not trigger + # After period end -> nearBreach=false; outstanding=350>0 -> breach=true When Admin sets the business date to "10 January 2026" - And Admin makes Internal Payment "200.0" on "2026-01-10" + And Customer makes repayment on "10 January 2026" with 200.0 transaction amount on Working Capital loan When Admin sets the business date to "25 January 2026" - And Admin makes Internal Payment "150.0" on "2026-01-25" + And Customer makes repayment on "25 January 2026" with 150.0 transaction amount on Working Capital loan When Admin sets the business date to "15 February 2026" - And Admin makes Internal Payment "200.0" on "2026-02-15" + And Customer makes repayment on "15 February 2026" with 200.0 transaction amount on Working Capital loan When Admin sets the business date to "01 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -341,8 +322,11 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId + # freq=60d -> 1 eval at 03-02 (cumulative required = 33.33% of 900 = 299.97) + # Pay 900 on 15 Jan (full) -> cumulative paid by 03-02 = 900 >= 299.97 -> not trigger + # After period end -> nearBreach=false; outstanding=0 -> breach=false (immediate via applyRepayment) When Admin sets the business date to "15 January 2026" - And Admin makes Internal Payment "900.0" on "2026-01-15" + And Customer makes repayment on "15 January 2026" with 900.0 transaction amount on Working Capital loan When Admin sets the business date to "01 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -369,9 +353,9 @@ Feature: Working Capital Near Breach Evaluation | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-01 | 2026-01-31 | 300.00 | 300.00 | true | true | | 2 | 2026-02-01 | 2026-02-28 | 300.00 | 300.00 | null | null | - # --- P2: pay 200, outstanding=100, 33.3% < 50% -> nearBreach=false, breach=true --- + # --- P2: pay 200 -> cumulative paid by eval#1 (02-16) = 200 >= 150 -> nearBreach=false; outstanding=100>0 -> breach=true --- When Admin sets the business date to "05 February 2026" - And Admin makes Internal Payment "200.0" on "2026-02-05" + And Customer makes repayment on "05 February 2026" with 200.0 transaction amount on Working Capital loan When Admin sets the business date to "01 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -379,11 +363,11 @@ Feature: Working Capital Near Breach Evaluation | 1 | 2026-01-01 | 2026-01-31 | 300.00 | 300.00 | true | true | | 2 | 2026-02-01 | 2026-02-28 | 300.00 | 100.00 | false | true | | 3 | 2026-03-01 | 2026-03-31 | 300.00 | 300.00 | null | null | - # --- P3: no payment, 100% > 50% -> nearBreach=true, breach=true --- + # --- P3: no payment by eval#1 (03-16) -> cumulative paid=0 < 150 -> nearBreach=true; breach=true --- When Admin sets the business date to "01 April 2026" And Admin runs inline COB job for Working Capital Loan by loanId - # --- P4: pay full 300, outstanding=0 -> breach=false (immediate), nearBreach=false (after period end) --- - And Admin makes Internal Payment "300.0" on "2026-04-01" + # --- P4: pay 300 on 04-01 -> cumulative paid by eval#1 (04-16) = 300 >= 150 -> nearBreach=false; outstanding=0 -> breach=false (immediate via applyRepayment) --- + And Customer makes repayment on "01 April 2026" with 300.0 transaction amount on Working Capital loan When Admin sets the business date to "01 May 2026" And Admin runs inline COB job for Working Capital Loan by loanId Then Working Capital loan breach schedule has the following data: @@ -407,3 +391,266 @@ Feature: Working Capital Near Breach Evaluation And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" # Loan is approved but NOT disbursed - no breach schedule should exist Then Working Capital loan breach schedule has no data + + @TestRailId:C80947 + Scenario: Verify near breach eval#1 OK, eval#2 fails with cumulative stepped threshold + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | FLAT | 90 | 3 | DAYS | 33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09 (9d), freq=3d -> 2 evals: 01-04, 01-07; step required = 33% of 90 = 29.7, cumulative required = N * 29.7. + # Pay 30 on 02 Jan: cumulative paid by eval#1 (01-04) = 30 >= 1 * 29.7 -> not trigger. + # Pay 10 on 05 Jan: cumulative paid by eval#2 (01-07) = 40 < 2 * 29.7 = 59.4 -> trigger Y at eval#2. + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 30.0 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 10.0 transaction amount on Working Capital loan + When Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 50.00 | true | null | + + @TestRailId:C80948 + Scenario: Verify near breach not triggered when full minimum is paid front-loaded - cumulative requirement satisfied at every eval + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | FLAT | 90 | 3 | DAYS | 50 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09 (9d), freq=3d -> 2 evals: 01-04, 01-07; step required = 50% of 90 = 45, cumulative required = N * 45. + # Pay 90 on 02 Jan (full minimum upfront). + # Eval#1 (01-04): cumulative paid = 90 >= 1 * 45 -> not trigger. + # Eval#2 (01-07): cumulative paid = 90 vs 2 * 45 = 90; strict less-than (90 < 90 is false) -> not trigger. + # After period end (01-10): all evals passed without trigger -> close-out sets nearBreach=false; outstanding=0 -> breach=false. + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 90.0 transaction amount on Working Capital loan + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 0.00 | false | false | + | 2 | 2026-01-10 | 2026-01-18 | 90.00 | 90.00 | null | null | + + @TestRailId:C80949 + Scenario: Verify near breach false when cumulative payments meet stepped requirements across multi-eval period + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | FLAT | 90 | 3 | DAYS | 33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09 (9d), evals at 01-04, 01-07; step required = 33% of 90 = 29.7, cumulative required = N * 29.7. + # Pay 30 on 02 Jan: cumulative paid by eval#1 (01-04) = 30 >= 29.7 -> not trigger. + # Pay 30 on 05 Jan: cumulative paid by eval#2 (01-07) = 60 >= 59.4 -> not trigger. + # After period end (01-10) -> close-out sets nearBreach=false; outstanding=30>0 -> breach=true. + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 30.0 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 30.0 transaction amount on Working Capital loan + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 30.00 | false | true | + | 2 | 2026-01-10 | 2026-01-18 | 90.00 | 90.00 | null | null | + + @TestRailId:C80950 + Scenario: Verify that near breach is detected by cumulative paid falling below stepped requirement across two consecutive breach periods - UC1 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | PERCENTAGE | 50 | 3 | DAYS | 33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09 (9d), freq=3d -> 2 evals: 01-04, 01-07; minPayment = 50% of 800 = 400; step required = 33% of 400 = 132, cumulative required = N * 132. + # Pay 200 on 02 Jan: cumulative paid by eval#1 (01-04) = 200 >= 132 -> not trigger. + # Pay 50 on 05 Jan: cumulative paid by eval#2 (01-07) = 250 < 264 -> trigger Y at eval#2. + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 200.0 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 50.0 transaction amount on Working Capital loan + When Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 400.00 | 150.00 | true | null | + # Advance past P1 end (01-09) and P2 eval#1 (01-13). + # P1 breach: paid=250 < min=400 -> breach=true. P1 nearBreach remains true (immutable). + # P2: 01-10 -> 01-18, 2 evals: 01-13, 01-16. Step required = 132. + # No payment in P2 -> cumulative at eval#1 (01-13) = 0 < 132 -> trigger Y. + When Admin sets the business date to "14 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 400.00 | 150.00 | true | true | + | 2 | 2026-01-10 | 2026-01-18 | 400.00 | 400.00 | true | null | + + @TestRailId:C80951 + Scenario: Verify that near breach evaluation is idempotent across multiple COB runs on the same business date - UC2 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | FLAT | 90 | 3 | DAYS | 33.33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09, freq=3d -> 2 evals: 01-04, 01-07. Step required = 33.33% of 90 = 29.997. + # No payment -> cum at eval#1 (01-04) = 0 < 29.997 -> trigger Y at eval#1. + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 90.00 | true | null | + # Re-run COB on the same business date. State must remain unchanged (immutability gate via nearBreach != null). + 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 | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 90.00 | true | null | + + @TestRailId:C80952 + Scenario: Verify that near breach stays immutable when backdated repayment with transaction date inside window is posted later - UC3 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | FLAT | 90 | 3 | DAYS | 33.33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09, eval#1 at 01-04 (cumulative required = 29.997). No payment -> trigger Y at eval#1. + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 90.00 | true | null | + # Now post a backdated repayment of 50 dated 02 Jan (before eval#1). + # If re-evaluated, paid 50 >= 29.997 would NOT trigger. But nearBreach is immutable -> stays true. + # paidAmount/outstanding update synchronously via applyRepayment. + When Admin sets the business date to "06 January 2026" + And Customer makes repayment on "02 January 2026" with 50.0 transaction amount on Working Capital loan + 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 | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 40.00 | true | null | + + @TestRailId:C80953 + Scenario: Verify that grace days shift breach period start and near breach is evaluated at shifted eval dates - UC4 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | PERCENTAGE | 50 | 3 | DAYS | 33 | 3 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 800 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # Grace=3 -> P1: 01-04 -> 01-12 (9d from 01-04 minus 1 day). minPayment = 50% of 800 = 400. + # near-breach freq=3 -> evals: 01-07 (#1), 01-10 (#2). step required = 33% of 400 = 132. + # Pay 100 on 05 Jan -> cumulative paid by eval#1 is 100. + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 100.0 transaction amount on Working Capital loan + # Phase A: current date 06 Jan (BEFORE eval#1 at 01-07) -> nearBreach=null. + When Admin sets the business date to "06 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-04 | 2026-01-12 | 400.00 | 300.00 | null | null | + # Phase B: advance past eval#1 (01-07) -> cumulative paid by 01-07 = 100 < 132 -> trigger Y at eval#1. + When Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-04 | 2026-01-12 | 400.00 | 300.00 | true | null | + + @TestRailId:C80954 + Scenario: Verify that near breach stays null between evals and is detected when cumulative paid falls short of stepped requirement at a later eval - UC5 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | PERCENTAGE | 10 | 3 | DAYS | 33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 10000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "10000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "10000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # P1: 01-01 -> 01-09 (9d). minPayment = 10% of 10000 = 1000. + # near-breach freq=3 -> eval points: 01-04 (#1), 01-07 (#2); 01-10 lands on/after toDate (01-09) and is excluded. + # Step required = 33% of 1000 = 330. Cumulative required at eval#N = N * 330 (330, 660). + # Pay 400 on 02 Jan -> cumulative at 01-04 = 400 >= 330 -> not trigger. + # No further payment -> cumulative at 01-07 = 400 < 660 -> trigger Y at eval#2. + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 400.0 transaction amount on Working Capital loan + # Phase A: COB on 05 Jan (AFTER eval#1, BEFORE eval#2) -> #1 passed without trigger, #2 not yet evaluated, period not ended -> nearBreach stays null. + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 1000.00 | 600.00 | null | null | + # Phase B: COB on 08 Jan (AFTER eval#2) -> eval#2 triggers (400 < 660) -> nearBreach=true. + When Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 1000.00 | 600.00 | true | null | + + @TestRailId:C80955 + Scenario: Verify credit balance refund does not mutate already evaluated near breach schedule - UC6 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with breach and near breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | nearBreachFrequency | nearBreachFrequencyType | nearBreachThreshold | delinquencyGraceDays | + | 9 | DAYS | FLAT | 90 | 3 | DAYS | 33.33 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + # No payment by eval#1 (01-04) -> nearBreach=true. + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 90.00 | true | null | + # Overpay the loan and post CBR. CBR must not clear or recalculate the already evaluated nearBreach flag. + When Admin sets the business date to "06 January 2026" + And Customer makes repayment on "06 January 2026" with 9500.0 transaction amount on Working Capital loan + And Customer makes credit balance refund on "06 January 2026" with 500.0 transaction amount on Working Capital loan + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-09 | 90.00 | 0.00 | true | false | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature index 11700c7c5eb..9ffa48adfb6 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature @@ -13,6 +13,7 @@ Feature: Working Capital COB Job | WC_DELINQUENCY_RANGE_SCHEDULE | 2 | | WC_LOAN_DELINQUENCY_CLASSIFICATION | 3 | | WC_BREACH_SCHEDULE | 4 | + | WC_NEAR_BREACH_EVALUATION | 5 | Then Admin verifies scheduler job "WC_COB" has display name "Working Capital Loan COB" Then Admin verifies scheduler job "WC_COB" has active status "false" diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/BreachScheduleBusinessStep.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/BreachScheduleBusinessStep.java index c07d8f457a5..92ea9422014 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/BreachScheduleBusinessStep.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/BreachScheduleBusinessStep.java @@ -58,7 +58,7 @@ public WorkingCapitalLoan execute(final WorkingCapitalLoan input) { } breachScheduleService.generateNextPeriodIfNeeded(input, businessDate); - breachScheduleService.evaluateBreachAndNearBreach(input, businessDate.plusDays(1L)); + breachScheduleService.evaluateBreach(input, businessDate.plusDays(1L)); return input; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java new file mode 100644 index 00000000000..83ff84ce9fb --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java @@ -0,0 +1,68 @@ +/** + * 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.cob.workingcapitalloan.businessstep; + +import java.time.LocalDate; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanNearBreachEvaluationService; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class NearBreachEvaluationBusinessStep extends WorkingCapitalLoanCOBBusinessStep { + + private final WorkingCapitalLoanNearBreachEvaluationService nearBreachEvaluationService; + + @Override + public WorkingCapitalLoan execute(final WorkingCapitalLoan input) { + final boolean isDisbursed = input.getDisbursementDetails().stream() + .map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull); + if (!isDisbursed) { + log.debug("Skipping near breach evaluation for WC loan {} - not yet disbursed", input.getId()); + return input; + } + + final WorkingCapitalLoanProductRelatedDetails details = input.getLoanProductRelatedDetails(); + if (details == null || details.getNearBreach() == null) { + log.debug("Skipping near breach evaluation for WC loan {} - no near breach configuration", input.getId()); + return input; + } + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + nearBreachEvaluationService.evaluateNearBreach(input, businessDate); + return input; + } + + @Override + public String getEnumStyledName() { + return "WC_NEAR_BREACH_EVALUATION"; + } + + @Override + public String getHumanReadableName() { + return "WC Near Breach Evaluation"; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java index 59e4c0cb9a3..c88a11acae5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -18,13 +18,19 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.repository; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.List; import java.util.Optional; +import java.util.Set; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface WorkingCapitalLoanTransactionRepository extends JpaRepository { @@ -37,4 +43,15 @@ public interface WorkingCapitalLoanTransactionRepository extends JpaRepository findByWcLoan_IdAndExternalId(Long wcLoanId, ExternalId externalId); boolean existsByExternalId(ExternalId externalId); + + @Query(""" + SELECT COALESCE(SUM(t.transactionAmount), 0) + FROM WorkingCapitalLoanTransaction t + WHERE t.wcLoan.id = :loanId + AND t.transactionDate BETWEEN :fromDate AND :toDate + AND t.reversed = false + AND t.transactionType IN :reducingTypes + """) + BigDecimal sumPaymentsInWindow(@Param("loanId") Long loanId, @Param("fromDate") LocalDate fromDate, @Param("toDate") LocalDate toDate, + @Param("reducingTypes") Set reducingTypes); } 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 53ddf713592..67ebee0740a 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 @@ -39,5 +39,5 @@ public interface WorkingCapitalLoanBreachScheduleService { void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal amount); - void evaluateBreachAndNearBreach(WorkingCapitalLoan loan, LocalDate businessDate); + void evaluateBreach(WorkingCapitalLoan loan, LocalDate businessDate); } 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 07ae120b572..db21ccdf8c2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java @@ -38,7 +38,6 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloanbreach.domain.WorkingCapitalBreach; -import org.apache.fineract.portfolio.workingcapitalloannearbreach.domain.WorkingCapitalNearBreach; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalBreachAmountCalculationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.springframework.stereotype.Service; @@ -148,25 +147,13 @@ private void applyRepayment(final WorkingCapitalLoanBreachSchedule period, BigDe } @Override - public void evaluateBreachAndNearBreach(final WorkingCapitalLoan loan, final LocalDate businessDate) { + public void evaluateBreach(final WorkingCapitalLoan loan, final LocalDate businessDate) { final List allPeriods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); - final Optional nearBreachConfigOpt = getNearBreachConfig(loan); final List updatedPeriods = new ArrayList<>(); for (final WorkingCapitalLoanBreachSchedule period : allPeriods) { - boolean updated = false; - if (period.getBreach() == null && !period.getToDate().isAfter(businessDate)) { evaluateBreachOnDate(period, businessDate); - updated = true; - } - - if (period.getNearBreach() == null && nearBreachConfigOpt.isPresent()) { - final boolean nearBreachEvaluated = evaluateNearBreachForPeriod(period, nearBreachConfigOpt.get(), businessDate); - updated = updated || nearBreachEvaluated; - } - - if (updated) { updatedPeriods.add(period); } } @@ -185,69 +172,6 @@ public List retrieveBreachSchedule(final L return mapper.toDataList(periods); } - private boolean evaluateNearBreachForPeriod(final WorkingCapitalLoanBreachSchedule period, final WorkingCapitalNearBreach config, - final LocalDate businessDate) { - if (period.getMinPaymentAmount().compareTo(BigDecimal.ZERO) == 0) { - return false; - } - - final LocalDate firstEvalDate = findFirstPassedEvalDate(period.getFromDate(), period.getToDate(), config.getFrequency(), - config.getFrequencyType(), businessDate); - - if (firstEvalDate == null) { - return false; - } - - final BigDecimal thresholdFraction = config.getThreshold().divide(BigDecimal.valueOf(100), MoneyHelper.getMathContext()); - final BigDecimal outstandingPercent = period.getOutstandingAmount().divide(period.getMinPaymentAmount(), - MoneyHelper.getMathContext()); - - if (outstandingPercent.compareTo(thresholdFraction) > 0) { - period.setNearBreach(true); - log.debug("Near breach detected for period {} of WC loan {}: outstanding%={}, threshold%={}", period.getPeriodNumber(), - period.getLoan().getId(), outstandingPercent, thresholdFraction); - return true; - } - - if (businessDate.isAfter(period.getToDate())) { - period.setNearBreach(false); - log.debug("No near breach for period {} of WC loan {}", period.getPeriodNumber(), period.getLoan().getId()); - return true; - } - - return false; - } - - private LocalDate findFirstPassedEvalDate(final LocalDate fromDate, final LocalDate toDate, final Integer frequency, - final WorkingCapitalLoanPeriodFrequencyType frequencyType, final LocalDate businessDate) { - LocalDate evalDate = addFrequency(fromDate, frequency, frequencyType); - while (!evalDate.isAfter(toDate)) { - if (businessDate.isAfter(evalDate)) { - return evalDate; - } - evalDate = addFrequency(evalDate, frequency, frequencyType); - } - return null; - } - - private LocalDate addFrequency(final LocalDate date, final Integer frequency, - final WorkingCapitalLoanPeriodFrequencyType frequencyType) { - return switch (frequencyType) { - case DAYS -> date.plusDays(frequency); - case WEEKS -> date.plusWeeks(frequency); - case MONTHS -> date.plusMonths(frequency); - case YEARS -> date.plusYears(frequency); - }; - } - - private Optional getNearBreachConfig(final WorkingCapitalLoan loan) { - final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); - if (details == null) { - return Optional.empty(); - } - return Optional.ofNullable(details.getNearBreach()); - } - private WorkingCapitalLoanBreachSchedule createPeriod(final WorkingCapitalLoan loan, final int periodNumber, final LocalDate fromDate, final LocalDate toDate, final BigDecimal minPaymentAmount) { final int numberOfDays = (int) ChronoUnit.DAYS.between(fromDate, toDate) + 1; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationService.java new file mode 100644 index 00000000000..79c84bb5729 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationService.java @@ -0,0 +1,28 @@ +/** + * 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.time.LocalDate; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public interface WorkingCapitalLoanNearBreachEvaluationService { + + void evaluateNearBreach(WorkingCapitalLoan loan, LocalDate effectiveDate); + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java new file mode 100644 index 00000000000..91766e1a037 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java @@ -0,0 +1,125 @@ +/** + * 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 java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloannearbreach.domain.WorkingCapitalNearBreach; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Slf4j +@Service +public class WorkingCapitalLoanNearBreachEvaluationServiceImpl implements WorkingCapitalLoanNearBreachEvaluationService { + + private static final Set REDUCING_TRANSACTION_TYPES = Set.of(LoanTransactionType.REPAYMENT, + LoanTransactionType.GOODWILL_CREDIT); + + private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + + @Override + public void evaluateNearBreach(final WorkingCapitalLoan loan, final LocalDate effectiveDate) { + final Optional relevantPeriod = breachScheduleRepository + .findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(loan.getId(), effectiveDate, effectiveDate); + if (relevantPeriod.isEmpty()) { + return; + } + final WorkingCapitalLoanBreachSchedule period = relevantPeriod.get(); + if (period.getNearBreach() != null) { + return; + } + final WorkingCapitalNearBreach config = loan.getLoanProductRelatedDetails().getNearBreach(); + if (evaluatePeriod(loan.getId(), period, config, effectiveDate)) { + breachScheduleRepository.saveAndFlush(period); + } + } + + private boolean evaluatePeriod(final Long loanId, final WorkingCapitalLoanBreachSchedule period, final WorkingCapitalNearBreach config, + final LocalDate effectiveDate) { + if (period.getMinPaymentAmount().compareTo(BigDecimal.ZERO) == 0) { + return false; + } + final LocalDate firstEvalDate = addFrequency(period.getFromDate(), config.getFrequency(), config.getFrequencyType()); + if (firstEvalDate.isAfter(period.getToDate())) { + return false; + } + final List evalDates = listEvalDates(period.getFromDate(), period.getToDate(), config.getFrequency(), + config.getFrequencyType()); + final BigDecimal thresholdFraction = config.getThreshold().divide(BigDecimal.valueOf(100), MoneyHelper.getMathContext()); + for (int index = 0; index < evalDates.size(); index++) { + final LocalDate evalDate = evalDates.get(index); + if (evalDate.isAfter(effectiveDate)) { + break; + } + final BigDecimal requiredCumulative = thresholdFraction.multiply(BigDecimal.valueOf(index + 1L), MoneyHelper.getMathContext()) + .multiply(period.getMinPaymentAmount(), MoneyHelper.getMathContext()); + final BigDecimal paidCumulative = transactionRepository.sumPaymentsInWindow(loanId, period.getFromDate(), evalDate, + REDUCING_TRANSACTION_TYPES); + if (paidCumulative.compareTo(requiredCumulative) < 0) { + period.setNearBreach(true); + log.debug("Near breach detected for period {} of WC loan {}: evalDate={} cumulativePaid={} requiredCumulative={}", + period.getPeriodNumber(), loanId, evalDate, paidCumulative, requiredCumulative); + return true; + } + } + if (!effectiveDate.isBefore(period.getToDate())) { + period.setNearBreach(false); + log.debug("No near breach for period {} of WC loan {} after all evaluation points", period.getPeriodNumber(), loanId); + return true; + } + return false; + } + + private List listEvalDates(final LocalDate fromDate, final LocalDate toDate, final Integer frequency, + final WorkingCapitalLoanPeriodFrequencyType frequencyType) { + final List dates = new ArrayList<>(); + for (int multiplicator = 1;; multiplicator++) { + final LocalDate evalDate = addFrequency(fromDate, frequency * multiplicator, frequencyType); + if (!evalDate.isBefore(toDate)) { + break; + } + dates.add(evalDate); + } + return dates; + } + + private LocalDate addFrequency(final LocalDate date, final int amount, final WorkingCapitalLoanPeriodFrequencyType frequencyType) { + return switch (frequencyType) { + case DAYS -> date.plusDays(amount); + case WEEKS -> date.plusWeeks(amount); + case MONTHS -> date.plusMonths(amount); + case YEARS -> date.plusYears(amount); + }; + } + +} 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 eea4716412a..4d42188f366 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 @@ -58,4 +58,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0037_wc_near_breach_evaluation.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0037_wc_near_breach_evaluation.xml new file mode 100644 index 00000000000..2aa943fe50e --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0037_wc_near_breach_evaluation.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + +