diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature index 21a6bd63b5d..d2f147e39a1 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature @@ -537,7 +537,7 @@ Feature: Working Capital Discount And Admin successfully undo Working Capital disbursal by externalId And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 0.0 | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 0.0 | null | null | And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | true | @@ -573,7 +573,7 @@ Feature: Working Capital Discount And Admin successfully undo Working Capital disbursal by externalId And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 22.0 | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 22.0 | null | null | And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | true | @@ -639,13 +639,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 18.0 | 17.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 18.0 | 17.0 | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 18.0 | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 18.0 | null | null | Then Approving the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" with "19" discount amount results an error with the following data: | HTTP response code | Error message | | 400 | amount.cannot.exceed.created.discount | @@ -721,13 +721,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 18.0 | 17.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 18.0 | 17.0 | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 18.0 | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 18.0 | null | null | Then Approving the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" with "19" discount amount results an error with the following data: | HTTP response code | Error message | | 400 | amount.cannot.exceed.created.discount | @@ -788,12 +788,12 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | 17.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | 17.0 | null | When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and "19" discount amount and expected disbursement date on "01 January 2026" Then Working capital loan approval was successful And Working capital loan account has the correct data: @@ -812,7 +812,7 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | 19.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | 19.0 | null | @TestRailId:C78860 Scenario: Discount on Working Capital Loan account added while approve, updated on disbursal and check after updo disbursal/approval with additional update while modify and check after undo disbursal - UC4.1 @@ -842,18 +842,18 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | 17.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | 17.0 | null | When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | When Admin modifies the working capital loan with the following data: | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | | | | | | | 20.0 | And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 20.0 | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 20.0 | null | null | Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and "20" discount amount and expected disbursement date on "01 January 2026" Then Working capital loan approval was successful And Working capital loan account has the correct data: @@ -872,7 +872,7 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 20.0 | 20.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 20.0 | 20.0 | null | @TestRailId:C78861 Scenario: Discount on Working Capital Loan account added while update discount and check after undo disbursal/approval - UC5 @@ -905,13 +905,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | null | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | @TestRailId:C78862 Scenario: Discount on Working Capital Loan account added while create loan, updated on disbursal and check after undo disbursal with additional disburse - UC6 @@ -939,7 +939,7 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 18.0 | 18.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 18.0 | 18.0 | null | Then Disbursing the working capital loan on "01 January 2026" with "100" amount and "19" discount amount results an error with the following data: | HTTP response code | Error message | | 400 | amount.cannot.exceed.approved.discount | @@ -982,13 +982,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | null | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | @TestRailId:C78864 Scenario: Discount on Working Capital Loan account added after disbursement with discount set on loan product level and WCLP overrides allowed after undo approval/disbursal - UC8 @@ -1025,13 +1025,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | null | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | @TestRailId:C78865 Scenario: Discount on Working Capital Loan account added on create loan account with modified discount amount while discount set on loan product level and WCLP overrides allowed after undo disbursal/approve - UC9.1 @@ -1069,20 +1069,20 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 48.0 | 40.0 | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 48.0 | 40.0 | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 48.0 | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 48.0 | null | null | # --- modify working capital loan account --- # When Admin modifies the working capital loan with the following data: | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | | | | | | | 49.0 | And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 49.0 | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 49.0 | null | null | Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" Then Working capital loan approval was successful And Working capital loan account has the correct data: @@ -1093,7 +1093,7 @@ Feature: Working Capital Discount Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 49.0 | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 49.0 | null | null | @TestRailId:C78866 Scenario: Discount on Working Capital Loan account added on modify loan account with modified discount amount with discount set on loan product level and WCLP overrides allowed after undo disbursal/approve - UC9.2 @@ -1137,12 +1137,12 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 30.0 | 20.0 | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 30.0 | 20.0 | null | When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 30.0 | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 30.0 | null | null | @TestRailId:C78867 Scenario: Discount on Working Capital Loan account added on Approve with modified discount amount with discount set on loan product level and WCLP overrides allowed after undo disbursal/approve - UC9 @@ -1180,13 +1180,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | 40.0 | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | 40.0 | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | @TestRailId:C78868 Scenario: Discount on Working Capital Loan account added on Disburse with modified discount amount while discount is set on loan product level with WCLP overrides allowed after undo disbursal/approval - UC10 @@ -1224,13 +1224,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | null | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISCOUNT | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | @TestRailId:C78869 Scenario: Discount on Working Capital Loan account added after disbursement while discount is set on loan product level with WCLP overrides disallowed after undo disbursal/approval - UC6 @@ -1262,13 +1262,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 50.0 | 50.0 | null | + | WCLP_DISCOUNT_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | 50.0 | 50.0 | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISCOUNT_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 50.0 | null | null | + | WCLP_DISCOUNT_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | 50.0 | null | null | @TestRailId:C85503 Scenario: Discount on Working Capital Loan account added after disbursement while discount is NOT set on loan product level with WCLP overrides disallowed after undo disbursal/approval - UC7 @@ -1300,13 +1300,13 @@ Feature: Working Capital Discount Then Admin successfully undo Working Capital disbursal And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | null | null | # --- undo working capital approval --- # When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | null | null | null | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 0.0 | 0.0 | 100.0 | 1.0 | null | null | null | @TestRailId:C74518 Scenario: Verify that undo disbursal of WCL account set discount to null - UC5.2 @@ -1337,7 +1337,7 @@ Feature: Working Capital Discount Then Working Capital loan status will be "APPROVED" And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountProposed | discountApproved | discount | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | null | 14.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 0.0 | 100.0 | 100.0 | 1.0 | null | 14.0 | null | And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | true | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature index 52e03d3b7b9..c62bb9a2338 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature @@ -82,7 +82,7 @@ Feature: Working Capital Loan Details | balance.principal | 100.0 | | balance.principalPaid | 0.0 | | balance.principalOutstanding | 100.0 | - | balance.totalDisbursement | 0.0 | + | balance.totalDisbursement | 100.0 | | balance.totalRepayment | 0.0 | | balance.totalOutstanding | 100.0 | | balance.totalExpectedRepayment | 100.0 | @@ -100,7 +100,7 @@ Feature: Working Capital Loan Details | balance.totalDiscountFeeAdjustment | 0.0 | | summary.principal | 100.0 | | summary.principalOutstanding | 100.0 | - | summary.totalDisbursement | 0.0 | + | summary.totalDisbursement | 100.0 | | summary.totalOutstanding | 100.0 | | summary.currency.code | EUR | | summary.currency.name | Euro | @@ -247,3 +247,139 @@ Feature: Working Capital Loan Details | nearBreach.frequencyType.code | WEEKS | | nearBreach.frequencyType.value | WEEKS | | nearBreach.threshold | present | + + @TestRailId:C98207 + Scenario: Verify loan details returns the active principal following the reduced approve and disburse amounts + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 500.0 | 500.0 | 1.0 | 100.0 | + Then Working capital loan creation was successful + Then Working capital loan details has the following field values: + | status.value | Submitted and pending approval | + | proposedPrincipal | 500.0 | + | approvedPrincipal | 0.0 | + | principal | 500.0 | + | netDisbursalAmount | null | + | balance.principal | 0.0 | + | balance.totalDisbursement | 0.0 | + | summary.principalOutstanding | 0.0 | + | summary.totalDisbursement | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "450" amount and "45" discount amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + Then Working capital loan details has the following field values: + | status.value | Approved | + | proposedPrincipal | 500.0 | + | approvedPrincipal | 450.0 | + | principal | 450.0 | + | approvedDiscountFee | 45.0 | + | discountFee | null | + | netDisbursalAmount | 450.0 | + | balance.principal | 0.0 | + | balance.totalDisbursement | 0.0 | + | summary.totalDisbursement | 0.0 | + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "250" EUR transaction amount and "25" discount amount + Then Verify Working Capital loan disbursement was successful + Then Working capital loan details has the following field values: + | status.value | Active | + | proposedPrincipal | 500.0 | + | approvedPrincipal | 450.0 | + | principal | 250.0 | + | discountFee | 25.0 | + | netDisbursalAmount | 250.0 | + | balance.principal | 275.0 | + | balance.totalDiscountFee | 25.0 | + | balance.totalDisbursement | 250.0 | + | summary.principal | 275.0 | + | summary.principalOutstanding | 275.0 | + | summary.totalDisbursement | 250.0 | + Then Customer makes repayment on "01 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working capital loan details has the following field values: + | principal | 250.0 | + | balance.principal | 275.0 | + | balance.principalPaid | 100.0 | + | balance.totalDisbursement | 250.0 | + | summary.principalOutstanding | 175.0 | + + @TestRailId:C98208 + Scenario: Verify loan details returns the modified principal as both proposed and active principal + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 500.0 | 500.0 | 1.0 | 100.0 | + Then Working capital loan creation was successful + Then Working capital loan details has the following field values: + | proposedPrincipal | 500.0 | + | principal | 500.0 | + | disbursementDetails.0.principal | 500.0 | + When Admin modifies the working capital loan with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | | | 400.0 | | | | + Then Working capital loan details has the following field values: + | status.value | Submitted and pending approval | + | proposedPrincipal | 400.0 | + | approvedPrincipal | 0.0 | + | principal | 400.0 | + | disbursementDetails.0.principal | 400.0 | + + @TestRailId:C98209 + Scenario: Verify active principal and balances after undo disbursal, undo approval and re-disbursement + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 500.0 | 500.0 | 1.0 | 100.0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "450" amount and "45" discount amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "250" EUR transaction amount and "25" discount amount + Then Verify Working Capital loan disbursement was successful + Then Admin successfully undo Working Capital disbursal + Then Working capital loan details has the following field values: + | status.value | Approved | + | proposedPrincipal | 500.0 | + | approvedPrincipal | 450.0 | + | principal | 450.0 | + | discountFee | null | + | netDisbursalAmount | 450.0 | + | balance.principal | 0.0 | + | balance.principalPaid | 0.0 | + | balance.principalOutstanding | 0.0 | + | balance.totalOutstanding | 0.0 | + | balance.totalExpectedRepayment | 0.0 | + | balance.totalDisbursement | 0.0 | + | balance.totalDiscountFee | 0.0 | + | balance.unrealizedIncomeFromDiscountFee | 0.0 | + | summary.principal | 0.0 | + | summary.principalOutstanding | 0.0 | + | summary.totalDisbursement | 0.0 | + | summary.totalDiscountFee | 0.0 | + When Admin makes undo approval on the working capital loan + Then Working capital loan undo approval was successful + Then Working capital loan details has the following field values: + | status.value | Submitted and pending approval | + | proposedPrincipal | 500.0 | + | approvedPrincipal | 0.0 | + | principal | 500.0 | + | approvedDiscountFee | null | + When Admin successfully approves the working capital loan on "01 January 2026" with "450" amount and "45" discount amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "300" EUR transaction amount and "30" discount amount + Then Verify Working Capital loan disbursement was successful + Then Working capital loan details has the following field values: + | status.value | Active | + | proposedPrincipal | 500.0 | + | approvedPrincipal | 450.0 | + | principal | 300.0 | + | discountFee | 30.0 | + | netDisbursalAmount | 300.0 | + | disbursementDetails.0.principal | 450.0 | + | disbursementDetails.0.actualAmount | 300.0 | + | balance.principal | 330.0 | + | balance.principalOutstanding | 330.0 | + | balance.totalDisbursement | 300.0 | + | balance.totalDiscountFee | 30.0 | + | summary.principalOutstanding | 330.0 | + | summary.totalDisbursement | 300.0 | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index ce5daaa5a85..794df8c6108 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -205,9 +205,15 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public String loanProductDescription; public GetWorkingCapitalLoansLoanIdStatus status; public GetWorkingCapitalLoansLoanIdTimeline timeline; + @Schema(example = "10000.00", description = "Principal requested at submission; never changes afterwards") public BigDecimal proposedPrincipal; + @Schema(example = "10000.00", description = "Principal granted at approval; zero before approval and after undoing it " + + "(deliberate Working Capital divergence from classic loans)") public BigDecimal approvedPrincipal; - @Schema(example = "10000.00", description = "Active principal (loanProductRelatedDetails.principal)") + @Schema(example = "10000.00", description = "Active principal: the requested amount while the application is " + + "pending, the granted amount from approval, the actually disbursed amount from disbursement. Undoing " + + "approval or disbursal restores the previous stage's value. This is the contractual principal, not " + + "the outstanding balance - see summary.principalOutstanding for that.") public BigDecimal principal; @Schema(example = "10000.00", description = "Net disbursal amount from the amortization schedule; null if schedule not yet generated") public BigDecimal netDisbursalAmount; @@ -334,6 +340,7 @@ private GetWorkingCapitalLoanSummary() {} public BigDecimal realizedIncomeFromDiscountFee; public BigDecimal unrealizedIncomeFromDiscountFee; public BigDecimal overpayment; + @Schema(description = "Total amount actually disbursed") public BigDecimal totalDisbursement; public BigDecimal totalDiscountFee; public BigDecimal totalDiscountFeeAdjustment; @@ -389,7 +396,8 @@ private GetBalance() {} @Schema(example = "1") public Long id; - @Schema(example = "10000.00") + @Schema(example = "10000.00", description = "Total repayable principal: the disbursed amount plus the discount fee. " + + "Zero until disbursement, and zero again once a disbursal is undone.") public BigDecimal principal; @Schema(example = "10000.00") public BigDecimal principalPaid; @@ -421,7 +429,7 @@ private GetBalance() {} public BigDecimal totalRepayment; @Schema(example = "10000.00") public BigDecimal totalOutstanding; - @Schema(example = "10000.00") + @Schema(example = "10000.00", description = "Total amount actually disbursed") public BigDecimal totalDisbursement; @Schema(example = "10000.00") public BigDecimal totalDiscountFee; @@ -441,7 +449,8 @@ private GetDisbursementDetail() {} @Schema(example = "1") public Long loanId; public LocalDate expectedDisbursementDate; - @Schema(example = "10000.00", description = "Expected (planned) disbursement principal") + @Schema(example = "10000.00", description = "Expected (planned) disbursement principal. Tracks the active " + + "principal, so modifying a pending application or undoing an approval moves it too.") public BigDecimal principal; public LocalDate expectedMaturityDate; public LocalDate actualDisbursementDate; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java index ddcfcf2f321..f4ce3563e20 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -145,10 +145,16 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom updateFrom(final JsonCommand command, final WorkingCa changes.put(WorkingCapitalLoanConstants.externalIdParameterName, externalIdStr); } } - final BigDecimal currentPrincipal = loan.getBalance() != null ? loan.getBalance().getPrincipalOutstanding() : null; - if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanConstants.principalAmountParamName, currentPrincipal)) { + final WorkingCapitalLoanProductRelatedDetails detail = loan.getLoanProductRelatedDetails(); + if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanConstants.principalAmountParamName, loan.getProposedPrincipal())) { final BigDecimal principal = fromApiJsonHelper .extractBigDecimalWithLocaleNamed(WorkingCapitalLoanConstants.principalAmountParamName, element); loan.setProposedPrincipal(principal); loan.setApprovedPrincipal(BigDecimal.ZERO); + if (detail != null) { + detail.setPrincipal(principal); + } + // The expected disbursement amount follows the proposed principal until approval overrides it. + if (!loan.getDisbursementDetails().isEmpty()) { + loan.getDisbursementDetails().getFirst().setExpectedAmount(principal); + } changes.put(WorkingCapitalLoanConstants.principalAmountParamName, principal); } final BigDecimal currenttotalPaymentVolumeVolume = loan.getTotalPaymentVolume(); @@ -353,15 +360,15 @@ public Map updateFrom(final JsonCommand command, final WorkingCa if (!loan.getDisbursementDetails().isEmpty()) { loan.getDisbursementDetails().getFirst().setExpectedDisbursementDate(expectedDisbursementDate); } else if (expectedDisbursementDate != null) { - final WorkingCapitalLoanDisbursementDetails detail = new WorkingCapitalLoanDisbursementDetails(); - detail.setWcLoan(loan); - detail.setExpectedDisbursementDate(expectedDisbursementDate); - loan.getDisbursementDetails().add(detail); + final WorkingCapitalLoanDisbursementDetails disbursementDetail = new WorkingCapitalLoanDisbursementDetails(); + disbursementDetail.setWcLoan(loan); + disbursementDetail.setExpectedDisbursementDate(expectedDisbursementDate); + disbursementDetail.setExpectedAmount(loan.getProposedPrincipal()); + loan.getDisbursementDetails().add(disbursementDetail); } changes.put(WorkingCapitalLoanConstants.expectedDisbursementDateParameterName, expectedDisbursementDate); } - final WorkingCapitalLoanProductRelatedDetails detail = loan.getLoanProductRelatedDetails(); if (detail != null) { if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element) && command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, @@ -390,8 +397,9 @@ public Map updateFrom(final JsonCommand command, final WorkingCa if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.discountParamName, element)) { final BigDecimal discount = fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.discountParamName, element, new HashSet<>()); + // Compare against the proposed discount: the effective one is only set at disbursement. if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.discountParamName, - detail.getDiscount())) { + detail.getDiscountProposed())) { detail.setDiscountProposed(discount); changes.put(WorkingCapitalLoanProductConstants.discountParamName, discount); } 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 156fce46652..4c8ce496d92 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 @@ -180,6 +180,9 @@ public CommandProcessingResult approveApplication(final Long loanId, final JsonC loan.getDisbursementDetails().getFirst().setExpectedAmount(loan.getApprovedPrincipal()); } + // The active contractual principal follows the approved amount from approval onwards. + loan.getLoanProductRelatedDetails().setPrincipal(loan.getApprovedPrincipal()); + this.loanRepository.saveAndFlush(loan); this.amortizationScheduleWriteService.generateAndSaveAmortizationScheduleOnApproval(loan); @@ -227,6 +230,14 @@ public CommandProcessingResult undoApplicationApproval(final Long loanId, final // The loan is back in SUBMITTED state and can be modified. loan.getLoanProductRelatedDetails().setDiscountApproved(null); + // The active contractual principal falls back to the proposed one while the loan is unapproved again. + loan.getLoanProductRelatedDetails().setPrincipal(loan.getProposedPrincipal()); + + // The expected disbursement amount follows it back, undoing the alignment done at approval. + if (!loan.getDisbursementDetails().isEmpty()) { + loan.getDisbursementDetails().getFirst().setExpectedAmount(loan.getProposedPrincipal()); + } + this.loanRepository.saveAndFlush(loan); createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName), loan); @@ -335,6 +346,9 @@ public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand loan.getDisbursementDetails().getFirst().setDisbursedBy(currentUser); } + // The active contractual principal becomes the amount actually disbursed. + loan.getLoanProductRelatedDetails().setPrincipal(transactionAmount); + // Discount amount (optional, can only be reduced per requirement) BigDecimal discount = null; if (!loan.getLoanProduct().getConfigurableAttributes().isDiscountDefaultOverridable()) { @@ -431,6 +445,8 @@ public CommandProcessingResult undoDisbursal(final Long loanId, final JsonComman } } loan.getLoanProductRelatedDetails().setDiscount(null); + // Nothing is disbursed any more, so the active contractual principal falls back to the approved one. + loan.getLoanProductRelatedDetails().setPrincipal(loan.getApprovedPrincipal()); amortizationScheduleWriteService.regenerateAmortizationScheduleOnUndoDisbursal(loan); this.loanRepository.saveAndFlush(loan); @@ -1227,9 +1243,14 @@ private WorkingCapitalLoanTransaction reverseDisbursementTransactionAndResetBala // recalculateRealizedIncome writes to, so all updates here apply to the same object that gets persisted. final WorkingCapitalLoanBalance balance = loan.getBalance(); if (balance != null) { - // Restore balance to pre-disbursement state. - balance.setPrincipal(loan.getApprovedPrincipal() != null ? loan.getApprovedPrincipal() : loan.getProposedPrincipal()); + // Restore balance to pre-disbursement state: nothing was paid out any more, so there is nothing + // repayable, exactly as on a loan that was approved but never disbursed. + balance.setPrincipal(BigDecimal.ZERO); + balance.setPrincipalAdjustment(BigDecimal.ZERO); balance.setPrincipalPaid(BigDecimal.ZERO); + balance.setTotalDisbursement(BigDecimal.ZERO); + balance.setTotalDiscountFee(BigDecimal.ZERO); + balance.setTotalDiscountFeeAdjustment(BigDecimal.ZERO); // All transactions were just reversed, so the single owner recomputes realized income to zero from them. discountFeeAmortizationService.recalculateRealizedIncome(loan); balance.setOverpaymentAmount(BigDecimal.ZERO); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java index 0e9c3ac3014..17f8321714a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java @@ -48,6 +48,13 @@ public class WorkingCapitalLoanProductRelatedDetails { @Embedded private MonetaryCurrency currency; + /** + * Active contractual principal. On a loan it tracks the lifecycle: the proposed amount while the application is + * submitted or modified, the approved amount from approval, and the amount actually disbursed from disbursement. + * Undoing approval or disbursal restores the previous stage's value. It is not the outstanding balance - that lives + * in {@code m_wc_loan_balance} and is published as {@code summary.principalOutstanding}. On a product this is the + * default principal offered to new applications. + */ @Column(name = "principal_amount", scale = 6, precision = 19) private BigDecimal principal; 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 e9ff8b41152..71b13775f6a 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 @@ -91,4 +91,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_principal_balance_backfill.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_principal_balance_backfill.xml new file mode 100644 index 00000000000..e7da5c5e084 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0070_wc_loan_principal_balance_backfill.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + UPDATE m_wc_loan + SET principal_amount = CASE + WHEN EXISTS (SELECT 1 FROM m_wc_loan_transaction t + WHERE t.wc_loan_id = m_wc_loan.id AND t.transaction_type_id = 1 AND t.is_reversed = FALSE) + THEN (SELECT SUM(t.transaction_amount) FROM m_wc_loan_transaction t + WHERE t.wc_loan_id = m_wc_loan.id AND t.transaction_type_id = 1 AND t.is_reversed = FALSE) + WHEN m_wc_loan.loan_status_id = 200 THEN m_wc_loan.approved_principal + ELSE m_wc_loan.principal_amount_proposed + END + + + + + + + + + + + UPDATE m_wc_loan_balance + SET total_disbursement = COALESCE((SELECT SUM(t.transaction_amount) FROM m_wc_loan_transaction t + WHERE t.wc_loan_id = m_wc_loan_balance.wc_loan_id + AND t.transaction_type_id = 1 AND t.is_reversed = FALSE), 0) + + + + + + + + + + + UPDATE m_wc_loan_balance + SET principal = 0, + principal_adjustment = 0, + principal_paid = 0, + total_discount_fee = 0, + total_discount_fee_adjustment = 0, + realized_income_from_discount_fee = 0, + overpayment_amount = 0 + WHERE NOT EXISTS (SELECT 1 FROM m_wc_loan_transaction t + WHERE t.wc_loan_id = m_wc_loan_balance.wc_loan_id + AND t.transaction_type_id = 1 AND t.is_reversed = FALSE) + + + + + + + + + + + UPDATE m_wc_loan_disbursement_detail + SET expected_amount = (SELECT l.principal_amount_proposed FROM m_wc_loan l + WHERE l.id = m_wc_loan_disbursement_detail.wc_loan_id) + WHERE m_wc_loan_disbursement_detail.actual_disburse_date IS NULL + AND EXISTS (SELECT 1 FROM m_wc_loan l + WHERE l.id = m_wc_loan_disbursement_detail.wc_loan_id AND l.loan_status_id = 100) + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java index 0a82b24b7af..cd3a0b3509d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java @@ -362,7 +362,9 @@ public void testUndoDisburseWorkingCapitalLoan() { assertStatus(data, "loanStatusType.approved"); assertEqualBigDecimal(BigDecimal.valueOf(5000), data.getApprovedPrincipal()); assertNotNull(data.getBalance(), "GET loan after undo should include balance"); - assertEqualBigDecimal(BigDecimal.valueOf(5000), data.getBalance().getPrincipalOutstanding()); + // Nothing is paid out after an undo-disbursal, so there is nothing outstanding - the loan is + // indistinguishable from one that was approved and never disbursed. + assertEqualBigDecimal(BigDecimal.ZERO, data.getBalance().getPrincipalOutstanding()); assertNotNull(data.getDisbursementDetails(), "GET loan after undo should include disbursementDetails array"); assertFalse(data.getDisbursementDetails().isEmpty(), "disbursementDetails should not be empty"); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java index 19e2af13543..ab851f75869 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java @@ -50,6 +50,8 @@ import org.apache.fineract.client.models.ProjectedAmortizationScheduleData; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdDiscountRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRateRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdResponse; import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.client.models.WorkingCapitalLoanChargeData; import org.apache.fineract.client.models.WorkingCapitalLoanDelinquencyRangeScheduleData; @@ -91,6 +93,12 @@ public void undoApproval(Long loanId, PostWorkingCapitalLoansLoanIdRequest reque ok(() -> fineractClient.workingCapitalLoans().stateTransitionWorkingCapitalLoanById(loanId, "undoapproval", request)); } + public Long modifyApplication(Long loanId, PutWorkingCapitalLoansLoanIdRequest request) { + PutWorkingCapitalLoansLoanIdResponse response = ok( + () -> fineractClient.workingCapitalLoans().modifyWorkingCapitalLoanApplicationById(loanId, request, (String) null)); + return response.getResourceId(); + } + public void delete(Long loanId) { ok(() -> fineractClient.workingCapitalLoans().deleteWorkingCapitalLoanApplication(loanId)); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java index f92a9b30605..ca0ff406d14 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java @@ -29,6 +29,7 @@ import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRateRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.integrationtests.common.Utils; public final class WorkingCapitalLoanRequestBuilders { @@ -50,6 +51,23 @@ public static PostWorkingCapitalLoansRequest submitApplication(Long clientId, Lo .totalPaymentVolume(BigDecimal.valueOf(100000)).locale(LOCALE).dateFormat(DATE_FORMAT); } + /** + * Submit overload carrying the loan-level {@code discount}. Only usable on a product whose {@code discountDefault} + * attribute is overridable, otherwise the application validator rejects the override. + */ + public static PostWorkingCapitalLoansRequest submitApplicationWithDiscount(Long clientId, Long productId, BigDecimal principal, + BigDecimal periodPaymentRate, String submittedOnDate, String expectedDisbursementDate, BigDecimal discount) { + return submitApplication(clientId, productId, principal, periodPaymentRate, submittedOnDate, expectedDisbursementDate) + .discount(discount); + } + + /** + * Modify (PUT) request changing only the requested principal of a submitted application. + */ + public static PutWorkingCapitalLoansLoanIdRequest modifyPrincipal(BigDecimal principalAmount) { + return new PutWorkingCapitalLoansLoanIdRequest().principalAmount(principalAmount).locale(LOCALE).dateFormat(DATE_FORMAT); + } + public static PostWorkingCapitalLoansLoanIdRequest approve(String approvedOnDate, BigDecimal approvedAmount, String expectedDisbursementDate) { return new PostWorkingCapitalLoansLoanIdRequest().approvedOnDate(approvedOnDate).approvedLoanAmount(approvedAmount) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPrincipalLifecycleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPrincipalLifecycleTest.java new file mode 100644 index 00000000000..31b240ab6d7 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPrincipalLifecycleTest.java @@ -0,0 +1,501 @@ +/** + * 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.integrationtests.client.feign.tests; + +import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.assertEqualBigDecimal; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.fineract.client.models.GetBalance; +import org.apache.fineract.client.models.GetDisbursementDetail; +import org.apache.fineract.client.models.GetWorkingCapitalLoanSummary; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.infrastructure.event.external.data.ExternalEventResponse; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignExternalEventHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +/** + * Principal / disbursed-total lifecycle for Working Capital loans. + * + *

+ * The reported worked example is reproduced verbatim so this class doubles as the regression test: submitted 500 + * (discount 100) → approved 450 (discount 45) → disbursed 250 (discount 25) → repayment 100. + * + *

+ * Field semantics under test (taken from the specification, not from the implementation): + *

    + *
  • {@code principal} = active contractual principal, moving proposed → approved → actually disbursed. + * Confluence "Working Capital APIs - Design page (WIP)":474 ("Active principal from loan product related details") and + * :604; {@code WorkingCapitalLoanApiResourceSwagger.java:210}; classic-loan parity {@code Loan.java:549-557}, + * {@code LoanScheduleAssembler.java:1527}, {@code LoanApplicationWritePlatformServiceJpaRepositoryImpl.java:883}, + * {@code LoanDisbursementService.java:207}, {@code LoanWritePlatformServiceJpaRepositoryImpl.java:3163}.
  • + *
  • {@code balance.totalDisbursement} / {@code summary.totalDisbursement} = "Total disbursed amount". Confluence APIs + * design page:520.
  • + *
  • {@code proposedPrincipal} / {@code approvedPrincipal} values, and the {@code netDisbursalAmount} of the disbursed + * loan (250), are quoted directly from the reported GET output.
  • + *
  • Characterization guards (current behaviour, not spec — the report never states them, so they are asserted only to + * keep the behaviour stable): the {@code netDisbursalAmount} values after approval, after undo-disbursal and on + * approve-without-amount.
  • + *
  • An undone disbursal leaves no repayable balance, so it is indistinguishable from a loan that was only ever + * approved.
  • + *
  • The expected disbursement amount ({@code disbursementDetails[0].principal}) tracks the active principal, so a + * pending application edited 500 → 400 offers 400.
  • + *
+ * + *

+ * Deliberately NOT asserted: {@code approvedPrincipal} semantics before approval. That is an unresolved semantics + * question and out of scope here, so the current {@code ZERO} before approval is asserted as-is. + */ +public class FeignWorkingCapitalLoanPrincipalLifecycleTest extends FeignIntegrationTest { + + private static final String DISBURSAL_EVENT = "WorkingCapitalLoanDisbursalBusinessEvent"; + + /** Reported scenario: "principalAmount": $500 */ + private static final BigDecimal PROPOSED_PRINCIPAL = BigDecimal.valueOf(500); + /** Reported scenario: "discount": "$100" */ + private static final BigDecimal PROPOSED_DISCOUNT = BigDecimal.valueOf(100); + /** Reported scenario: "approvedLoanAmount": $450 */ + private static final BigDecimal APPROVED_PRINCIPAL = BigDecimal.valueOf(450); + /** Reported scenario: approval "discountAmount": $45 */ + private static final BigDecimal APPROVED_DISCOUNT = BigDecimal.valueOf(45); + /** Reported scenario: "transactionAmount": $250 */ + private static final BigDecimal DISBURSED_PRINCIPAL = BigDecimal.valueOf(250); + /** Reported scenario: disbursal "discountAmount": $25 */ + private static final BigDecimal DISBURSED_DISCOUNT = BigDecimal.valueOf(25); + + private static final BigDecimal MODIFIED_PRINCIPAL = BigDecimal.valueOf(400); + private static final BigDecimal REPAYMENT = BigDecimal.valueOf(100); + + private static final BigDecimal ZERO = BigDecimal.ZERO; + /** Product default; percent, not a fraction — see {@code WorkingCapitalLoanProductTestBuilder:47}. */ + private static final BigDecimal PERIOD_PAYMENT_RATE = BigDecimal.valueOf(18); + + private static final String ISO_DATE = "2026-01-01"; + private static final String DATE = "01 January 2026"; + + private FeignWorkingCapitalLoanHelper wcLoanHelper; + private FeignClientHelper clientHelper; + private FeignBusinessDateHelper businessDateHelper; + private FeignExternalEventHelper externalEventHelper; + private WorkingCapitalLoanProductHelper productHelper; + + private final List createdLoanIds = new ArrayList<>(); + + @BeforeAll + void setupHelpers() { + wcLoanHelper = new FeignWorkingCapitalLoanHelper(fineractClient()); + clientHelper = new FeignClientHelper(fineractClient()); + businessDateHelper = new FeignBusinessDateHelper(fineractClient()); + externalEventHelper = new FeignExternalEventHelper(fineractClient()); + productHelper = new WorkingCapitalLoanProductHelper(); + } + + @AfterAll + void cleanupEntities() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + } + + @Test + @Order(1) + void submittedLoanPublishesProposedAsActivePrincipal() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + final GetBalance balance = balanceOf(loan); + final GetWorkingCapitalLoanSummary summary = summaryOf(loan); + + assertAll("submitted loan principal fields", // + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getProposedPrincipal(), "proposedPrincipal on a submitted loan"), + () -> assertEqualBigDecimal(ZERO, loan.getApprovedPrincipal(), + "approvedPrincipal before approval (Working Capital keeps ZERO)"), + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getPrincipal(), + "active principal on a submitted loan equals the proposed principal"), + () -> assertNull(loan.getNetDisbursalAmount(), + "netDisbursalAmount is null before the schedule exists (generated at approval)"), + () -> assertEqualBigDecimal(ZERO, balance.getPrincipal(), "balance.principal before disbursement"), + () -> assertEqualBigDecimal(ZERO, balance.getTotalDisbursement(), "balance.totalDisbursement before disbursement"), + () -> assertEqualBigDecimal(ZERO, summary.getPrincipalOutstanding(), + "summary.principalOutstanding before disbursement")); + }); + } + + @Test + @Order(2) + void modifyBeforeApprovalMovesProposedAndActivePrincipal() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + + wcLoanHelper.modifyApplication(loanId, WorkingCapitalLoanRequestBuilders.modifyPrincipal(MODIFIED_PRINCIPAL)); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + assertAll("modified application principal fields", // + () -> assertEqualBigDecimal(MODIFIED_PRINCIPAL, loan.getProposedPrincipal(), "proposedPrincipal after modify"), + () -> assertEqualBigDecimal(MODIFIED_PRINCIPAL, loan.getPrincipal(), + "active principal must follow the modified proposed principal — otherwise a loan modified 500 → 400 still publishes 500"), + () -> assertEqualBigDecimal(ZERO, loan.getApprovedPrincipal(), "approvedPrincipal stays ZERO after modify"), + () -> assertEqualBigDecimal(MODIFIED_PRINCIPAL, expectedDisbursementAmountOf(loan), + "the expected disbursement amount must follow the modified principal — otherwise the loan reads 400 everywhere but the disbursement section still offers 500")); + }); + } + + @Test + @Order(3) + void approveAtReducedAmountUpdatesActivePrincipal() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + approveReduced(loanId); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + final GetBalance balance = balanceOf(loan); + + assertAll("approved loan principal fields", // + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getProposedPrincipal(), "proposedPrincipal after approval"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getApprovedPrincipal(), "approvedPrincipal after approval"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getPrincipal(), + "active principal must become the approved principal (classic parity: LoanScheduleAssembler.java:1527)"), + () -> assertEqualBigDecimal(APPROVED_DISCOUNT, loan.getApprovedDiscountFee(), "approved discount fee after approval"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getNetDisbursalAmount(), "netDisbursalAmount after approval"), + () -> assertEqualBigDecimal(ZERO, balance.getPrincipal(), "balance.principal is still zero before disbursement"), + () -> assertEqualBigDecimal(ZERO, balance.getTotalDisbursement(), + "balance.totalDisbursement is still zero before disbursement")); + }); + } + + @Test + @Order(4) + void disburseBelowApprovedUpdatesActivePrincipalAndDisbursedTotal() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + approveReduced(loanId); + disburseReduced(loanId); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + final GetBalance balance = balanceOf(loan); + final GetWorkingCapitalLoanSummary summary = summaryOf(loan); + + assertAll("disbursed loan principal fields", // + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getProposedPrincipal(), "proposedPrincipal after disbursement"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getApprovedPrincipal(), "approvedPrincipal after disbursement"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, loan.getPrincipal(), + "active principal must become the actually disbursed amount (classic parity: LoanDisbursementService.java:207)"), + () -> assertEqualBigDecimal(DISBURSED_DISCOUNT, loan.getDiscountFee(), "effective discount fee after disbursement"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, loan.getNetDisbursalAmount(), "netDisbursalAmount after disbursement"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL.add(DISBURSED_DISCOUNT), balance.getPrincipal(), + "balance.principal is total repayable = disbursed + discount"), + () -> assertEqualBigDecimal(DISBURSED_DISCOUNT, balance.getTotalDiscountFee(), + "balance.totalDiscountFee after disbursement"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, balance.getTotalDisbursement(), + "balance.totalDisbursement must carry the disbursed amount, not 0"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, summary.getTotalDisbursement(), + "summary.totalDisbursement must carry the disbursed amount, not 0"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL.add(DISBURSED_DISCOUNT), summary.getPrincipalOutstanding(), + "summary.principalOutstanding right after disbursement")); + }); + } + + @Test + @Order(5) + void repaymentLeavesActivePrincipalAndDisbursedTotalUntouched() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + approveReduced(loanId); + disburseReduced(loanId); + + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(REPAYMENT, DATE)); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + final GetBalance balance = balanceOf(loan); + final GetWorkingCapitalLoanSummary summary = summaryOf(loan); + + assertAll("post-repayment principal fields", // + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, loan.getPrincipal(), + "active principal is contractual, so a repayment must not move it"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL.add(DISBURSED_DISCOUNT), balance.getPrincipal(), + "balance.principal is unchanged by a repayment"), + () -> assertEqualBigDecimal(REPAYMENT, balance.getPrincipalPaid(), "balance.principalPaid after a 100 repayment"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, balance.getTotalDisbursement(), + "balance.totalDisbursement is unchanged by a repayment"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL.add(DISBURSED_DISCOUNT).subtract(REPAYMENT), + summary.getPrincipalOutstanding(), "summary.principalOutstanding after a 100 repayment")); + }); + } + + @Test + @Order(6) + void undoDisburseRestoresApprovedPrincipalAndClearsRepayableBalance() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + approveReduced(loanId); + disburseReduced(loanId); + + wcLoanHelper.undoDisbursal(loanId, WorkingCapitalLoanRequestBuilders.undoDisbursal()); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + final GetBalance balance = balanceOf(loan); + final GetWorkingCapitalLoanSummary summary = summaryOf(loan); + + assertAll("post-undo-disbursal principal fields", // + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getPrincipal(), + "active principal must fall back to the approved principal (classic parity: LoanWritePlatformServiceJpaRepositoryImpl.java:3163)"), + () -> assertNull(loan.getDiscountFee(), "effective discount fee is cleared on undo-disbursal"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getApprovedPrincipal(), + "approvedPrincipal survives undo-disbursal"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, loan.getNetDisbursalAmount(), + "netDisbursalAmount returns to the approved principal"), + () -> assertEqualBigDecimal(ZERO, balance.getTotalDisbursement(), + "balance.totalDisbursement must be backed out on undo-disbursal"), + () -> assertEqualBigDecimal(ZERO, balance.getPrincipalPaid(), "balance.principalPaid is reset on undo-disbursal"), + () -> assertEqualBigDecimal(ZERO, balance.getPrincipal(), + "nothing is paid out after an undo-disbursal, so there is nothing repayable"), + () -> assertEqualBigDecimal(ZERO, balance.getPrincipalOutstanding(), + "balance.principalOutstanding is cleared with the repayable principal"), + () -> assertEqualBigDecimal(ZERO, balance.getTotalOutstanding(), "balance.totalOutstanding after undo-disbursal"), + () -> assertEqualBigDecimal(ZERO, balance.getTotalDiscountFee(), + "the discount fee is charged at disbursement, so it is backed out with it"), + () -> assertEqualBigDecimal(ZERO, balance.getUnrealizedIncomeFromDiscountFee(), + "no discount fee left means no unrealized income from it"), + () -> assertEqualBigDecimal(ZERO, summary.getPrincipal(), "summary.principal after undo-disbursal"), + () -> assertEqualBigDecimal(ZERO, summary.getPrincipalOutstanding(), + "summary.principalOutstanding after undo-disbursal"), + () -> assertEqualBigDecimal(ZERO, summary.getTotalDisbursement(), "summary.totalDisbursement after undo-disbursal")); + }); + } + + @Test + @Order(7) + void undoApproveRestoresProposedPrincipal() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + approveReduced(loanId); + + wcLoanHelper.undoApproval(loanId, WorkingCapitalLoanRequestBuilders.emptyCommand()); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + assertAll("post-undo-approval principal fields", // + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getPrincipal(), + "active principal must fall back to the proposed principal (classic parity: LoanApplicationWritePlatformServiceJpaRepositoryImpl.java:883)"), + () -> assertEqualBigDecimal(ZERO, loan.getApprovedPrincipal(), "approvedPrincipal is reset to ZERO on undo-approval"), + () -> assertNull(loan.getApprovedDiscountFee(), "approved discount fee is cleared on undo-approval"), + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getProposedPrincipal(), + "proposedPrincipal survives undo-approval"), + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, expectedDisbursementAmountOf(loan), + "the expected disbursement amount follows the active principal back to the proposed one")); + }); + } + + @Test + @Order(8) + void approveWithoutAmountLeavesActivePrincipalAtProposed() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approveWithDiscount(DATE, null, DATE, PROPOSED_DISCOUNT)); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + + assertAll("approve-without-amount principal fields", // + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getApprovedPrincipal(), + "approvedPrincipal defaults to the proposed principal when no amount is sent"), + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getPrincipal(), "active principal equals the proposed principal"), + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, loan.getNetDisbursalAmount(), + "netDisbursalAmount equals the approved principal")); + }); + } + + @Test + @Order(9) + void disbursalBusinessEventCarriesActivePrincipalAndDisbursedTotal() { + externalEventHelper.enableBusinessEvent(DISBURSAL_EVENT); + try { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long loanId = submitLoan(); + approveReduced(loanId); + externalEventHelper.deleteAllExternalEvents(); + disburseReduced(loanId); + + final List events = externalEventHelper.getExternalEventsByType(DISBURSAL_EVENT); + final ExternalEventResponse event = events.stream().filter(e -> loanId.equals(toLong(e.getPayLoad().get("id")))).findFirst() + .orElse(null); + assertNotNull(event, "A " + DISBURSAL_EVENT + " must be recorded for loan " + loanId); + + final Map payload = event.getPayLoad(); + @SuppressWarnings("unchecked") + final Map summary = (Map) payload.get("summary"); + assertNotNull(summary, "event payload must carry a summary"); + + assertAll("disbursal event principal fields", // + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, toBigDecimal(payload.get("principal")), + "event principal must be the actually disbursed amount"), + () -> assertEqualBigDecimal(APPROVED_PRINCIPAL, toBigDecimal(payload.get("approvedPrincipal")), + "event approvedPrincipal"), + () -> assertEqualBigDecimal(PROPOSED_PRINCIPAL, toBigDecimal(payload.get("proposedPrincipal")), + "event proposedPrincipal"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL, toBigDecimal(summary.get("principalDisbursed")), + "event summary.principalDisbursed is sourced from balance.totalDisbursement"), + () -> assertEqualBigDecimal(DISBURSED_PRINCIPAL.add(DISBURSED_DISCOUNT), + toBigDecimal(summary.get("totalPrincipal")), + "event summary.totalPrincipal is sourced from balance.principal")); + }); + } finally { + externalEventHelper.disableBusinessEvent(DISBURSAL_EVENT); + } + } + + /** + * Two loans in the same state - approved for 450, nothing paid out - must publish the same balances whether that + * state was reached by approval alone or by disbursing and then undoing it. + */ + @Test + @Order(10) + void undoneDisbursalMatchesNeverDisbursedLoan() { + businessDateHelper.runAt(ISO_DATE, () -> { + final Long neverDisbursedId = submitLoan(); + approveReduced(neverDisbursedId); + + final Long undoneId = submitLoan(); + approveReduced(undoneId); + disburseReduced(undoneId); + wcLoanHelper.undoDisbursal(undoneId, WorkingCapitalLoanRequestBuilders.undoDisbursal()); + + final GetWorkingCapitalLoansLoanIdResponse neverDisbursed = wcLoanHelper.getLoanDetails(neverDisbursedId); + final GetWorkingCapitalLoansLoanIdResponse undone = wcLoanHelper.getLoanDetails(undoneId); + + final GetBalance neverDisbursedBalance = balanceOf(neverDisbursed); + final GetBalance undoneBalance = balanceOf(undone); + + assertAll("the two routes into 'approved, nothing paid out' must agree", // + () -> assertEqualBigDecimal(neverDisbursed.getPrincipal(), undone.getPrincipal(), "active principal"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getPrincipal(), undoneBalance.getPrincipal(), "balance.principal"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getPrincipalOutstanding(), undoneBalance.getPrincipalOutstanding(), + "balance.principalOutstanding"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getTotalOutstanding(), undoneBalance.getTotalOutstanding(), + "balance.totalOutstanding"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getTotalExpectedRepayment(), + undoneBalance.getTotalExpectedRepayment(), "balance.totalExpectedRepayment"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getTotalDisbursement(), undoneBalance.getTotalDisbursement(), + "balance.totalDisbursement"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getTotalDiscountFee(), undoneBalance.getTotalDiscountFee(), + "balance.totalDiscountFee"), + () -> assertEqualBigDecimal(neverDisbursedBalance.getUnrealizedIncomeFromDiscountFee(), + undoneBalance.getUnrealizedIncomeFromDiscountFee(), "balance.unrealizedIncomeFromDiscountFee"), + () -> assertEqualBigDecimal(summaryOf(neverDisbursed).getPrincipalOutstanding(), + summaryOf(undone).getPrincipalOutstanding(), "summary.principalOutstanding")); + }); + } + + // ----------------------------------------------------------------------- + // Setup helpers + // ----------------------------------------------------------------------- + + /** + * A product whose {@code discountDefault} is overridable, so the loan-level discount ladder (100 → 45 → 25) is + * accepted at submit, approve and disburse. + */ + private Long createProduct() { + final String uniqueName = "WCL Principal " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + return productHelper + .createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withAllowAttributeOverrides(Map.of("discountDefault", Boolean.TRUE)).build()) + .getResourceId(); + } + + private Long submitLoan() { + final Long clientId = clientHelper.createClient(DATE); + final Long productId = createProduct(); + final Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplicationWithDiscount(clientId, + productId, PROPOSED_PRINCIPAL, PERIOD_PAYMENT_RATE, DATE, DATE, PROPOSED_DISCOUNT)); + createdLoanIds.add(loanId); + return loanId; + } + + private void approveReduced(final Long loanId) { + wcLoanHelper.approve(loanId, + WorkingCapitalLoanRequestBuilders.approveWithDiscount(DATE, APPROVED_PRINCIPAL, DATE, APPROVED_DISCOUNT)); + } + + private void disburseReduced(final Long loanId) { + wcLoanHelper.disburse(loanId, + WorkingCapitalLoanRequestBuilders.disburseWithDiscount(DATE, DISBURSED_PRINCIPAL, DISBURSED_DISCOUNT)); + } + + private static GetBalance balanceOf(final GetWorkingCapitalLoansLoanIdResponse loan) { + final GetBalance balance = loan.getBalance(); + assertNotNull(balance, "loan.balance must be present"); + return balance; + } + + /** {@code disbursementDetails[0].principal} is the expected disbursement amount of the single supported tranche. */ + private static BigDecimal expectedDisbursementAmountOf(final GetWorkingCapitalLoansLoanIdResponse loan) { + final List details = loan.getDisbursementDetails(); + assertNotNull(details, "loan.disbursementDetails must be present"); + assertFalse(details.isEmpty(), "loan.disbursementDetails must carry the expected tranche"); + return details.getFirst().getPrincipal(); + } + + private static GetWorkingCapitalLoanSummary summaryOf(final GetWorkingCapitalLoansLoanIdResponse loan) { + final GetWorkingCapitalLoanSummary summary = loan.getSummary(); + assertNotNull(summary, "loan.summary must be present"); + return summary; + } + + private static Long toLong(final Object value) { + return value instanceof Number number ? number.longValue() : null; + } + + /** Avro {@code bigdecimal} fields surface as either a JSON number or a string, depending on the codec. */ + private static BigDecimal toBigDecimal(final Object value) { + if (value == null) { + return null; + } + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + if (value instanceof Number number) { + return new BigDecimal(number.toString()); + } + return new BigDecimal(value.toString()); + } +}