From 9001247ee64e8fe7093e2c32a0b324a1aa13be58 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Mon, 27 Apr 2026 18:32:40 +0200 Subject: [PATCH 1/2] FINERACT-2421: Added e2e test for covering Loan invalid state after CBR and backdated Goodwill Credit --- .../data/loanproduct/DefaultLoanProduct.java | 1 + .../fineract/test/support/TestContextKey.java | 1 + .../test/resources/features/LoanCBR.feature | 71 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 34f3e95296d..25c4f405c08 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -131,6 +131,7 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CASH_ACCOUNTING_DISBURSEMENT_CHARGES, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY, // + LP2_PS3087_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY_LAST_INSTALLMENT, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ACCELERATE_MATURITY_CHARGE_OFF, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ACC_MATUR_CHARGE_OFF, // diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 75d67a25834..5e3e95f1efa 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -123,6 +123,7 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_REFUND_FULL = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualNoInterestRecalcRefundFull"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullZeroInterestChargeOff"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullZeroInterestChargeOffAccrualActivity"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PS3087_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY_LAST_INSTALLMENT = "loanProductCreateResponseLP2Ps3087ActualActualInterestRefundFullZeroInterestChargeOffAccrualActivityLastInstallment"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualNoInterestRecalcInterestRefundFullZeroInterestChargeOff"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ACCELERATE_MATURITY_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullAccelerateMaturityChargeOff"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ACC_MATUR_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualNoInterestRecalcInterestRefundFullAccelerateMaturityChargeOff"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature index c6b3ac24f1f..6f7b830808d 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature @@ -1789,3 +1789,74 @@ Feature: Credit Balance Refund | 01 April 2025 | Disbursement | 243.79 | 0.0 | 0.0 | 0.0 | 0.0 | 243.79 | false | false | | 01 April 2025 | Down Payment | 61.0 | 61.0 | 0.0 | 0.0 | 0.0 | 182.79 | false | false | Then Loan status will be "ACTIVE" + + @TestRailId:C78812 + Scenario: Loan ends in invalid state after CBR and backdated Goodwill Credit with both totalOutstanding and totalOverpaid > 0 + When Admin sets the business date to "02 September 2025" + And Admin creates a client with random data + + # Migration loan: submitted & disbursed back-dated to 06 April 2025, 6 monthly installments + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_PS3087_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY_LAST_INSTALLMENT | 06 April 2025 | 1316.49 | 12.2062 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "06 April 2025" with "1316.49" amount and expected disbursement date on "06 April 2025" + And Admin successfully disburse the loan on "06 April 2025" with "1316.49" EUR transaction amount + + # 4 backdated AUTOPAY repayments of 227.31 EUR (still on system date 02 September 2025) + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "06 May 2025" with 227.31 EUR transaction amount and system-generated Idempotency key + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "06 June 2025" with 227.31 EUR transaction amount and system-generated Idempotency key + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "06 July 2025" with 227.31 EUR transaction amount and system-generated Idempotency key + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "06 August 2025" with 227.31 EUR transaction amount and system-generated Idempotency key + + # 5th installment via AUTOPAY on 06 September 2025 + When Admin sets the business date to "06 September 2025" + And Admin runs inline COB job for Loan + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "06 September 2025" with 227.31 EUR transaction amount and system-generated Idempotency key + + # 6th installment via REAL_TIME on 02 October 2025 + When Admin sets the business date to "02 October 2025" + And Admin runs inline COB job for Loan + And Customer makes "REPAYMENT" transaction with "REAL_TIME" payment type on "02 October 2025" with 227.00 EUR transaction amount and system-generated Idempotency key + + # 3 MIRs (interestRefundCalculation=false) on 29 October 2025 → loan flips to OVERPAID + When Admin sets the business date to "29 October 2025" + And Admin runs inline COB job for Loan + And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "29 October 2025" with 242.00 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation false + And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "29 October 2025" with 242.00 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation false + And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "29 October 2025" with 30.49 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation false + Then Loan status will be "OVERPAID" + + # 30 October 2025 → CBR 514.49 + When Admin sets the business date to "30 October 2025" + And Admin runs inline COB job for Loan + And Admin makes Credit Balance Refund transaction on "30 October 2025" with 514.49 EUR transaction amount + + # 11 December 2025 → INTEREST_REFUND on MIR1 + backdated GOODWILL_CREDIT (txn date 28 October 2025, BEFORE the MIRs) + When Admin sets the business date to "11 December 2025" + And Admin runs inline COB job for Loan + And Admin manually adds Interest Refund for "1"th "MERCHANT_ISSUED_REFUND" transaction made on "29 October 2025" with 0.01 EUR interest refund amount + And Customer makes "GOODWILL_CREDIT" transaction with "AUTOPAY" payment type on "28 October 2025" with 0.01 EUR transaction amount and system-generated Idempotency key + + # 12 December 2025 → CBR 27.92 + When Admin sets the business date to "12 December 2025" + And Admin runs inline COB job for Loan + And Admin makes Credit Balance Refund transaction on "12 December 2025" with 27.92 EUR transaction amount + + # 16 December 2025 → INTEREST_REFUND on MIR2 & MIR3 + another backdated GOODWILL_CREDIT + When Admin sets the business date to "16 December 2025" + And Admin runs inline COB job for Loan + And Admin manually adds Interest Refund for "2"th "MERCHANT_ISSUED_REFUND" transaction made on "29 October 2025" with 0.01 EUR interest refund amount + And Admin manually adds Interest Refund for "3"th "MERCHANT_ISSUED_REFUND" transaction made on "29 October 2025" with 0.01 EUR interest refund amount + And Customer makes "GOODWILL_CREDIT" transaction with "AUTOPAY" payment type on "28 October 2025" with 0.01 EUR transaction amount and system-generated Idempotency key + + # 17 December 2025 → final CBR 0.01 — should fully close the loan + When Admin sets the business date to "17 December 2025" + And Admin runs inline COB job for Loan + And Admin makes Credit Balance Refund transaction on "17 December 2025" with 0.01 EUR transaction amount + + # ===== INVARIANT THE BUG VIOLATES ===== + # PS-3087: today both fields equal 542.41 and status is OVERPAID. + # After fix: outstanding == 0 AND overpaid == 0, status == CLOSED_OBLIGATIONS_MET. + Then Loan has 0.0 outstanding amount + And Loan has 0.0 overpaid amount + And Loan status will be "CLOSED_OBLIGATIONS_MET" \ No newline at end of file From 078d1c601d674f464d06a65fda6916bb937b98f9 Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Sat, 2 May 2026 21:28:49 -0500 Subject: [PATCH 2/2] FINERACT-2421: Fix for Loan invalid state after CBR and backdated Goodwill Credit --- .../data/loanproduct/DefaultLoanProduct.java | 2 +- .../test/stepdef/loan/LoanStepDef.java | 10 +- .../fineract/test/support/TestContextKey.java | 2 +- .../LoanProductGlobalInitializerStep.java | 41 +++++++ .../test/resources/features/LoanCBR.feature | 108 +++++++++++++++--- ...edPaymentScheduleTransactionProcessor.java | 14 ++- 6 files changed, 158 insertions(+), 19 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 25c4f405c08..0c5b81cceff 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -97,6 +97,7 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_INTEREST_DAILY_EMI_ACTUAL_ACTUAL_INTEREST_REFUND_INTEREST_RECALCULATION_MULTIDISB, // LP2_ADV_PYMNT_INTEREST_DAILY_INTEREST_RECALCULATION_ZERO_INTEREST_CHARGE_OFF_BEHAVIOUR, // LP2_ADV_PYMNT_INTEREST_DAILY_INTEREST_RECALCULATION_ZERO_INTEREST_CHARGE_OFF, // + LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT, // LP2_ADV_PYMNT_ZERO_INTEREST_CHARGE_OFF_BEHAVIOUR, // LP2_ADV_PYMNT_ZERO_INTEREST_CHARGE_OFF, // LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_ZERO_CHARGE_OFF, // @@ -131,7 +132,6 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CASH_ACCOUNTING_DISBURSEMENT_CHARGES, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY, // - LP2_PS3087_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY_LAST_INSTALLMENT, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ACCELERATE_MATURITY_CHARGE_OFF, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF, // LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ACC_MATUR_CHARGE_OFF, // diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index 347758af79d..94378c751b7 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -2169,12 +2169,20 @@ public void loanOverpaid(double totalOverpaidExpected) { () -> fineractClient.loans().retrieveLoan(loanId, Map.of("staffInSelectedOfficeOnly", "false"))); testContext().set(TestContextKey.LOAN_RESPONSE, loanDetailsResponse); - Double totalOverpaidActual = loanDetailsResponse.getTotalOverpaid().doubleValue(); Double totalOutstandingActual = loanDetailsResponse.getSummary().getTotalOutstanding().doubleValue(); double totalOutstandingExpected = 0.0; assertThat(totalOutstandingActual) .as(ErrorMessageHelper.wrongAmountInTotalOutstanding(totalOutstandingActual, totalOutstandingExpected)) .isEqualTo(totalOutstandingExpected); + // Loan API omits `totalOverpaid` when the loan has no overpayment (CLOSED_OBLIGATIONS_MET with zero + // overpayment). Tolerate that omission only when the scenario expects 0.0 - otherwise fail loudly so + // a regression where the API stops emitting the field is caught immediately. + double totalOverpaidActual = Optional.ofNullable(loanDetailsResponse.getTotalOverpaid()).map(BigDecimal::doubleValue) + .orElseGet(() -> { + assertThat(totalOverpaidExpected) + .as("Loan API returned null totalOverpaid but scenario expected %s", totalOverpaidExpected).isEqualTo(0.0); + return 0.0; + }); assertThat(totalOverpaidActual) .as(ErrorMessageHelper.wrongAmountInTransactionsOverpayment(totalOverpaidActual, totalOverpaidExpected)) .isEqualTo(totalOverpaidExpected); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 5e3e95f1efa..1ddf1b1af4d 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -123,7 +123,7 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_REFUND_FULL = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualNoInterestRecalcRefundFull"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullZeroInterestChargeOff"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullZeroInterestChargeOffAccrualActivity"; - public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PS3087_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY_LAST_INSTALLMENT = "loanProductCreateResponseLP2Ps3087ActualActualInterestRefundFullZeroInterestChargeOffAccrualActivityLastInstallment"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullZeroInterestChargeOffAccrualActivityLastInstallment"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualNoInterestRecalcInterestRefundFullZeroInterestChargeOff"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ACCELERATE_MATURITY_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualInterestRefundFullAccelerateMaturityChargeOff"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ACC_MATUR_CHARGE_OFF = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmiActualActualNoInterestRecalcInterestRefundFullAccelerateMaturityChargeOff"; diff --git a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index 803d8329c6a..b69e6e9a71b 100644 --- a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -4961,6 +4961,47 @@ public void initialize() throws Exception { responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOffAccrualActivity); }); + tasks.add(() -> { + // LP2 + zero-interest chargeOff behaviour + progressive loan schedule + horizontal + interest recalculation + // + accrual activity posting, with LAST_INSTALLMENT future-installment allocation rule baked in for every + // advanced-allocation transaction type - exercises code paths that the more common NEXT_INSTALLMENT + // configuration does not reach. + // (LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT) + final String nameLastInst = DefaultLoanProduct.LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT + .getName(); + final PostLoanProductsRequest loanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOffAccLastInstallment = loanProductsRequestFactory + .defaultLoanProductsRequestLP2Emi()// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .maxTrancheCount(500)// + .isInterestRecalculationEnabled(true)// + .preClosureInterestCalculationStrategy(1)// + .rescheduleStrategyMethod(4)// + .interestRecalculationCompoundingMethod(0)// + .recalculationRestFrequencyType(2)// + .recalculationRestFrequencyInterval(1)// + .supportedInterestRefundTypes(supportedInterestRefundTypes).paymentAllocation(List.of(// + createPaymentAllocation("DEFAULT", "LAST_INSTALLMENT"), // + createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), // + createPaymentAllocation("MERCHANT_ISSUED_REFUND", "LAST_INSTALLMENT"), // + createPaymentAllocation("PAYOUT_REFUND", "LAST_INSTALLMENT"), // + createPaymentAllocation("INTEREST_REFUND", "LAST_INSTALLMENT"))) + .name(nameLastInst)// + .shortName(loanProductsRequestFactory.generateShortNameSafely())// + .enableAccrualActivityPosting(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType(OverAppliedCalculationType.FIXED_SIZE.value)// + .overAppliedNumber(1000)// + .enableInstallmentLevelDelinquency(true)// + .interestRecognitionOnDisbursementDate(true)// + .chargeOffBehaviour("ZERO_INTEREST");// + final PostLoanProductsResponse responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOffAccLastInstallment = createLoanProductIdempotent( + loanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOffAccLastInstallment); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT, + responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOffAccLastInstallment); + }); + tasks.add(() -> { // LP2 with progressive loan schedule + horizontal + interest recalculation daily EMI + 360/30 + // multidisbursement with full term tranche enabled diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature index 6f7b830808d..f1ae6ed98a9 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanCBR.feature @@ -1790,15 +1790,14 @@ Feature: Credit Balance Refund | 01 April 2025 | Down Payment | 61.0 | 61.0 | 0.0 | 0.0 | 0.0 | 182.79 | false | false | Then Loan status will be "ACTIVE" - @TestRailId:C78812 - Scenario: Loan ends in invalid state after CBR and backdated Goodwill Credit with both totalOutstanding and totalOverpaid > 0 + Scenario Outline: Verify that Loan ends in correct state after CBR + backdated GoodwillCredit cocktail ( future-installment rule) When Admin sets the business date to "02 September 2025" And Admin creates a client with random data # Migration loan: submitted & disbursed back-dated to 06 April 2025, 6 monthly installments And Admin creates a fully customized loan with the following data: - | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | - | LP2_PS3087_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY_LAST_INSTALLMENT | 06 April 2025 | 1316.49 | 12.2062 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | | 06 April 2025 | 1316.49 | 12.2062 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | And Admin successfully approves the loan on "06 April 2025" with "1316.49" amount and expected disbursement date on "06 April 2025" And Admin successfully disburse the loan on "06 April 2025" with "1316.49" EUR transaction amount @@ -1818,7 +1817,7 @@ Feature: Credit Balance Refund And Admin runs inline COB job for Loan And Customer makes "REPAYMENT" transaction with "REAL_TIME" payment type on "02 October 2025" with 227.00 EUR transaction amount and system-generated Idempotency key - # 3 MIRs (interestRefundCalculation=false) on 29 October 2025 → loan flips to OVERPAID + # 3 MIRs (interestRefundCalculation=false) on 29 October 2025 -> loan flips to OVERPAID When Admin sets the business date to "29 October 2025" And Admin runs inline COB job for Loan And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "29 October 2025" with 242.00 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation false @@ -1826,37 +1825,116 @@ Feature: Credit Balance Refund And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "29 October 2025" with 30.49 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation false Then Loan status will be "OVERPAID" - # 30 October 2025 → CBR 514.49 + # 30 October 2025 -> CBR 514.49 When Admin sets the business date to "30 October 2025" And Admin runs inline COB job for Loan And Admin makes Credit Balance Refund transaction on "30 October 2025" with 514.49 EUR transaction amount - # 11 December 2025 → INTEREST_REFUND on MIR1 + backdated GOODWILL_CREDIT (txn date 28 October 2025, BEFORE the MIRs) + # 11 December 2025 -> INTEREST_REFUND on MIR1 + backdated GOODWILL_CREDIT (txn date 28 October 2025, BEFORE the MIRs) When Admin sets the business date to "11 December 2025" And Admin runs inline COB job for Loan And Admin manually adds Interest Refund for "1"th "MERCHANT_ISSUED_REFUND" transaction made on "29 October 2025" with 0.01 EUR interest refund amount And Customer makes "GOODWILL_CREDIT" transaction with "AUTOPAY" payment type on "28 October 2025" with 0.01 EUR transaction amount and system-generated Idempotency key - # 12 December 2025 → CBR 27.92 + # 12 December 2025 -> CBR 27.92 When Admin sets the business date to "12 December 2025" And Admin runs inline COB job for Loan And Admin makes Credit Balance Refund transaction on "12 December 2025" with 27.92 EUR transaction amount - # 16 December 2025 → INTEREST_REFUND on MIR2 & MIR3 + another backdated GOODWILL_CREDIT + # 16 December 2025 -> INTEREST_REFUND on MIR2 & MIR3 + another backdated GOODWILL_CREDIT When Admin sets the business date to "16 December 2025" And Admin runs inline COB job for Loan And Admin manually adds Interest Refund for "2"th "MERCHANT_ISSUED_REFUND" transaction made on "29 October 2025" with 0.01 EUR interest refund amount And Admin manually adds Interest Refund for "3"th "MERCHANT_ISSUED_REFUND" transaction made on "29 October 2025" with 0.01 EUR interest refund amount And Customer makes "GOODWILL_CREDIT" transaction with "AUTOPAY" payment type on "28 October 2025" with 0.01 EUR transaction amount and system-generated Idempotency key - # 17 December 2025 → final CBR 0.01 — should fully close the loan + # 17 December 2025 -> final CBR 0.01 - should fully close the loan When Admin sets the business date to "17 December 2025" And Admin runs inline COB job for Loan And Admin makes Credit Balance Refund transaction on "17 December 2025" with 0.01 EUR transaction amount - - # ===== INVARIANT THE BUG VIOLATES ===== - # PS-3087: today both fields equal 542.41 and status is OVERPAID. - # After fix: outstanding == 0 AND overpaid == 0, status == CLOSED_OBLIGATIONS_MET. Then Loan has 0.0 outstanding amount And Loan has 0.0 overpaid amount - And Loan status will be "CLOSED_OBLIGATIONS_MET" \ No newline at end of file + And Loan status will be "CLOSED_OBLIGATIONS_MET" + And Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 06 April 2025 | | 1316.49 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 30 | 06 May 2025 | 06 May 2025 | 1102.39 | 214.1 | 13.21 | 0.0 | 0.0 | 227.31 | 227.31 | 0.0 | 0.0 | 0.0 | + | 2 | 31 | 06 June 2025 | 06 June 2025 | 886.51 | 215.88 | 11.43 | 0.0 | 0.0 | 227.31 | 227.31 | 0.0 | 0.0 | 0.0 | + | 3 | 30 | 06 July 2025 | 06 July 2025 | 668.09 | 218.42 | 8.89 | 0.0 | 0.0 | 227.31 | 227.31 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 06 August 2025 | 06 August 2025 | 447.71 | 220.38 | 6.93 | 0.0 | 0.0 | 227.31 | 227.31 | 0.0 | 0.0 | 0.0 | + | 5 | 31 | 06 September 2025 | 06 September 2025 | 225.04 | 222.67 | 4.64 | 0.0 | 0.0 | 227.31 | 227.31 | 0.0 | 0.0 | 0.0 | + | 6 | 30 | 06 October 2025 | 02 October 2025 | 0.0 | 225.04 | 1.96 | 0.0 | 0.0 | 227.0 | 227.0 | 227.0 | 0.0 | 0.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1316.49 | 47.06 | 0.0 | 0.0 | 1363.55 | 1363.55 | 227.0 | 0.0 | 0.0 | + + @TestRailId:C78812 + Examples: LAST_INSTALLMENT future-installment rule (the configuration that originally reproduced PS-3087) + | rule | loanProduct | + | LAST_INSTALLMENT | LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT | + + @TestRailId:C78853 + Examples: NEXT_INSTALLMENT future-installment rule (default; must stay unaffected by the fix) + | rule | loanProduct | + | NEXT_INSTALLMENT | LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACCRUAL_ACTIVITY | + + @TestRailId:C78851 + Scenario: Verify that backdated GoodwillCredit on fully paid loan followed by CBR closes the loan + When Admin sets the business date to "15 December 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT | 01 January 2025 | 300 | 0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "300" amount and expected disbursement date on "01 January 2025" + And Admin successfully disburse the loan on "01 January 2025" with "300" EUR transaction amount + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "01 February 2025" with 100.00 EUR transaction amount and system-generated Idempotency key + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "01 March 2025" with 100.00 EUR transaction amount and system-generated Idempotency key + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "01 April 2025" with 100.00 EUR transaction amount and system-generated Idempotency key + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + # Backdated GoodwillCredit dated BEFORE maturity (01 April 2025) on an already-closed loan + When Customer makes "GOODWILL_CREDIT" transaction with "AUTOPAY" payment type on "15 March 2025" with 0.50 EUR transaction amount and system-generated Idempotency key + Then Loan status will be "OVERPAID" + And Loan has 0.0 outstanding amount + And Loan has 0.5 overpaid amount + # CBR equal to overpayment closes the loan; the schedule's last installment must remain intact + When Admin makes Credit Balance Refund transaction on "15 April 2025" with 0.5 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + And Loan has 0.0 outstanding amount + And Loan has 0.0 overpaid amount + And Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 300.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | 01 February 2025 | 200.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | + | 2 | 28 | 01 March 2025 | 01 March 2025 | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | + | 3 | 31 | 01 April 2025 | 01 April 2025 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 100.0 | 0.5 | 0.0 | 0.0 | + + @TestRailId:C78852 + Scenario: Verify that Reverse-replay reduces overpayment so an earlier CBR re-runs with principalPortion > 0 + When Admin sets the business date to "15 December 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF_ACC_LAST_INSTALLMENT | 01 January 2025 | 300 | 0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "300" amount and expected disbursement date on "01 January 2025" + And Admin successfully disburse the loan on "01 January 2025" with "300" EUR transaction amount + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "01 February 2025" with 100.00 EUR transaction amount and system-generated Idempotency key + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "01 March 2025" with 100.00 EUR transaction amount and system-generated Idempotency key + # Final repayment overpays by 50 EUR + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "01 April 2025" with 150.00 EUR transaction amount and system-generated Idempotency key + Then Loan status will be "OVERPAID" + And Loan has 50.0 overpaid amount + # CBR equals overpayment, after maturity → loan closes + When Admin makes Credit Balance Refund transaction on "15 April 2025" with 50 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + And Loan has 0.0 outstanding amount + # Reverse the SECOND repayment → reverse-replay re-runs the CBR with smaller overpayment + When Customer undo "1"th repayment on "01 March 2025" + Then Loan status will be "ACTIVE" + And Loan has 100.0 outstanding amount + And Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 300.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | 01 March 2025 | 200.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 100.0 | 0.0 | 100.0 | 0.0 | + | 2 | 28 | 01 March 2025 | 01 April 2025 | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 100.0 | 0.0 | 100.0 | 0.0 | + | 3 | 31 | 01 April 2025 | | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 50.0 | 0.0 | 0.0 | 50.0 | + | 4 | 14 | 15 April 2025 | | 0.0 | 50.0 | 0.0 | 0.0 | 0.0 | 50.0 | 0.0 | 0.0 | 0.0 | 50.0 | diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 417c76a4a46..34b94a6f8d9 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -650,11 +650,17 @@ private void handleInterestRefund(final LoanTransaction loanTransaction, final T final Money interestAfterRefund = interestRefundService.totalInterestByTransactions(this, loan.getId(), targetDate, modifiedTransactions, unmodifiedTransactionIds, ctx.getActiveLoanTermVariations()); final Money newAmount = interestBeforeRefund.minus(progCtx.getSumOfInterestRefundAmount()).minus(interestAfterRefund); - loanTransaction.updateAmount(newAmount.getAmount()); + loanTransaction.updateAmount(MathUtil.negativeToZero(newAmount).getAmount()); } progCtx.setSumOfInterestRefundAmount(progCtx.getSumOfInterestRefundAmount().add(loanTransaction.getAmount())); } } + // A zero-amount interest refund has no effect on balances; processing it would incorrectly + // zero the overpaymentHolder via handleOverpayment, causing subsequent CBR transactions + // to see an empty holder and create phantom outstanding on additional installments. + if (!loanTransaction.getAmount(ctx.getCurrency()).isGreaterThanZero()) { + return; + } handleRepayment(loanTransaction, ctx); } @@ -952,6 +958,12 @@ protected void handleCreditBalanceRefund(LoanTransaction loanTransaction, Transa } else { // transaction is after maturity date, create an additional installment Loan loan = loanTransaction.getLoan(); + // CBR is a credit to the customer. If funded by overpayment, repaidAmount will pay off this + // principal. + // If the CBR is unfunded (e.g. after a reverse-replay), the unfunded portion (transactionAmount + // - repaidAmount) + // correctly becomes a new principal obligation (principalOutstanding). + // Credited principal is added to prevent negative 'Balance of loan' in the schedule. LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, (installments.size() + 1), pastDueDate, transactionDate, transactionAmount.getAmount(), zeroMoney.getAmount(), zeroMoney.getAmount(), zeroMoney.getAmount(), false, null);