This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit 6058837af2a42848d9f85b648a7bd39d5785ede5 Author: Attila Budai <[email protected]> AuthorDate: Tue Feb 24 22:04:21 2026 +0100 FINERACT-2421: fix reage accumulating periods --- .../resources/features/LoanReAgingPreview.feature | 56 +++++ .../loanproduct/calc/ProgressiveEMICalculator.java | 29 +++ .../data/ProgressiveLoanInterestScheduleModel.java | 14 +- .../loan/reaging/LoanReAgingIntegrationTest.java | 247 +++++++++++++++++++++ 4 files changed, 343 insertions(+), 3 deletions(-) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature index 0cf1deb9b0..07e8801c9d 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature @@ -2870,4 +2870,60 @@ Feature: LoanReAgingPreview When Loan Pay-off is made on "24 February 2026" Then Loan is closed with zero outstanding balance and it's all installments have obligations met + @TestRailId:C6000 @AdvancedPaymentAllocation + Scenario: Re-aging preview should not accumulate 1-day periods after repeated re-aging across month-end (PS-3004) + When Admin sets the business date to "28 January 2026" + When Admin creates a client with random data + 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_ADV_PYMNT_INTEREST_DAILY_INTEREST_RECALCULATION_CONTRACT_TERMINATION | 28 January 2026 | 100 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "28 January 2026" with "100" amount and expected disbursement date on "28 January 2026" + When Admin successfully disburse the loan on "28 January 2026" with "100" EUR transaction amount +# --- Re-age 4 times across late-January dates --- + When Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 28 February 2026 | 6 | + When Admin sets the business date to "29 January 2026" + And Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 28 February 2026 | 6 | + When Admin sets the business date to "30 January 2026" + And Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 28 February 2026 | 6 | + When Admin sets the business date to "31 January 2026" + And Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 28 February 2026 | 6 | +# --- Verify actual schedule: should be 8 periods (1 disbursement + 1 stub + 6 re-aged), NOT 12+ --- + Then Loan Repayment schedule has 7 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 | + | | | 28 January 2026 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 3 | 31 January 2026 | 28 January 2026 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 2 | 28 | 28 February 2026 | | 83.62 | 16.38 | 0.64 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 3 | 28 | 28 March 2026 | | 67.09 | 16.53 | 0.49 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 4 | 31 | 28 April 2026 | | 50.46 | 16.63 | 0.39 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 5 | 30 | 28 May 2026 | | 33.73 | 16.73 | 0.29 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 6 | 31 | 28 June 2026 | | 16.91 | 16.82 | 0.2 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 7 | 30 | 28 July 2026 | | 0.0 | 16.91 | 0.1 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | +# --- Now move to February and call re-aging preview --- + When Admin sets the business date to "01 February 2026" + And Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 28 February 2026 | 6 | +# --- Preview should also show correct number of periods, NOT duplicated --- + Then Loan Re-Aged Repayment schedule preview has 7 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 | + | | | 28 January 2026 | | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 4 | 01 February 2026 | 28 January 2026 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 2 | 27 | 28 February 2026 | | 83.62 | 16.38 | 0.64 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 3 | 28 | 28 March 2026 | | 67.09 | 16.53 | 0.49 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 4 | 31 | 28 April 2026 | | 50.46 | 16.63 | 0.39 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 5 | 30 | 28 May 2026 | | 33.73 | 16.73 | 0.29 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 6 | 31 | 28 June 2026 | | 16.91 | 16.82 | 0.2 | 0.0 | 0.0 | 17.02 | 0.0 | 0.0 | 0.0 | 17.02 | + | 7 | 30 | 28 July 2026 | | 0.0 | 16.91 | 0.1 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + + When Loan Pay-off is made on "01 February 2026" + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index b5de8612bb..77c9a48a5d 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -718,6 +718,8 @@ public final class ProgressiveEMICalculator implements EMICalculator { moveOutstandingAmountsFromPeriodsBeforeTransactionDate(scheduleModel.repaymentPeriods(), targetDate); + collapseIntermediateStubPeriods(scheduleModel); + final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generateTemporaryScheduleModel(loanApplicationTerms, mc, reAgePeriodStartDate, reAgePeriodStartDate); @@ -1082,6 +1084,31 @@ public final class ProgressiveEMICalculator implements EMICalculator { }); } + private void collapseIntermediateStubPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel) { + final List<RepaymentPeriod> periods = scheduleModel.repaymentPeriods(); + if (periods.size() <= 1) { + return; + } + // Only collapse if ALL periods are zero-EMI stubs (no principal due, no interest due, no paid amounts). + // This handles the repeated re-aging case where each re-age leaves behind a 1-day stub period, + // without affecting legitimate paid installments in multi-disbursement scenarios. + final boolean allPeriodsAreStubs = periods.stream() + .allMatch(rp -> rp.getEmi().isZero() && rp.getDuePrincipal().isZero() && rp.getDueInterest().isZero()); + if (!allPeriodsAreStubs) { + return; + } + final RepaymentPeriod firstPeriod = periods.getFirst(); + final RepaymentPeriod lastPeriod = periods.getLast(); + final LocalDate lastDueDate = lastPeriod.getDueDate(); + + firstPeriod.setDueDate(lastDueDate); + firstPeriod.getInterestPeriods().getLast().setDueDate(lastDueDate); + + periods.subList(1, periods.size()).clear(); + + calculateRateFactorForRepaymentPeriod(firstPeriod, scheduleModel); + } + private void calculateLastUnpaidRepaymentPeriodEMI(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate tillDate) { Money totalDuePaidDiff = scheduleModel.getTotalDuePrincipal().minus(scheduleModel.getTotalPaidPrincipal()); @@ -2012,6 +2039,8 @@ public final class ProgressiveEMICalculator implements EMICalculator { rp.setInterestMovedDownward(true); }); + collapseIntermediateStubPeriods(interestSchedule); + if (!originalMaturityDate.isBefore(transactionDate)) { createRepaymentPeriodForEarlyRepaidAmountsDuringReAgeing(interestSchedule, paidBalancesFromTransactionDate.getOutstandingPrincipal(), paidBalancesFromTransactionDate.getOutstandingInterest(), diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java index c02a23e2b0..1dabb865da 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java @@ -167,10 +167,18 @@ public class ProgressiveLoanInterestScheduleModel { if (repaymentPeriodDueDate == null) { return Optional.empty(); } - return repaymentPeriods.stream()// - .filter(repaymentPeriodItem -> DateUtils.isEqual(repaymentPeriodItem.getFromDate(), repaymentPeriodFromDate) - && DateUtils.isEqual(repaymentPeriodItem.getDueDate(), repaymentPeriodDueDate))// + // Exact match first + Optional<RepaymentPeriod> result = repaymentPeriods.stream() + .filter(rp -> DateUtils.isEqual(rp.getFromDate(), repaymentPeriodFromDate) + && DateUtils.isEqual(rp.getDueDate(), repaymentPeriodDueDate)) .findFirst(); + if (result.isEmpty()) { + // Fallback: find a period that encompasses the requested date range + // This handles collapsed stub periods where multiple periods were merged into one + result = repaymentPeriods.stream().filter(rp -> !DateUtils.isAfter(rp.getFromDate(), repaymentPeriodFromDate) + && !DateUtils.isBefore(rp.getDueDate(), repaymentPeriodDueDate)).findFirst(); + } + return result; } public List<RepaymentPeriod> getRelatedRepaymentPeriods(final LocalDate calculateFromRepaymentPeriodDueDate) { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java index 2b2c728aff..a258b0c0fd 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java @@ -24,12 +24,15 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.restassured.path.json.JsonPath; import java.math.BigDecimal; import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; +import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.PostChargesResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsResponse; @@ -1024,6 +1027,250 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest { }); } + @Test + public void test_LoanReAge_RepeatedReAgeDoesNotCreateDuplicatePeriods() { + AtomicLong createdLoanId = new AtomicLong(); + + runAt("28 January 2026", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 6; + int repaymentEvery = 1; + + // Create interest-bearing progressive loan product + PostLoanProductsRequest product = create4IProgressive() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L); // + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 1000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "28 January 2026", amount, numberOfRepayments)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestRatePerPeriod(BigDecimal.valueOf(10.0))// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "28 January 2026")); + + Long loanId = approvedLoanResult.getLoanId(); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(amount), "28 January 2026"); + + createdLoanId.set(loanId); + + // First re-age + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + // Second re-age on next day + runAt("29 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + // Third re-age on next day + runAt("30 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + // Fourth re-age on next day + runAt("31 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + + // Verify: should have 8 periods total (1 disbursement + 1 stub + 6 re-aged installments) + // NOT 12+ periods with spurious stubs from each intermediate reAge + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + List<GetLoansLoanIdRepaymentPeriod> periods = loanDetails.getRepaymentSchedule().getPeriods(); + + assertEquals(8, periods.size(), "Expected 8 periods (1 disbursement + 1 stub + 6 re-aged) but got " + periods.size()); + + // Verify due dates are correct + assertEquals(LocalDate.of(2026, 1, 28), periods.get(0).getDueDate()); // disbursement + assertEquals(LocalDate.of(2026, 1, 31), periods.get(1).getDueDate()); // stub + assertEquals(LocalDate.of(2026, 2, 28), periods.get(2).getDueDate()); // 1st re-aged + assertEquals(LocalDate.of(2026, 3, 28), periods.get(3).getDueDate()); // 2nd re-aged + assertEquals(LocalDate.of(2026, 4, 28), periods.get(4).getDueDate()); // 3rd re-aged + assertEquals(LocalDate.of(2026, 5, 28), periods.get(5).getDueDate()); // 4th re-aged + assertEquals(LocalDate.of(2026, 6, 28), periods.get(6).getDueDate()); // 5th re-aged + assertEquals(LocalDate.of(2026, 7, 28), periods.get(7).getDueDate()); // 6th re-aged + + checkMaturityDates(loanId, LocalDate.of(2026, 7, 28), LocalDate.of(2026, 7, 28)); + }); + } + + @Test + public void test_LoanReAge_RepeatedReAge_COBAccrualDoesNotFail() { + AtomicLong createdLoanId = new AtomicLong(); + + runAt("28 January 2026", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 6; + int repaymentEvery = 1; + + PostLoanProductsRequest product = create4IProgressive() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L); // + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + double amount = 1000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "28 January 2026", amount, numberOfRepayments)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestRatePerPeriod(BigDecimal.valueOf(10.0))// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "28 January 2026")); + + Long loanId = approvedLoanResult.getLoanId(); + + disburseLoan(loanId, BigDecimal.valueOf(amount), "28 January 2026"); + + createdLoanId.set(loanId); + + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("29 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("30 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("31 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("01 February 2026", () -> { + long loanId = createdLoanId.get(); + + // Execute inline COB - this should not fail with NoSuchElementException + executeInlineCOB(loanId); + + // Verify loan schedule still has 8 periods (1 disbursement + 1 stub + 6 re-aged) + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + List<GetLoansLoanIdRepaymentPeriod> periods = loanDetails.getRepaymentSchedule().getPeriods(); + + assertEquals(8, periods.size(), "Expected 8 periods (1 disbursement + 1 stub + 6 re-aged) but got " + periods.size()); + + // Verify loan is still active (COB did not crash) + assertEquals(LoanStatus.ACTIVE.getValue(), loanDetails.getStatus().getId().intValue()); + }); + } + + @Test + public void test_LoanReAge_RepeatedReAge_PreviewShowsCorrectPeriods() { + AtomicLong createdLoanId = new AtomicLong(); + + runAt("28 January 2026", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 6; + int repaymentEvery = 1; + + PostLoanProductsRequest product = create4IProgressive() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L); // + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + double amount = 1000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "28 January 2026", amount, numberOfRepayments)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestRatePerPeriod(BigDecimal.valueOf(10.0))// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "28 January 2026")); + + Long loanId = approvedLoanResult.getLoanId(); + + disburseLoan(loanId, BigDecimal.valueOf(amount), "28 January 2026"); + + createdLoanId.set(loanId); + + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("29 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("30 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + }); + + runAt("31 January 2026", () -> { + long loanId = createdLoanId.get(); + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "28 February 2026", 6, null); + + // Verify actual schedule has 8 periods + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + List<GetLoansLoanIdRepaymentPeriod> periods = loanDetails.getRepaymentSchedule().getPeriods(); + + assertEquals(8, periods.size(), "Expected 8 periods (1 disbursement + 1 stub + 6 re-aged) but got " + periods.size()); + }); + + runAt("01 February 2026", () -> { + long loanId = createdLoanId.get(); + + // Call preview API via REST + String previewUrl = "/fineract-provider/api/v1/loans/" + loanId + "/transactions/reage-preview" // + + "?frequencyType=MONTHS&frequencyNumber=1&startDate=28+February+2026&numberOfInstallments=6" // + + "&dateFormat=dd+MMMM+yyyy&locale=en&" + Utils.TENANT_IDENTIFIER; + + String jsonResponse = Utils.performServerGet(requestSpec, responseSpec, previewUrl); + + // Parse the periods array from the JSON response + List<HashMap<String, Object>> previewPeriods = JsonPath.from(jsonResponse).getList("periods"); + + assertNotNull(previewPeriods, "Preview response should contain periods"); + assertEquals(8, previewPeriods.size(), + "Preview should have 8 periods (1 disbursement + 1 stub + 6 re-aged) but got " + previewPeriods.size()); + }); + } + private HashMap<String, Object> getReAgeTemplate(Long loanId) { final String GET_REAGE_TEMPLATE_URL = "/fineract-provider/api/v1/loans/" + loanId + "/transactions/template?command=reAge&" + Utils.TENANT_IDENTIFIER;
