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..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, // 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 75d67a25834..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,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_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 c6b3ac24f1f..f1ae6ed98a9 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,152 @@ 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" + + 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 | + | | 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 + Then Loan has 0.0 outstanding amount + And Loan has 0.0 overpaid amount + 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);