Merge pull request #5824
FINERACT-2421: reschedule after reage schedule and balance fix
diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature
index 98a914f..3cf3825 100644
--- a/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature
+++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature
@@ -1771,4 +1771,226 @@
| From Date | Reason | Status |
| 01 February 2025 | None | Approved |
| 01 February 2025 | None | Approved |
- | 01 February 2025 | None | Approved |
\ No newline at end of file
+ | 01 February 2025 | None | Approved |
+
+ @TestRailId:C78849
+ Scenario: Verify that reschedule after ReAge produces correct schedule and balance for downpayment product
+ When Admin sets the business date to "21 January 2026"
+ When Admin creates a client with random data
+ When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule
+ When 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_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION | 21 January 2026 | 480 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
+ And Admin successfully approves the loan on "21 January 2026" with "480" amount and expected disbursement date on "21 January 2026"
+ When Admin successfully disburse the loan on "21 January 2026" with "480" EUR transaction amount
+ Then Loan has 360.0 outstanding amount
+ Then 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 |
+ | | | 21 January 2026 | | 480.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 0 | 21 January 2026 | 21 January 2026 | 360.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 |
+ | 2 | 31 | 21 February 2026 | | 240.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 |
+ | 3 | 28 | 21 March 2026 | | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 |
+ | 4 | 31 | 21 April 2026 | | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 480.0 | 0.0 | 0.0 | 0.0 | 480.0 | 120.0 | 0.0 | 0.0 | 360.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 480.0 | 0.0 | 0.0 | 0.0 | 0.0 | 480.0 | false |
+ | 21 January 2026 | Down Payment | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 | 360.0 | false |
+ # --- Step 1: Reschedule (1 month payment holiday: push 21 Feb installment to 21 Mar) ---
+ When Admin sets the business date to "23 January 2026"
+ When Admin creates and approves Loan reschedule with the following data:
+ | rescheduleFromDate | submittedOnDate | adjustedDueDate | graceOnPrincipal | graceOnInterest | extraTerms | newInterestRate |
+ | 21 February 2026 | 23 January 2026 | 21 March 2026 | | | | |
+ Then Loan has 360.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 |
+ | | | 21 January 2026 | | 480.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 0 | 21 January 2026 | 21 January 2026 | 360.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 |
+ | 2 | 59 | 21 March 2026 | | 240.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 |
+ | 3 | 31 | 21 April 2026 | | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 |
+ | 4 | 30 | 21 May 2026 | | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 480.0 | 0.0 | 0.0 | 0.0 | 480.0 | 120.0 | 0.0 | 0.0 | 360.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 480.0 | 0.0 | 0.0 | 0.0 | 0.0 | 480.0 | false |
+ | 21 January 2026 | Down Payment | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 | 360.0 | false |
+ # --- Step 2: ReAge for 15 monthly installments starting 21 March 2026 ---
+ When Admin creates a Loan re-aging transaction with the following data:
+ | frequencyNumber | frequencyType | startDate | numberOfInstallments | reAgeInterestHandling |
+ | 1 | MONTHS | 21 March 2026 | 15 | DEFAULT |
+ Then Loan has 360.0 outstanding amount
+ And Loan Repayment schedule has 17 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 |
+ | | | 21 January 2026 | | 480.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 0 | 21 January 2026 | 21 January 2026 | 360.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 |
+ | 2 | 2 | 23 January 2026 | 23 January 2026 | 360.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
+ | 3 | 57 | 21 March 2026 | | 336.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 4 | 31 | 21 April 2026 | | 312.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 5 | 30 | 21 May 2026 | | 288.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 6 | 31 | 21 June 2026 | | 264.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 7 | 30 | 21 July 2026 | | 240.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 8 | 31 | 21 August 2026 | | 216.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 9 | 31 | 21 September 2026 | | 192.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 10 | 30 | 21 October 2026 | | 168.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 11 | 31 | 21 November 2026 | | 144.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 12 | 30 | 21 December 2026 | | 120.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 13 | 31 | 21 January 2027 | | 96.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 14 | 31 | 21 February 2027 | | 72.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 15 | 28 | 21 March 2027 | | 48.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 16 | 31 | 21 April 2027 | | 24.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 17 | 30 | 21 May 2027 | | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 480.0 | 0.0 | 0.0 | 0.0 | 480.0 | 120.0 | 0.0 | 0.0 | 360.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 480.0 | 0.0 | 0.0 | 0.0 | 0.0 | 480.0 | false |
+ | 21 January 2026 | Down Payment | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 | 360.0 | false |
+ | 23 January 2026 | Re-age | 360.0 | 360.0 | 0.0 | 0.0 | 0.0 | 0.0 | false |
+ # --- Step 3: BUG TRIGGER: Reschedule again (push schedule 2 more months) ---
+ When Admin sets the business date to "28 January 2026"
+ When Admin creates and approves Loan reschedule with the following data:
+ | rescheduleFromDate | submittedOnDate | adjustedDueDate | graceOnPrincipal | graceOnInterest | extraTerms | newInterestRate |
+ | 21 March 2026 | 28 January 2026 | 21 May 2026 | | | | |
+ # After reschedule 2 the 15 ReAge installments must cascade by 2 months: first reAge installment
+ # moves from 21 March 2026 to 21 May 2026 and the last one moves from 21 May 2027 to 21 July 2027.
+ # The bug : the second reschedule silently leaves the schedule unchanged - i.e. 21 July 2027
+ # does not exist in the schedule.
+ Then Loan has 360.0 outstanding amount
+ Then Loan Repayment schedule has 17 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 |
+ | | | 21 January 2026 | | 480.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 0 | 21 January 2026 | 21 January 2026 | 360.0 | 120.0 | 0.0 | 0.0 | 0.0 | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 |
+ | 2 | 2 | 23 January 2026 | 23 January 2026 | 360.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
+ | 3 | 118 | 21 May 2026 | | 336.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 4 | 31 | 21 June 2026 | | 312.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 5 | 30 | 21 July 2026 | | 288.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 6 | 31 | 21 August 2026 | | 264.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 7 | 31 | 21 September 2026 | | 240.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 8 | 30 | 21 October 2026 | | 216.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 9 | 31 | 21 November 2026 | | 192.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 10 | 30 | 21 December 2026 | | 168.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 11 | 31 | 21 January 2027 | | 144.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 12 | 31 | 21 February 2027 | | 120.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 13 | 28 | 21 March 2027 | | 96.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 14 | 31 | 21 April 2027 | | 72.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 15 | 30 | 21 May 2027 | | 48.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 16 | 31 | 21 June 2027 | | 24.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ | 17 | 30 | 21 July 2027 | | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 | 0.0 | 0.0 | 0.0 | 24.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 480.0 | 0.0 | 0.0 | 0.0 | 480.0 | 120.0 | 0.0 | 0.0 | 360.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 480.0 | 0.0 | 0.0 | 0.0 | 0.0 | 480.0 | false |
+ | 21 January 2026 | Down Payment | 120.0 | 120.0 | 0.0 | 0.0 | 0.0 | 360.0 | false |
+ | 23 January 2026 | Re-age | 360.0 | 360.0 | 0.0 | 0.0 | 0.0 | 0.0 | false |
+
+ @TestRailId:C78850
+ Scenario: Verify that reschedule after ReAge produces correct schedule and balance for non-interest bearing single-installment product
+ When Admin sets the business date to "21 January 2026"
+ When Admin creates a client with random data
+ When Admin set "LP1_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule
+ When 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 |
+ | LP1_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 21 January 2026 | 150 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 30 | DAYS | 30 | DAYS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
+ And Admin successfully approves the loan on "21 January 2026" with "150" amount and expected disbursement date on "21 January 2026"
+ When Admin successfully disburse the loan on "21 January 2026" with "150" EUR transaction amount
+ Then Loan has 150.0 outstanding amount
+ Then Loan Repayment schedule has 1 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 |
+ | | | 21 January 2026 | | 150.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 30 | 20 February 2026 | | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | 150.0 | false |
+ # --- Step 1: Reschedule (push 20 Feb installment to 22 Mar) ---
+ When Admin sets the business date to "23 January 2026"
+ When Admin creates and approves Loan reschedule with the following data:
+ | rescheduleFromDate | submittedOnDate | adjustedDueDate | graceOnPrincipal | graceOnInterest | extraTerms | newInterestRate |
+ | 20 February 2026 | 23 January 2026 | 22 March 2026 | | | | |
+ Then Loan has 150.0 outstanding amount
+ And Loan Repayment schedule has 1 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 |
+ | | | 21 January 2026 | | 150.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 60 | 22 March 2026 | | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | 150.0 | false |
+ # --- Step 2: ReAge for 15 monthly installments starting 22 March 2026 ---
+ When Admin creates a Loan re-aging transaction with the following data:
+ | frequencyNumber | frequencyType | startDate | numberOfInstallments | reAgeInterestHandling |
+ | 1 | MONTHS | 22 March 2026 | 15 | DEFAULT |
+ Then Loan has 150.0 outstanding amount
+ And Loan Repayment schedule has 16 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 |
+ | | | 21 January 2026 | | 150.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 2 | 23 January 2026 | 23 January 2026 | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
+ | 2 | 58 | 22 March 2026 | | 140.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 3 | 31 | 22 April 2026 | | 130.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 4 | 30 | 22 May 2026 | | 120.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 5 | 31 | 22 June 2026 | | 110.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 6 | 30 | 22 July 2026 | | 100.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 7 | 31 | 22 August 2026 | | 90.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 8 | 31 | 22 September 2026 | | 80.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 9 | 30 | 22 October 2026 | | 70.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 10 | 31 | 22 November 2026 | | 60.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 11 | 30 | 22 December 2026 | | 50.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 12 | 31 | 22 January 2027 | | 40.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 13 | 31 | 22 February 2027 | | 30.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 14 | 28 | 22 March 2027 | | 20.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 15 | 31 | 22 April 2027 | | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 16 | 30 | 22 May 2027 | | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | 150.0 | false |
+ | 23 January 2026 | Re-age | 150.0 | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | false |
+ # --- Step 3: BUG TRIGGER: Reschedule again (push schedule 2 more months: 22 Mar -> 22 May) ---
+ When Admin sets the business date to "28 January 2026"
+ When Admin creates and approves Loan reschedule with the following data:
+ | rescheduleFromDate | submittedOnDate | adjustedDueDate | graceOnPrincipal | graceOnInterest | extraTerms | newInterestRate |
+ | 22 March 2026 | 28 January 2026 | 22 May 2026 | | | | |
+ # After reschedule 2 the 15 ReAge installments must each shift by +61 days (22 Mar 2026 -> 22 May 2026).
+ # Note: fixed-day offset means the 22nd-of-month alignment drifts in months with different lengths.
+ # Bug today: schedule unchanged - 22 July 2027 is missing because the second reschedule did not shift
+ # the schedule, identical defect to scenario C78849
+ Then Loan has 150.0 outstanding amount
+ Then Loan Repayment schedule has 16 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 |
+ | | | 21 January 2026 | | 150.0 | | | 0.0 | | 0.0 | 0.0 | | | |
+ | 1 | 2 | 23 January 2026 | 23 January 2026 | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
+ | 2 | 119 | 22 May 2026 | | 140.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 3 | 31 | 22 June 2026 | | 130.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 4 | 30 | 22 July 2026 | | 120.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 5 | 31 | 22 August 2026 | | 110.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 6 | 30 | 21 September 2026 | | 100.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 7 | 31 | 22 October 2026 | | 90.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 8 | 31 | 22 November 2026 | | 80.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 9 | 30 | 22 December 2026 | | 70.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 10 | 31 | 22 January 2027 | | 60.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 11 | 30 | 21 February 2027 | | 50.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 12 | 31 | 24 March 2027 | | 40.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 13 | 31 | 24 April 2027 | | 30.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 14 | 28 | 22 May 2027 | | 20.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 15 | 31 | 22 June 2027 | | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ | 16 | 30 | 22 July 2027 | | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 |
+ And Loan Repayment schedule has the following data in Total row:
+ | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
+ | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 |
+ And Loan Transactions tab has the following data:
+ | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
+ | 21 January 2026 | Disbursement | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | 150.0 | false |
+ | 23 January 2026 | Re-age | 150.0 | 150.0 | 0.0 | 0.0 | 0.0 | 0.0 | false |
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 cd47b80..417c76a 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
@@ -321,27 +321,49 @@
case INTEREST_RATE_FROM_INSTALLMENT -> handleChangeInterestRate(installments, termVariationsData, scheduleModel);
case EXTEND_REPAYMENT_PERIOD ->
handleExtraRepaymentPeriod(installments, termVariationsData, scheduleModel, ctx.getAlreadyProcessedTransactions());
- case DUE_DATE -> handleDueDateChangeOnRepaymentPeriod(installments, termVariationsData, scheduleModel);
+ case DUE_DATE -> handleDueDateChangeOnRepaymentPeriod(installments, termVariationsData, scheduleModel,
+ ctx.getAlreadyProcessedTransactions());
default -> throw new IllegalStateException("Unhandled LoanTermVariationType.");
}
}
private void handleDueDateChangeOnRepaymentPeriod(final List<LoanRepaymentScheduleInstallment> installments,
- final LoanTermVariationsData termVariationsData, final ProgressiveLoanInterestScheduleModel scheduleModel) {
+ final LoanTermVariationsData termVariationsData, final ProgressiveLoanInterestScheduleModel scheduleModel,
+ final List<LoanTransaction> alreadyProcessedTransactions) {
final LocalDate targetRepaymentPeriodDueDate = termVariationsData.getTermVariationApplicableFrom();
final LocalDate newDueDate = termVariationsData.getDateValue();
final Loan loan = installments.getFirst().getLoan();
- final LoanApplicationTerms loanApplicationTerms = new LoanApplicationTerms.Builder() //
- .currency(loan.getCurrency().toData()) //
- .repaymentEvery(loan.getLoanProductRelatedDetail().getRepayEvery()) //
- .repaymentPeriodFrequencyType(loan.getLoanProductRelatedDetail().getRepaymentPeriodFrequencyType()) //
- .fixedLength(loan.getLoanProductRelatedDetail().getFixedLength()) //
- .seedDate(newDueDate) //
- .build();
- emiCalculator.changeDueDate(scheduleModel, loanApplicationTerms, targetRepaymentPeriodDueDate, newDueDate);
+
+ // Check if the target due date exists in the scheduleModel.
+ final boolean targetExistsInScheduleModel = scheduleModel.repaymentPeriods().stream()
+ .anyMatch(rp -> rp.getDueDate().equals(targetRepaymentPeriodDueDate));
+
+ // Determine if the target installment is re-aged. After a ReAge, base installments were zeroed
+ // by liftOutstandingBalances and re-aged installments carry the redistributed principal.
+ // A due-date variation that targets a non-re-aged base installment AFTER a ReAge was processed
+ // is stale — applying the scheduleModel's principal/interest would restore zeroed amounts.
+ final Optional<LoanRepaymentScheduleInstallment> targetInstallment = installments.stream()
+ .filter(inst -> inst.getDueDate().equals(targetRepaymentPeriodDueDate)).findFirst();
+ final boolean targetIsReAged = targetInstallment.map(LoanRepaymentScheduleInstallment::isReAged).orElse(false);
+ final boolean reAgeAlreadyProcessed = alreadyProcessedTransactions.stream().anyMatch(t -> t.isReAge() && t.isNotReversed());
+ final boolean isStalePreReAgeVariation = !targetIsReAged && reAgeAlreadyProcessed;
+
+ if (targetExistsInScheduleModel && !isStalePreReAgeVariation) {
+ final LoanApplicationTerms loanApplicationTerms = new LoanApplicationTerms.Builder() //
+ .currency(loan.getCurrency().toData()) //
+ .repaymentEvery(loan.getLoanProductRelatedDetail().getRepayEvery()) //
+ .repaymentPeriodFrequencyType(loan.getLoanProductRelatedDetail().getRepaymentPeriodFrequencyType()) //
+ .fixedLength(loan.getLoanProductRelatedDetail().getFixedLength()) //
+ .seedDate(newDueDate) //
+ .build();
+ emiCalculator.changeDueDate(scheduleModel, loanApplicationTerms, targetRepaymentPeriodDueDate, newDueDate);
+ }
+
+ final boolean dateShiftOnly = isStalePreReAgeVariation || !targetExistsInScheduleModel;
IntStream.range(0, installments.size()).filter(i -> installments.get(i).getDueDate().equals(targetRepaymentPeriodDueDate))
.findFirst().ifPresent(targetInstallmentIndex -> {
+ final long dateOffsetDays = ChronoUnit.DAYS.between(targetRepaymentPeriodDueDate, newDueDate);
long scheduleModelStartIndex = installments.subList(0, targetInstallmentIndex).stream()
.filter(inst -> !inst.isDownPayment() && !inst.isAdditional()).count();
@@ -350,8 +372,12 @@
if (installment.isDownPayment() || installment.isAdditional()) {
continue;
}
- if (scheduleModelStartIndex >= scheduleModel.repaymentPeriods().size()) {
- break;
+ if (dateShiftOnly || scheduleModelStartIndex >= scheduleModel.repaymentPeriods().size()) {
+ if (isNotObligationsMet(installment)) {
+ installment.updateFromDate(installment.getFromDate().plusDays(dateOffsetDays));
+ installment.updateDueDate(installment.getDueDate().plusDays(dateOffsetDays));
+ }
+ continue;
}
final RepaymentPeriod repaymentPeriod = scheduleModel.repaymentPeriods().get((int) scheduleModelStartIndex);
@@ -367,7 +393,15 @@
}
});
- mergeAdditionalInstallmentsBeforeMaturityDate(installments, scheduleModel, loan);
+ // When the target date was already consumed by generate() and a ReAge was processed,
+ // the re-aged installments still carry their original dates. Shift them to apply the reschedule.
+ if (targetInstallment.isEmpty() && reAgeAlreadyProcessed) {
+ shiftReAgedInstallmentsAfterReschedule(installments, newDueDate, loan);
+ }
+
+ if (!dateShiftOnly) {
+ mergeAdditionalInstallmentsBeforeMaturityDate(installments, scheduleModel, loan);
+ }
installments.sort(Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate));
int installmentNumber = 1;
@@ -376,6 +410,40 @@
}
}
+ private void shiftReAgedInstallmentsAfterReschedule(final List<LoanRepaymentScheduleInstallment> installments,
+ final LocalDate newDueDate, final Loan loan) {
+ final Optional<LoanRepaymentScheduleInstallment> firstUnmetReAged = installments.stream()
+ .filter(inst -> inst.isReAged() && isNotObligationsMet(inst))
+ .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate));
+
+ if (firstUnmetReAged.isEmpty() || !newDueDate.isAfter(firstUnmetReAged.get().getDueDate())) {
+ return;
+ }
+
+ final LocalDate firstReAgedDueDate = firstUnmetReAged.get().getDueDate();
+ final PeriodFrequencyType frequencyType = loan.getLoanProductRelatedDetail().getRepaymentPeriodFrequencyType();
+
+ for (final LoanRepaymentScheduleInstallment installment : installments) {
+ if (!installment.isReAged() || !isNotObligationsMet(installment)) {
+ continue;
+ }
+ final LocalDate shiftedDueDate = shiftDateByFrequency(installment.getDueDate(), firstReAgedDueDate, newDueDate, frequencyType);
+ installment.updateDueDate(shiftedDueDate);
+ }
+ reprocessInstallments(installments);
+ }
+
+ private LocalDate shiftDateByFrequency(final LocalDate dateToShift, final LocalDate fromDate, final LocalDate toDate,
+ final PeriodFrequencyType frequencyType) {
+ return switch (frequencyType) {
+ case DAYS -> dateToShift.plusDays(ChronoUnit.DAYS.between(fromDate, toDate));
+ case WEEKS -> dateToShift.plusWeeks(ChronoUnit.WEEKS.between(fromDate, toDate));
+ case MONTHS -> dateToShift.plusMonths(ChronoUnit.MONTHS.between(fromDate, toDate));
+ case YEARS -> dateToShift.plusYears(ChronoUnit.YEARS.between(fromDate, toDate));
+ default -> dateToShift.plusDays(ChronoUnit.DAYS.between(fromDate, toDate));
+ };
+ }
+
private void mergeAdditionalInstallmentsBeforeMaturityDate(final List<LoanRepaymentScheduleInstallment> installments,
final ProgressiveLoanInterestScheduleModel scheduleModel, final Loan loan) {
final LocalDate newMaturityDate = scheduleModel.repaymentPeriods().getLast().getDueDate();
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java
index 48923fc..91f8dc8 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java
@@ -24,10 +24,12 @@
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdStatus;
import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse;
+import org.apache.fineract.client.models.PostCreateRescheduleLoansRequest;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansRequest;
+import org.apache.fineract.client.models.PostUpdateRescheduleLoansRequest;
import org.apache.fineract.integrationtests.client.FeignIntegrationTest;
import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper;
import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper;
@@ -195,6 +197,20 @@
return loanId;
}
+ protected Long createApproveAndDisburseProgressiveLoan(Long clientId, Long productId, String date, Double principal,
+ Integer numberOfRepayments) {
+ PostLoansRequest applyRequest = LoanRequestBuilders.applyProgressiveLoan(clientId, productId, date, principal, numberOfRepayments);
+ Long loanId = applyForLoan(applyRequest);
+
+ PostLoansLoanIdRequest approveRequest = LoanRequestBuilders.approveLoan(principal, date);
+ approveLoan(loanId, approveRequest);
+
+ PostLoansLoanIdRequest disburseRequest = LoanRequestBuilders.disburseLoan(principal, date);
+ disburseLoan(loanId, disburseRequest);
+
+ return loanId;
+ }
+
protected Long createApprovedLoan(Long clientId, Long productId, String date, Double principal, Integer numberOfRepayments) {
PostLoansRequest applyRequest = LoanRequestBuilders.applyLoan(clientId, productId, date, principal, numberOfRepayments);
Long loanId = applyForLoan(applyRequest);
@@ -232,4 +248,27 @@
protected GetLoansLoanIdTransactionsTemplateResponse getPrepaymentAmount(Long loanId, String transactionDate, String dateFormat) {
return transactionHelper.getPrepaymentAmount(loanId, transactionDate, dateFormat);
}
+
+ protected Long createRescheduleRequest(PostCreateRescheduleLoansRequest request) {
+ return loanHelper.createRescheduleRequest(request);
+ }
+
+ protected Long approveRescheduleRequest(Long scheduleId, PostUpdateRescheduleLoansRequest request) {
+ return loanHelper.approveRescheduleRequest(scheduleId, request);
+ }
+
+ protected void createAndApproveReschedule(Long loanId, String submittedOnDate, String rescheduleFromDate, String adjustedDueDate) {
+ loanHelper.createAndApproveRescheduleRequest(
+ LoanRequestBuilders.rescheduleRequest(loanId, submittedOnDate, rescheduleFromDate, adjustedDueDate),
+ LoanRequestBuilders.approveReschedule(submittedOnDate));
+ }
+
+ protected Long reAge(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ return transactionHelper.reAge(loanId, request);
+ }
+
+ protected PostLoansLoanIdTransactionsRequest reAge(String startDate, String frequencyType, Integer frequencyNumber,
+ Integer numberOfInstallments) {
+ return LoanRequestBuilders.reAge(startDate, frequencyType, frequencyNumber, numberOfInstallments);
+ }
}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
index 62da207..15650e1 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
@@ -27,6 +27,8 @@
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostCreateRescheduleLoansRequest;
+import org.apache.fineract.client.models.PostCreateRescheduleLoansResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
@@ -34,6 +36,8 @@
import org.apache.fineract.client.models.PostLoansOriginatorData;
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse;
+import org.apache.fineract.client.models.PostUpdateRescheduleLoansRequest;
+import org.apache.fineract.client.models.PostUpdateRescheduleLoansResponse;
import org.apache.fineract.integrationtests.common.Utils;
public class FeignLoanHelper {
@@ -201,6 +205,23 @@
return buildSubmittedLoanRequest(clientId, createSimpleLoanProduct());
}
+ public Long createRescheduleRequest(PostCreateRescheduleLoansRequest request) {
+ PostCreateRescheduleLoansResponse response = ok(() -> fineractClient.rescheduleLoans().createLoanRescheduleRequest(request));
+ return response.getResourceId();
+ }
+
+ public Long approveRescheduleRequest(Long scheduleId, PostUpdateRescheduleLoansRequest request) {
+ PostUpdateRescheduleLoansResponse response = ok(
+ () -> fineractClient.rescheduleLoans().updateLoanRescheduleRequest(scheduleId, request, "approve"));
+ return response.getResourceId();
+ }
+
+ public void createAndApproveRescheduleRequest(PostCreateRescheduleLoansRequest createRequest,
+ PostUpdateRescheduleLoansRequest approveRequest) {
+ Long scheduleId = createRescheduleRequest(createRequest);
+ approveRescheduleRequest(scheduleId, approveRequest);
+ }
+
private PostLoansRequest buildSubmittedLoanRequest(Long clientId, Long productId) {
String todayDate = org.apache.fineract.integrationtests.common.Utils.dateFormatter
.format(org.apache.fineract.integrationtests.common.Utils.getLocalDateOfTenant());
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java
index 7fbfe4c..b52124c 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java
@@ -77,6 +77,18 @@
return response.getResourceId();
}
+ public Long reAge(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ PostLoansLoanIdTransactionsResponse response = ok(
+ () -> fineractClient.loanTransactions().executeLoanTransaction(loanId, request, Map.of("command", "reAge")));
+ return response.getResourceId();
+ }
+
+ public Long undoReAge(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ PostLoansLoanIdTransactionsResponse response = ok(
+ () -> fineractClient.loanTransactions().executeLoanTransaction(loanId, request, Map.of("command", "undoReAge")));
+ return response.getResourceId();
+ }
+
public void undoRepayment(Long loanId, Long transactionId, String transactionDate) {
PostLoansLoanIdTransactionsTransactionIdRequest request = new PostLoansLoanIdTransactionsTransactionIdRequest();
request.setTransactionDate(transactionDate);
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java
index 41ef8f5..b34ab56 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java
@@ -31,6 +31,7 @@
import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.InterestType;
import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.RepaymentFrequencyType;
import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.RescheduleStrategyMethod;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.TransactionProcessingStrategyCode;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
@@ -139,6 +140,17 @@
.rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD);
}
+ /**
+ * Progressive loan product with advanced-payment-allocation-strategy and default payment allocation. Use this
+ * instead of {@link #fourInstallmentsProgressive()} when the product needs to actually create loans (PROGRESSIVE
+ * schedule type requires advanced-payment-allocation-strategy with explicit payment allocation configuration).
+ */
+ default PostLoanProductsRequest fourInstallmentsProgressiveWithAdvancedAllocation() {
+ return fourInstallmentsProgressive()//
+ .transactionProcessingStrategyCode(TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)//
+ .paymentAllocation(List.of(LoanRequestBuilders.defaultPaymentAllocation()));
+ }
+
default PostLoanProductsRequest fourInstallmentsProgressiveWithCapitalizedIncome() {
return fourInstallmentsProgressive().enableIncomeCapitalization(true)//
.capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT)//
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java
index 683f8a3..926c54b 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java
@@ -19,9 +19,16 @@
package org.apache.fineract.integrationtests.client.feign.modules;
import java.math.BigDecimal;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+import org.apache.fineract.client.models.AdvancedPaymentData;
+import org.apache.fineract.client.models.PaymentAllocationOrder;
+import org.apache.fineract.client.models.PostCreateRescheduleLoansRequest;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansRequest;
+import org.apache.fineract.client.models.PostUpdateRescheduleLoansRequest;
public final class LoanRequestBuilders {
@@ -75,7 +82,14 @@
public static PostLoansRequest applyProgressiveLoan(Long clientId, Long productId, String submittedOnDate, Double principal,
Integer numberOfRepayments, Double interestRate) {
- return applyCumulativeLoan(clientId, productId, submittedOnDate, principal, numberOfRepayments, interestRate);
+ return applyCumulativeLoan(clientId, productId, submittedOnDate, principal, numberOfRepayments, interestRate)
+ .transactionProcessingStrategyCode(LoanTestData.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
+ }
+
+ public static PostLoansRequest applyProgressiveLoan(Long clientId, Long productId, String submittedOnDate, Double principal,
+ Integer numberOfRepayments) {
+ return applyLoan(clientId, productId, submittedOnDate, principal, numberOfRepayments)
+ .transactionProcessingStrategyCode(LoanTestData.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
}
public static PostLoansLoanIdRequest approveLoan(Double approvedAmount, String approvedOnDate) {
@@ -132,4 +146,91 @@
public static PostLoansLoanIdTransactionsRequest waiveInterest(Double amount, String transactionDate) {
return makeWaiver(amount, transactionDate);
}
+
+ /**
+ * Creates a reschedule request that shifts a due date. Uses rescheduleReasonId=1 (default seed data).
+ */
+ public static PostCreateRescheduleLoansRequest rescheduleRequest(Long loanId, String submittedOnDate, String rescheduleFromDate,
+ String adjustedDueDate) {
+ return rescheduleRequest(loanId, submittedOnDate, rescheduleFromDate, adjustedDueDate, 1L);
+ }
+
+ public static PostCreateRescheduleLoansRequest rescheduleRequest(Long loanId, String submittedOnDate, String rescheduleFromDate,
+ String adjustedDueDate, Long rescheduleReasonId) {
+ return new PostCreateRescheduleLoansRequest()//
+ .loanId(loanId)//
+ .submittedOnDate(submittedOnDate)//
+ .rescheduleFromDate(rescheduleFromDate)//
+ .adjustedDueDate(adjustedDueDate)//
+ .rescheduleReasonId(rescheduleReasonId)//
+ .locale(LoanTestData.LOCALE)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN);
+ }
+
+ public static PostUpdateRescheduleLoansRequest approveReschedule(String approvedOnDate) {
+ return new PostUpdateRescheduleLoansRequest()//
+ .approvedOnDate(approvedOnDate)//
+ .locale(LoanTestData.LOCALE)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN);
+ }
+
+ /**
+ * Creates a reAge request for non-interest-bearing loans (no interest handling needed).
+ */
+ public static PostLoansLoanIdTransactionsRequest reAge(String startDate, String frequencyType, Integer frequencyNumber,
+ Integer numberOfInstallments) {
+ return reAge(startDate, frequencyType, frequencyNumber, numberOfInstallments, null);
+ }
+
+ /**
+ * Creates a reAge request with explicit interest handling.
+ *
+ * @param reAgeInterestHandling
+ * e.g. "EQUAL_AMORTIZATION_PAYABLE_INTEREST", "EQUAL_AMORTIZATION_FULL_INTEREST", or null for
+ * non-interest-bearing loans
+ */
+ public static PostLoansLoanIdTransactionsRequest reAge(String startDate, String frequencyType, Integer frequencyNumber,
+ Integer numberOfInstallments, String reAgeInterestHandling) {
+ PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
+ request.setStartDate(startDate);
+ request.setFrequencyType(frequencyType);
+ request.setFrequencyNumber(frequencyNumber);
+ request.setNumberOfInstallments(numberOfInstallments);
+ if (reAgeInterestHandling != null) {
+ request.setReAgeInterestHandling(reAgeInterestHandling);
+ }
+ request.setLocale(LoanTestData.LOCALE);
+ request.setDateFormat(LoanTestData.DATETIME_PATTERN);
+ return request;
+ }
+
+ /**
+ * Creates a DEFAULT payment allocation with NEXT_INSTALLMENT future rule. Suitable for most progressive loan
+ * products using advanced-payment-allocation-strategy.
+ */
+ public static AdvancedPaymentData defaultPaymentAllocation() {
+ return paymentAllocation("DEFAULT", "NEXT_INSTALLMENT");
+ }
+
+ /**
+ * Creates a payment allocation for a specific transaction type and future installment allocation rule.
+ *
+ * @param transactionType
+ * e.g. "DEFAULT", "REPAYMENT", "DOWN_PAYMENT", "MERCHANT_ISSUED_REFUND"
+ * @param futureInstallmentAllocationRule
+ * e.g. "NEXT_INSTALLMENT", "LAST_INSTALLMENT", "NEXT_LAST_INSTALLMENT"
+ */
+ public static AdvancedPaymentData paymentAllocation(String transactionType, String futureInstallmentAllocationRule) {
+ AdvancedPaymentData data = new AdvancedPaymentData();
+ data.setTransactionType(transactionType);
+ data.setFutureInstallmentAllocationRule(futureInstallmentAllocationRule);
+ AtomicInteger order = new AtomicInteger(1);
+ List<PaymentAllocationOrder> orders = Stream
+ .of("PAST_DUE_PENALTY", "PAST_DUE_FEE", "PAST_DUE_PRINCIPAL", "PAST_DUE_INTEREST", "DUE_PENALTY", "DUE_FEE",
+ "DUE_PRINCIPAL", "DUE_INTEREST", "IN_ADVANCE_PENALTY", "IN_ADVANCE_FEE", "IN_ADVANCE_PRINCIPAL",
+ "IN_ADVANCE_INTEREST")
+ .map(rule -> new PaymentAllocationOrder().paymentAllocationRule(rule).order(order.getAndIncrement())).toList();
+ data.setPaymentAllocationOrder(orders);
+ return data;
+ }
}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanRescheduleAfterReAgeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanRescheduleAfterReAgeTest.java
new file mode 100644
index 0000000..9dcb001
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanRescheduleAfterReAgeTest.java
@@ -0,0 +1,165 @@
+/**
+ * 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.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import java.util.List;
+import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
+import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.GetLoansLoanIdStatus;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that the sequence Reschedule -> ReAge -> Reschedule produces a correct repayment schedule.
+ */
+public class FeignLoanRescheduleAfterReAgeTest extends FeignLoanTestBase {
+
+ @Test
+ void testRescheduleAfterReAgeWithDownpayment() {
+ runAt("2026-04-21", () -> {
+ Long clientId = createClient("21 April 2026");
+ Long productId = createLoanProduct(createProgressiveNoInterestWithDownpayment());
+
+ Long loanId = createApproveAndDisburseProgressiveLoan(clientId, productId, "21 April 2026", 540.0, 2);
+
+ GetLoansLoanIdResponse loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, GetLoansLoanIdStatus::getActive);
+ assertEquals(360.0, Utils.getDoubleValue(loan.getSummary().getTotalOutstanding()));
+
+ createAndApproveReschedule(loanId, "21 April 2026", "21 May 2026", "21 June 2026");
+
+ reAge(loanId, reAge("23 May 2026", LoanTestData.RepaymentFrequencyType.MONTHS_STRING, 1, 15));
+
+ loan = getLoanDetails(loanId);
+ assertEquals(360.0, Utils.getDoubleValue(loan.getSummary().getTotalOutstanding()));
+
+ createAndApproveReschedule(loanId, "21 April 2026", "23 May 2026", "23 July 2026");
+
+ loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, GetLoansLoanIdStatus::getActive);
+
+ double totalOutstanding = Utils.getDoubleValue(loan.getSummary().getTotalOutstanding());
+ assertEquals(360.0, totalOutstanding, "Outstanding balance should equal principal minus downpayment");
+
+ double totalPrincipalDue = loan.getRepaymentSchedule().getPeriods().stream()//
+ .filter(p -> p.getPeriod() != null)//
+ .mapToDouble(p -> Utils.getDoubleValue(p.getPrincipalDue()))//
+ .sum();
+ assertEquals(540.0, totalPrincipalDue, "Total principal due must equal loan amount");
+
+ List<GetLoansLoanIdRepaymentPeriod> periods = loan.getRepaymentSchedule().getPeriods();
+ for (GetLoansLoanIdRepaymentPeriod period : periods) {
+ if (period.getPrincipalLoanBalanceOutstanding() != null) {
+ assertTrue(Utils.getDoubleValue(period.getPrincipalLoanBalanceOutstanding()) >= 0,
+ "Balance should not be negative for period " + period.getPeriod());
+ }
+ }
+
+ GetLoansLoanIdRepaymentPeriod firstUnpaid = periods.stream()//
+ .filter(p -> p.getPeriod() != null && Utils.getDoubleValue(p.getPrincipalOutstanding()) > 0)//
+ .findFirst().orElseThrow(() -> new AssertionError("Expected at least one unpaid installment"));
+ assertEquals(2026, firstUnpaid.getDueDate().getYear());
+ assertEquals(7, firstUnpaid.getDueDate().getMonthValue(), "First unpaid installment should be in July (shifted by 2 months)");
+ });
+ }
+
+ @Test
+ void testRescheduleAfterReAgeWithoutDownpayment() {
+ runAt("2026-04-20", () -> {
+ Long clientId = createClient("20 April 2026");
+ Long productId = createLoanProduct(createProgressiveNoInterestNoDownpayment());
+
+ Long loanId = createApproveAndDisburseProgressiveLoan(clientId, productId, "20 April 2026", 600.0, 1);
+
+ GetLoansLoanIdResponse loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, GetLoansLoanIdStatus::getActive);
+ assertEquals(600.0, Utils.getDoubleValue(loan.getSummary().getTotalOutstanding()));
+
+ createAndApproveReschedule(loanId, "20 April 2026", "20 May 2026", "18 August 2026");
+
+ reAge(loanId, reAge("04 June 2026", LoanTestData.RepaymentFrequencyType.DAYS_STRING, 30, 20));
+
+ loan = getLoanDetails(loanId);
+ assertEquals(600.0, Utils.getDoubleValue(loan.getSummary().getTotalOutstanding()));
+
+ createAndApproveReschedule(loanId, "20 April 2026", "04 July 2026", "02 October 2026");
+
+ loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, GetLoansLoanIdStatus::getActive);
+
+ double totalOutstanding = Utils.getDoubleValue(loan.getSummary().getTotalOutstanding());
+ assertEquals(600.0, totalOutstanding, "Outstanding balance should be 600");
+
+ double totalPrincipalDue = loan.getRepaymentSchedule().getPeriods().stream()//
+ .filter(p -> p.getPeriod() != null)//
+ .mapToDouble(p -> Utils.getDoubleValue(p.getPrincipalDue()))//
+ .sum();
+ assertEquals(600.0, totalPrincipalDue, "Total principal due must equal loan amount");
+
+ List<GetLoansLoanIdRepaymentPeriod> periods = loan.getRepaymentSchedule().getPeriods();
+ for (GetLoansLoanIdRepaymentPeriod period : periods) {
+ if (period.getPrincipalLoanBalanceOutstanding() != null) {
+ assertTrue(Utils.getDoubleValue(period.getPrincipalLoanBalanceOutstanding()) >= 0,
+ "Balance should not be negative for period " + period.getPeriod());
+ }
+ }
+
+ boolean hasOctoberInstallment = periods.stream()//
+ .filter(p -> p.getPeriod() != null)//
+ .anyMatch(p -> p.getDueDate().getMonthValue() == 10 && p.getDueDate().getYear() == 2026);
+ assertTrue(hasOctoberInstallment, "Should have an installment shifted to October 2026");
+
+ boolean hasJulyFourthInstallment = periods.stream()//
+ .filter(p -> p.getPeriod() != null)//
+ .anyMatch(p -> p.getDueDate().getMonthValue() == 7 && p.getDueDate().getDayOfMonth() == 4
+ && p.getDueDate().getYear() == 2026);
+ assertFalse(hasJulyFourthInstallment, "Jul 4 installment should have been shifted");
+ });
+ }
+
+ private PostLoanProductsRequest createProgressiveNoInterestWithDownpayment() {
+ return customizeProduct(fourInstallmentsProgressiveWithAdvancedAllocation(), p -> p//
+ .numberOfRepayments(2)//
+ .interestRatePerPeriod(0.0)//
+ .repaymentEvery(1)//
+ .repaymentFrequencyType(LoanTestData.RepaymentFrequencyType.MONTHS_L)//
+ .enableDownPayment(true)//
+ .disbursedAmountPercentageForDownPayment(BigDecimal.valueOf(33.333333))//
+ .enableAutoRepaymentForDownPayment(true)//
+ .currencyCode("GBP"));
+ }
+
+ private PostLoanProductsRequest createProgressiveNoInterestNoDownpayment() {
+ return customizeProduct(fourInstallmentsProgressiveWithAdvancedAllocation(), p -> p//
+ .numberOfRepayments(1)//
+ .interestRatePerPeriod(0.0)//
+ .repaymentEvery(30)//
+ .repaymentFrequencyType(LoanTestData.RepaymentFrequencyType.DAYS_L)//
+ .enableDownPayment(false));
+ }
+
+}