diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java index 533b98c073d..2f09e9a93a8 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java @@ -1462,7 +1462,7 @@ periodNumberTemp, mc, mergeVariationsToMap(loanApplicationTerms, params), params } } - if (!outstanding.isZero()) { + if (!outstanding.isZero() || (params.getScheduleTillDate() != null && !params.getLatePaymentMap().isEmpty())) { PrincipalInterest principalInterestForThisPeriod = calculatePrincipalInterestComponentsForPeriod( getPaymentPeriodsInOneYearCalculator(), interestCalculationGraceOnRepaymentPeriodFraction, totalInterest.zero(), totalInterest.zero(), totalInterest.zero(), totalInterest.zero(), outstanding, loanApplicationTerms, @@ -1499,7 +1499,8 @@ periodNumberTemp, mc, mergeVariationsToMap(loanApplicationTerms, params), params } } params.setPeriodStartDate(params.getActualRepaymentDate()); - } while (DateUtils.isBefore(params.getActualRepaymentDate(), currentDate) && !outstanding.isZero()); + } while (DateUtils.isBefore(params.getActualRepaymentDate(), currentDate) + && (!outstanding.isZero() || (params.getScheduleTillDate() != null && !params.getLatePaymentMap().isEmpty()))); if (totalInterest.isGreaterThanZero()) { LoanScheduleModelRepaymentPeriod installment = LoanScheduleModelRepaymentPeriod.repayment(params.getInstalmentNumber(), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java index b319daecc16..21ea7ec3279 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java @@ -487,14 +487,19 @@ private void addInterestAccrual(@NonNull final Loan loan, @NonNull final LocalDa final AccrualPeriodData period = accrualPeriods.getPeriodByInstallmentNumber(installment.getInstallmentNumber()); final MonetaryCurrency currency = accrualPeriods.getCurrency(); Money interest = null; - final boolean isPastPeriod = isAfterPeriod(tillDate, installment); - final boolean isInPeriod = isInPeriod(tillDate, installment, false); + LocalDate effectiveDate = tillDate; + if (loan.isClosed() && loan.getClosedOnDate() != null && DateUtils.isBefore(loan.getClosedOnDate(), tillDate)) { + effectiveDate = loan.getClosedOnDate(); + } else if (loanBalanceService.isOverPaid(loan) && loan.getOverpaidOnDate() != null + && DateUtils.isBefore(loan.getOverpaidOnDate(), tillDate)) { + effectiveDate = loan.getOverpaidOnDate(); + } + final boolean isPastPeriod = isAfterPeriod(effectiveDate, installment); + final boolean isInPeriod = isInPeriod(effectiveDate, installment, false); if (isPastPeriod || loan.isClosed() || loanBalanceService.isOverPaid(loan)) { interest = installment.getInterestCharged(currency).minus(installment.getCreditedInterest()); - } else { - if (isInPeriod) { // first period first day is not accrued - interest = scheduleGenerator.getPeriodInterestTillDate(installment, tillDate); - } + } else if (isInPeriod) { + interest = scheduleGenerator.getPeriodInterestTillDate(installment, effectiveDate); } period.setInterestAmount(interest); Money accruable = null; 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 57177c9cdb5..48923fcb146 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 @@ -23,6 +23,7 @@ import org.apache.fineract.client.feign.FineractFeignClient; 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.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoansLoanIdRequest; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; @@ -97,6 +98,10 @@ protected Long createClient() { return clientHelper.createClient(); } + protected Long createClient(String activationDate) { + return clientHelper.createClient(activationDate); + } + protected Long createLoanProduct(PostLoanProductsRequest request) { return loanHelper.createLoanProduct(request); } @@ -219,4 +224,12 @@ protected PostLoansLoanIdTransactionsRequest waiveInterest(double amount, String protected PostLoansLoanIdTransactionsRequest chargeOff(String date) { return LoanRequestBuilders.chargeOff(date); } + + protected void executeInlineCOB(Long loanId) { + transactionHelper.executeInlineCOB(loanId); + } + + protected GetLoansLoanIdTransactionsTemplateResponse getPrepaymentAmount(Long loanId, String transactionDate, String dateFormat) { + return transactionHelper.getPrepaymentAmount(loanId, transactionDate, dateFormat); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java index 88038af59f1..9e8e74edd35 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java @@ -28,10 +28,14 @@ public class FeignBusinessDateHelper { + private static final String ENABLE_BUSINESS_DATE = "enable-business-date"; + private final FineractFeignClient fineractClient; + private final FeignGlobalConfigurationHelper configHelper; public FeignBusinessDateHelper(FineractFeignClient fineractClient) { this.fineractClient = fineractClient; + this.configHelper = new FeignGlobalConfigurationHelper(fineractClient); } public BusinessDateResponse getBusinessDate(String type) { @@ -49,14 +53,12 @@ public void updateBusinessDate(String type, String date) { } public void runAt(String date, Runnable action) { - BusinessDateResponse originalDate = getBusinessDate("BUSINESS_DATE"); try { + configHelper.updateConfigurationByName(ENABLE_BUSINESS_DATE, true); updateBusinessDate("BUSINESS_DATE", date); action.run(); } finally { - if (originalDate != null && originalDate.getDate() != null) { - updateBusinessDate("BUSINESS_DATE", originalDate.getDate().toString()); - } + configHelper.updateConfigurationByName(ENABLE_BUSINESS_DATE, false); } } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java index 1bcd4faeab8..22141276cf7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java @@ -37,8 +37,11 @@ public FeignClientHelper(FineractFeignClient fineractClient) { } public Long createClient() { + return createClient(Utils.dateFormatter.format(Utils.getLocalDateOfTenant())); + } + + public Long createClient(String activationDate) { String externalId = Utils.randomStringGenerator("EXT_", 7); - String activationDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); PostClientsRequest request = new PostClientsRequest()// .officeId(1L)// 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 7356844e2b0..7fbfe4cd901 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 @@ -20,8 +20,11 @@ import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import java.util.List; import java.util.Map; import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse; +import org.apache.fineract.client.models.InlineJobRequest; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest; @@ -34,6 +37,16 @@ public FeignTransactionHelper(FineractFeignClient fineractClient) { this.fineractClient = fineractClient; } + public void executeInlineCOB(Long loanId) { + InlineJobRequest request = new InlineJobRequest().loanIds(List.of(loanId)); + ok(() -> fineractClient.inlineJob().executeInlineJob("LOAN_COB", request)); + } + + public GetLoansLoanIdTransactionsTemplateResponse getPrepaymentAmount(Long loanId, String transactionDate, String dateFormat) { + return ok(() -> fineractClient.loanTransactions().retrieveTransactionTemplate(loanId, "prepayLoan", dateFormat, transactionDate, + "en", null)); + } + public Long addRepayment(Long loanId, PostLoansLoanIdTransactionsRequest request) { PostLoansLoanIdTransactionsResponse response = ok( () -> fineractClient.loanTransactions().executeLoanTransaction(loanId, request, Map.of("command", "repayment"))); 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 5b95f82d2f4..41ef8f52b4a 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 @@ -233,6 +233,18 @@ default PostLoanProductsRequest fourInstallmentsCumulativeTemplate() { LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY); } + default PostLoanProductsRequest fourInstallmentsCumulativeWithInterestRecalculation() { + return fourInstallmentsCumulativeTemplate()// + .loanScheduleType(LoanScheduleType.CUMULATIVE.toString())// + .isInterestRecalculationEnabled(true)// + .interestRecalculationCompoundingMethod(0)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .recalculationRestFrequencyType(1)// + .recalculationRestFrequencyInterval(0)// + .preClosureInterestCalculationStrategy(1)// + .enableAccrualActivityPosting(true); + } + default PostLoanProductsRequest customizeProduct(PostLoanProductsRequest template, Function customizer) { return customizer.apply(template); 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 ead6e9140ca..683f8a38f1b 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 @@ -67,7 +67,7 @@ public static PostLoansRequest applyCumulativeLoan(Long clientId, Long productId .amortizationType(LoanTestData.AmortizationType.EQUAL_INSTALLMENTS)// .interestType(LoanTestData.InterestType.DECLINING_BALANCE)// .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.DAILY)// - .transactionProcessingStrategyCode("DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY")// + .transactionProcessingStrategyCode("due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest-strategy")// .loanType("individual")// .locale(LoanTestData.LOCALE)// .dateFormat(LoanTestData.DATETIME_PATTERN); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java index 0b42a26f9fe..55f11ffbda1 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java @@ -111,6 +111,7 @@ public static class OutstandingAmounts { public static final class AmortizationType { + public static final Integer EQUAL_PRINCIPAL = 0; public static final Integer EQUAL_INSTALLMENTS = 1; private AmortizationType() {} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanAccrualOnClosedLoanTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanAccrualOnClosedLoanTest.java new file mode 100644 index 00000000000..092e4c8f150 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanAccrualOnClosedLoanTest.java @@ -0,0 +1,820 @@ +/** + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +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.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase; +import org.apache.fineract.integrationtests.client.feign.modules.LoanRequestBuilders; +import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData; +import org.apache.fineract.integrationtests.common.Utils; +import org.junit.jupiter.api.Test; + +@Slf4j +public class FeignLoanAccrualOnClosedLoanTest extends FeignLoanTestBase { + + private static final DateTimeFormatter ISO_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final String DATETIME_PATTERN = LoanTestData.DATETIME_PATTERN; + + @Test + void testPrepayLoanShouldNotReceiveAccrualAfterClosure() { + final Long[] loanIdHolder = new Long[1]; + + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(12)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 12, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 2, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + assertNotNull(loanDetails.getRepaymentSchedule()); + + var firstInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 1).findFirst().orElseThrow(); + + double firstInstallmentAmount = Utils.getDoubleValue(firstInstallment.getTotalDueForPeriod()); + log.info("First installment amount: {}", firstInstallmentAmount); + + addRepayment(loanId, repayment(firstInstallmentAmount, "01 February 2026")); + }); + + for (LocalDate date = LocalDate.of(2026, 2, 2); !date.isAfter(LocalDate.of(2026, 2, 15)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-15", () -> { + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 February 2026", DATETIME_PATTERN); + assertNotNull(prepayTemplate); + + double prepayAmount = prepayTemplate.getAmount(); + log.info("Prepay template amount: {}", prepayAmount); + log.info("Prepay template interest portion: {}", prepayTemplate.getInterestPortion()); + log.info("Prepay template principal portion: {}", prepayTemplate.getPrincipalPortion()); + + addRepayment(loanId, repayment(prepayAmount, "15 February 2026")); + + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + log.info("Loan status after prepay: {}", loanAfterPrepay.getStatus().getCode()); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + assertEquals(0.0, Utils.getDoubleValue(loanAfterPrepay.getSummary().getTotalOutstanding()), + "Total outstanding should be 0 after prepay"); + }); + + runAt("2026-02-16", () -> { + executeInlineCOB(loanId); + + GetLoansLoanIdResponse loanAfterCOB = getLoanDetails(loanId); + log.info("Loan status after COB: {}", loanAfterCOB.getStatus().getCode()); + log.info("Outstanding after COB: principal={}, interest={}, total={}", loanAfterCOB.getSummary().getPrincipalOutstanding(), + loanAfterCOB.getSummary().getInterestOutstanding(), loanAfterCOB.getSummary().getTotalOutstanding()); + + verifyLoanStatus(loanAfterCOB, GetLoansLoanIdStatus::getClosedObligationsMet); + + assertEquals(0.0, Utils.getDoubleValue(loanAfterCOB.getSummary().getTotalOutstanding()), + "Total outstanding should still be 0 after COB"); + + List transactions = loanAfterCOB.getTransactions(); + assertNotNull(transactions); + + LocalDate prepayDate = LocalDate.of(2026, 2, 15); + List postClosureAccruals = transactions.stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getDate().isAfter(prepayDate)) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()).toList(); + + log.info("Post-closure accrual transactions found: {}", postClosureAccruals.size()); + for (GetLoansLoanIdTransactions accrual : postClosureAccruals) { + log.info(" Accrual: date={}, amount={}, interest={}", accrual.getDate(), accrual.getAmount(), + accrual.getInterestPortion()); + } + + assertTrue(postClosureAccruals.isEmpty(), + "No accrual transactions should be posted after the loan is closed. Found " + postClosureAccruals.size() + + " accrual(s) with total interest: " + + postClosureAccruals.stream().mapToDouble(a -> Utils.getDoubleValue(a.getInterestPortion())).sum()); + }); + } + + @Test + void testPrepayAmountShouldIncludeFullAccruedInterest() { + final Long[] loanIdHolder = new Long[1]; + + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(12)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 12, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 2, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var firstInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 1).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(firstInstallment.getTotalDueForPeriod()), "01 February 2026")); + }); + + for (LocalDate date = LocalDate.of(2026, 2, 2); !date.isAfter(LocalDate.of(2026, 2, 15)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-15", () -> { + GetLoansLoanIdResponse loanBeforePrepay = getLoanDetails(loanId); + double totalAccruedInterest = loanBeforePrepay.getTransactions().stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()) + .mapToDouble(tx -> Utils.getDoubleValue(tx.getInterestPortion())).sum(); + double totalInterestPaid = loanBeforePrepay.getTransactions().stream() + .filter(tx -> "loanTransactionType.repayment".equals(tx.getType().getCode())) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()) + .mapToDouble(tx -> Utils.getDoubleValue(tx.getInterestPortion())).sum(); + double unpaidAccruedInterest = totalAccruedInterest - totalInterestPaid; + log.info("Before prepay - totalAccrued: {}, totalPaid: {}, unpaidAccrued: {}", totalAccruedInterest, totalInterestPaid, + unpaidAccruedInterest); + + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 February 2026", DATETIME_PATTERN); + double prepayAmount = prepayTemplate.getAmount(); + double templateInterest = prepayTemplate.getInterestPortion() != null ? prepayTemplate.getInterestPortion() : 0.0; + log.info("Prepay template - amount: {}, interest: {}, principal: {}", prepayAmount, templateInterest, + prepayTemplate.getPrincipalPortion()); + + assertTrue(templateInterest >= unpaidAccruedInterest, + "Prepay template interest (" + templateInterest + ") should cover all unpaid accrued interest (" + unpaidAccruedInterest + + "). Shortfall: " + (unpaidAccruedInterest - templateInterest)); + + addRepayment(loanId, repayment(prepayAmount, "15 February 2026")); + + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + }); + + runAt("2026-02-16", () -> { + executeInlineCOB(loanId); + + GetLoansLoanIdResponse loanAfterCOB = getLoanDetails(loanId); + + double totalOutstanding = Utils.getDoubleValue(loanAfterCOB.getSummary().getTotalOutstanding()); + double interestOutstanding = Utils.getDoubleValue(loanAfterCOB.getSummary().getInterestOutstanding()); + + log.info("After COB - status: {}, totalOutstanding: {}, interestOutstanding: {}", loanAfterCOB.getStatus().getCode(), + totalOutstanding, interestOutstanding); + + assertEquals(0.0, interestOutstanding, + "Interest outstanding should be 0 after prepay + COB. " + + "The prepay template should have included all accrued interest. " + "Found interest outstanding: " + + interestOutstanding); + + assertEquals(0.0, totalOutstanding, "Total outstanding should be 0 after prepay + COB. Found: " + totalOutstanding); + + verifyLoanStatus(loanAfterCOB, GetLoansLoanIdStatus::getClosedObligationsMet); + }); + } + + @Test + void testMaturedLoanPrepayAmountShouldIncludePostMaturityInterest() { + final Long[] loanIdHolder = new Long[1]; + + // Step 1: Create and disburse a 3-installment loan on Jan 1 + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(3)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 3, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + // Step 2: Run daily COB from Jan 2 to Feb 1 + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 2, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 3: Pay 1st installment on Feb 1 + runAt("2026-02-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var firstInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 1).findFirst().orElseThrow(); + double firstAmount = Utils.getDoubleValue(firstInstallment.getTotalDueForPeriod()); + log.info("Paying 1st installment: {}", firstAmount); + addRepayment(loanId, repayment(firstAmount, "01 February 2026")); + }); + + // Step 4: Run daily COB from Feb 2 to Mar 1 + for (LocalDate date = LocalDate.of(2026, 2, 2); !date.isAfter(LocalDate.of(2026, 3, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 5: Pay 2nd installment on Mar 1 + runAt("2026-03-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var secondInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 2).findFirst().orElseThrow(); + double secondAmount = Utils.getDoubleValue(secondInstallment.getTotalDueForPeriod()); + log.info("Paying 2nd installment: {}", secondAmount); + addRepayment(loanId, repayment(secondAmount, "01 March 2026")); + }); + + // Step 6: Run daily COB from Mar 2 through Apr 15 + // 3rd installment due date (Apr 1) passes WITHOUT payment — loan matures + for (LocalDate date = LocalDate.of(2026, 3, 2); !date.isAfter(LocalDate.of(2026, 4, 15)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 7: On Apr 15 — all installments past due, loan matured + // Query prepay template and verify it includes post-maturity interest + runAt("2026-04-15", () -> { + GetLoansLoanIdResponse loanBeforePrepay = getLoanDetails(loanId); + log.info("Loan status before prepay: {}", loanBeforePrepay.getStatus().getCode()); + + // Calculate total accrued interest and total interest already paid + double totalAccruedInterest = loanBeforePrepay.getTransactions().stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()) + .mapToDouble(tx -> Utils.getDoubleValue(tx.getInterestPortion())).sum(); + double totalInterestPaid = loanBeforePrepay.getTransactions().stream() + .filter(tx -> "loanTransactionType.repayment".equals(tx.getType().getCode())) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()) + .mapToDouble(tx -> Utils.getDoubleValue(tx.getInterestPortion())).sum(); + double unpaidAccruedInterest = totalAccruedInterest - totalInterestPaid; + log.info("Before prepay - totalAccrued: {}, totalPaid: {}, unpaidAccrued: {}", totalAccruedInterest, totalInterestPaid, + unpaidAccruedInterest); + + // Query prepay template + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 April 2026", DATETIME_PATTERN); + assertNotNull(prepayTemplate); + double prepayAmount = prepayTemplate.getAmount(); + double templateInterest = prepayTemplate.getInterestPortion() != null ? prepayTemplate.getInterestPortion() : 0.0; + log.info("Prepay template - amount: {}, interest: {}, principal: {}", prepayAmount, templateInterest, + prepayTemplate.getPrincipalPortion()); + + // KEY ASSERTION: prepay template interest must cover all unpaid accrued interest + // This includes post-maturity interest for Apr 1 → Apr 15 + assertTrue(templateInterest >= unpaidAccruedInterest, + "Prepay template interest (" + templateInterest + ") should cover all unpaid accrued interest (" + unpaidAccruedInterest + + ") including post-maturity period. Shortfall: " + (unpaidAccruedInterest - templateInterest)); + + // Pay the prepay amount + addRepayment(loanId, repayment(prepayAmount, "15 April 2026")); + + // Verify loan closes + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + log.info("Loan status after prepay: {}", loanAfterPrepay.getStatus().getCode()); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + assertEquals(0.0, Utils.getDoubleValue(loanAfterPrepay.getSummary().getTotalOutstanding()), + "Total outstanding should be 0 after prepay"); + }); + + // Step 8: Run COB the next day — verify no spurious accruals + runAt("2026-04-16", () -> { + executeInlineCOB(loanId); + + GetLoansLoanIdResponse loanAfterCOB = getLoanDetails(loanId); + double totalOutstanding = Utils.getDoubleValue(loanAfterCOB.getSummary().getTotalOutstanding()); + double interestOutstanding = Utils.getDoubleValue(loanAfterCOB.getSummary().getInterestOutstanding()); + + log.info("After COB - status: {}, totalOutstanding: {}, interestOutstanding: {}", loanAfterCOB.getStatus().getCode(), + totalOutstanding, interestOutstanding); + + verifyLoanStatus(loanAfterCOB, GetLoansLoanIdStatus::getClosedObligationsMet); + + assertEquals(0.0, interestOutstanding, + "Interest outstanding should be 0 after prepay + COB on matured loan. Found: " + interestOutstanding); + + assertEquals(0.0, totalOutstanding, + "Total outstanding should be 0 after prepay + COB on matured loan. Found: " + totalOutstanding); + + // Verify no accrual transactions posted after closure + List transactions = loanAfterCOB.getTransactions(); + assertNotNull(transactions); + + LocalDate prepayDate = LocalDate.of(2026, 4, 15); + List postClosureAccruals = transactions.stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getDate().isAfter(prepayDate)) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()).toList(); + + log.info("Post-closure accrual transactions found: {}", postClosureAccruals.size()); + for (GetLoansLoanIdTransactions accrual : postClosureAccruals) { + log.info(" Accrual: date={}, amount={}, interest={}", accrual.getDate(), accrual.getAmount(), + accrual.getInterestPortion()); + } + + assertTrue(postClosureAccruals.isEmpty(), + "No accrual transactions should be posted after the matured loan is closed. Found " + postClosureAccruals.size() + + " accrual(s) with total interest: " + + postClosureAccruals.stream().mapToDouble(a -> Utils.getDoubleValue(a.getInterestPortion())).sum()); + }); + } + + @Test + void testN1InstallmentExistsAfterPostMaturityPrepay() { + final Long[] loanIdHolder = new Long[1]; + + // Step 1: Create and disburse a 3-installment loan on Jan 1 + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(3)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 3, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + // Step 2: Run daily COB and pay installments 1 and 2 + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 2, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var firstInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 1).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(firstInstallment.getTotalDueForPeriod()), "01 February 2026")); + }); + + for (LocalDate date = LocalDate.of(2026, 2, 2); !date.isAfter(LocalDate.of(2026, 3, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-03-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var secondInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 2).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(secondInstallment.getTotalDueForPeriod()), "01 March 2026")); + }); + + // Step 3: Let installment 3 (Apr 1) pass unpaid, COB runs through Apr 15 + for (LocalDate date = LocalDate.of(2026, 3, 2); !date.isAfter(LocalDate.of(2026, 4, 15)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 4: Before prepay — verify schedule has exactly 3 installment periods + runAt("2026-04-15", () -> { + GetLoansLoanIdResponse loanBeforePrepay = getLoanDetails(loanId); + List periodsBeforePrepay = loanBeforePrepay.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null).toList(); + log.info("Schedule periods before prepay: {}", periodsBeforePrepay.size()); + for (GetLoansLoanIdRepaymentPeriod p : periodsBeforePrepay) { + log.info(" Period {}: fromDate={}, dueDate={}, interestDue={}", p.getPeriod(), p.getFromDate(), p.getDueDate(), + p.getInterestDue()); + } + + // Prepay on Apr 15 + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 April 2026", DATETIME_PATTERN); + double prepayAmount = prepayTemplate.getAmount(); + log.info("Prepay amount: {}", prepayAmount); + addRepayment(loanId, repayment(prepayAmount, "15 April 2026")); + + // After prepay — verify N+1 installment exists + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + + List periodsAfterPrepay = loanAfterPrepay.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null).toList(); + log.info("Schedule periods after prepay: {}", periodsAfterPrepay.size()); + for (GetLoansLoanIdRepaymentPeriod p : periodsAfterPrepay) { + log.info(" Period {}: fromDate={}, dueDate={}, interestDue={}, principalDue={}", p.getPeriod(), p.getFromDate(), + p.getDueDate(), p.getInterestDue(), p.getPrincipalDue()); + } + + assertTrue(periodsAfterPrepay.size() > 3, + "After post-maturity prepay, schedule should have more than 3 periods (N+1 installment). Found: " + + periodsAfterPrepay.size()); + + // Find the N+1 period (period 4) + GetLoansLoanIdRepaymentPeriod n1Period = periodsAfterPrepay.stream().filter(p -> p.getPeriod() == 4).findFirst() + .orElse(periodsAfterPrepay.get(periodsAfterPrepay.size() - 1)); + + // N+1 period should cover from Apr 1 onwards + assertNotNull(n1Period.getFromDate(), "N+1 period should have a fromDate"); + assertNotNull(n1Period.getDueDate(), "N+1 period should have a dueDate"); + assertEquals(LocalDate.of(2026, 4, 1), n1Period.getFromDate(), + "N+1 period should start from last installment due date (Apr 1)"); + + // N+1 period should have interest > 0 + double n1Interest = Utils.getDoubleValue(n1Period.getInterestDue()); + log.info("N+1 period interest: {}", n1Interest); + assertTrue(n1Interest > 0, "N+1 period should carry post-maturity interest. Found: " + n1Interest); + + assertEquals(0.0, Utils.getDoubleValue(loanAfterPrepay.getSummary().getTotalOutstanding()), + "Total outstanding should be 0 after prepay"); + }); + } + + @Test + void testPostMaturityInterestAccruesDaily() { + final Long[] loanIdHolder = new Long[1]; + + // Step 1: Create and disburse a 3-installment loan on Jan 1 + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(3)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 3, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + // Step 2: Pay installments 1 and 2 on time + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 2, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var firstInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 1).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(firstInstallment.getTotalDueForPeriod()), "01 February 2026")); + }); + + for (LocalDate date = LocalDate.of(2026, 2, 2); !date.isAfter(LocalDate.of(2026, 3, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-03-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var secondInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 2).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(secondInstallment.getTotalDueForPeriod()), "01 March 2026")); + }); + + // Step 3: Let installment 3 (Apr 1) pass unpaid, COB runs through Apr 10 + for (LocalDate date = LocalDate.of(2026, 3, 2); !date.isAfter(LocalDate.of(2026, 4, 10)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 4: Verify prepay amount on Apr 15 includes post-maturity interest + runAt("2026-04-15", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + + // Get installment 3's total due (principal + scheduled interest for the period) + var installment3 = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 3).findFirst().orElseThrow(); + double installment3TotalDue = Utils.getDoubleValue(installment3.getTotalDueForPeriod()); + log.info("Installment 3 total due: {}", installment3TotalDue); + + // Get prepay amount on Apr 15 (14 days after last installment due date) + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 April 2026", DATETIME_PATTERN); + double prepayAmount = prepayTemplate.getAmount(); + log.info("Prepay amount on Apr 15: {}", prepayAmount); + + // Prepay amount must be greater than installment 3's total due because it includes + // post-maturity interest (Apr 1 → Apr 15). This proves the schedule generator correctly + // computes additional interest during prepayment calculation. + assertTrue(prepayAmount > installment3TotalDue, + "Prepay amount should include post-maturity interest beyond installment 3 total due. " + "Prepay amount: " + + prepayAmount + ", Installment 3 total due: " + installment3TotalDue); + + // Prepay and verify loan closes successfully + addRepayment(loanId, repayment(prepayAmount, "15 April 2026")); + + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + }); + } + + @Test + void testPostMaturityPrepayWithNoInstallmentsPaid() { + final Long[] loanIdHolder = new Long[1]; + + // Step 1: Create and disburse a 3-installment loan on Jan 1 + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(3)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 3, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + // Step 2: NO payments at all — run COB from Jan 2 through Apr 15 + // All 3 installments (Feb 1, Mar 1, Apr 1) pass unpaid + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 4, 15)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 3: Prepay on Apr 15 for the full amount + runAt("2026-04-15", () -> { + GetLoansLoanIdResponse loanBeforePrepay = getLoanDetails(loanId); + log.info("Loan status before prepay (no installments paid): {}", loanBeforePrepay.getStatus().getCode()); + + // Calculate total accrued interest (should include post-maturity accruals on full 100k principal) + double totalAccruedInterest = loanBeforePrepay.getTransactions().stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()) + .mapToDouble(tx -> Utils.getDoubleValue(tx.getInterestPortion())).sum(); + log.info("Total accrued interest (all on full principal, no payments): {}", totalAccruedInterest); + + // Query prepay template + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 April 2026", DATETIME_PATTERN); + assertNotNull(prepayTemplate); + double prepayAmount = prepayTemplate.getAmount(); + double templateInterest = prepayTemplate.getInterestPortion() != null ? prepayTemplate.getInterestPortion() : 0.0; + double templatePrincipal = prepayTemplate.getPrincipalPortion() != null ? prepayTemplate.getPrincipalPortion() : 0.0; + log.info("Prepay template - amount: {}, interest: {}, principal: {}", prepayAmount, templateInterest, templatePrincipal); + + // Prepay template must include full principal + all interest (including post-maturity) + assertTrue(templateInterest >= totalAccruedInterest, + "Prepay template interest (" + templateInterest + ") should cover all accrued interest (" + totalAccruedInterest + + ") including post-maturity period on full 100k principal. Shortfall: " + + (totalAccruedInterest - templateInterest)); + + // Pay the prepay amount + addRepayment(loanId, repayment(prepayAmount, "15 April 2026")); + + // Verify loan closes + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + log.info("Loan status after prepay: {}", loanAfterPrepay.getStatus().getCode()); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + assertEquals(0.0, Utils.getDoubleValue(loanAfterPrepay.getSummary().getTotalOutstanding()), + "Total outstanding should be 0 after prepay"); + }); + + // Step 4: Run COB the next day — verify no spurious accruals + runAt("2026-04-16", () -> { + executeInlineCOB(loanId); + + GetLoansLoanIdResponse loanAfterCOB = getLoanDetails(loanId); + verifyLoanStatus(loanAfterCOB, GetLoansLoanIdStatus::getClosedObligationsMet); + assertEquals(0.0, Utils.getDoubleValue(loanAfterCOB.getSummary().getTotalOutstanding()), + "Total outstanding should remain 0 after COB on closed loan with no prior payments"); + assertEquals(0.0, Utils.getDoubleValue(loanAfterCOB.getSummary().getInterestOutstanding()), + "Interest outstanding should be 0 after COB"); + + // Verify no accrual transactions posted after closure + LocalDate prepayDate = LocalDate.of(2026, 4, 15); + List postClosureAccruals = loanAfterCOB.getTransactions().stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getDate().isAfter(prepayDate)) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()).toList(); + + log.info("Post-closure accrual transactions found: {}", postClosureAccruals.size()); + assertTrue(postClosureAccruals.isEmpty(), + "No accrual transactions should be posted after closure (all installments unpaid scenario). Found " + + postClosureAccruals.size() + " accrual(s)"); + }); + } + + @Test + void testMultipleCOBCyclesAfterClosureProduceNoAccruals() { + final Long[] loanIdHolder = new Long[1]; + + // Step 1: Create and disburse a 3-installment loan on Jan 1 + runAt("2026-01-01", () -> { + Long clientId = createClient("01 January 2026"); + + PostLoanProductsRequest productRequest = fourInstallmentsCumulativeWithInterestRecalculation()// + .currencyCode("USD")// + .principal(100000.0)// + .minPrincipal(1000.0)// + .maxPrincipal(200000.0)// + .numberOfRepayments(3)// + .amortizationType(LoanTestData.AmortizationType.EQUAL_PRINCIPAL)// + .interestRatePerPeriod(12.0)// + .interestRateFrequencyType(LoanTestData.InterestRateFrequencyType.YEARS)// + .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)// + .allowPartialPeriodInterestCalculation(true); + + Long productId = createLoanProduct(productRequest); + + Long loanId = applyForLoan(LoanRequestBuilders.applyCumulativeLoan(clientId, productId, "01 January 2026", 100000.0, 3, 12.0)); + approveLoan(loanId, LoanRequestBuilders.approveLoan(100000.0, "01 January 2026")); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(100000.0, "01 January 2026")); + loanIdHolder[0] = loanId; + }); + + Long loanId = loanIdHolder[0]; + + // Step 2: Pay installments 1 and 2 on time + for (LocalDate date = LocalDate.of(2026, 1, 2); !date.isAfter(LocalDate.of(2026, 2, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-02-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var firstInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 1).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(firstInstallment.getTotalDueForPeriod()), "01 February 2026")); + }); + + for (LocalDate date = LocalDate.of(2026, 2, 2); !date.isAfter(LocalDate.of(2026, 3, 1)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + runAt("2026-03-01", () -> { + GetLoansLoanIdResponse loanDetails = getLoanDetails(loanId); + var secondInstallment = loanDetails.getRepaymentSchedule().getPeriods().stream() + .filter(p -> p.getPeriod() != null && p.getPeriod() == 2).findFirst().orElseThrow(); + addRepayment(loanId, repayment(Utils.getDoubleValue(secondInstallment.getTotalDueForPeriod()), "01 March 2026")); + }); + + // Step 3: Let installment 3 (Apr 1) pass unpaid, COB through Apr 15 + for (LocalDate date = LocalDate.of(2026, 3, 2); !date.isAfter(LocalDate.of(2026, 4, 15)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + runAt(dateStr, () -> executeInlineCOB(loanId)); + } + + // Step 4: Prepay on Apr 15 + runAt("2026-04-15", () -> { + GetLoansLoanIdTransactionsTemplateResponse prepayTemplate = getPrepaymentAmount(loanId, "15 April 2026", DATETIME_PATTERN); + double prepayAmount = prepayTemplate.getAmount(); + log.info("Prepay amount: {}", prepayAmount); + addRepayment(loanId, repayment(prepayAmount, "15 April 2026")); + + GetLoansLoanIdResponse loanAfterPrepay = getLoanDetails(loanId); + verifyLoanStatus(loanAfterPrepay, GetLoansLoanIdStatus::getClosedObligationsMet); + }); + + // Step 5: Run COB for 5 consecutive days after closure (Apr 16 → Apr 20) + // Verify durability of accrual cap on each day + LocalDate closureDate = LocalDate.of(2026, 4, 15); + + for (LocalDate date = LocalDate.of(2026, 4, 16); !date.isAfter(LocalDate.of(2026, 4, 20)); date = date.plusDays(1)) { + final String dateStr = ISO_FORMAT.format(date); + final LocalDate currentDate = date; + runAt(dateStr, () -> { + executeInlineCOB(loanId); + + GetLoansLoanIdResponse loanAfterCOB = getLoanDetails(loanId); + + // Status must remain closed + verifyLoanStatus(loanAfterCOB, GetLoansLoanIdStatus::getClosedObligationsMet); + + // Total outstanding must remain 0 + double totalOutstanding = Utils.getDoubleValue(loanAfterCOB.getSummary().getTotalOutstanding()); + assertEquals(0.0, totalOutstanding, + "Total outstanding should be 0 after COB on " + currentDate + ". Found: " + totalOutstanding); + + // Interest outstanding must remain 0 + double interestOutstanding = Utils.getDoubleValue(loanAfterCOB.getSummary().getInterestOutstanding()); + assertEquals(0.0, interestOutstanding, + "Interest outstanding should be 0 after COB on " + currentDate + ". Found: " + interestOutstanding); + + // No new non-reversed accrual transactions after closure + List postClosureAccruals = loanAfterCOB.getTransactions().stream() + .filter(tx -> "loanTransactionType.accrual".equals(tx.getType().getCode())) + .filter(tx -> tx.getDate().isAfter(closureDate)) + .filter(tx -> tx.getManuallyReversed() == null || !tx.getManuallyReversed()).toList(); + + log.info("COB on {} - postClosureAccruals: {}, totalOutstanding: {}, interestOutstanding: {}", currentDate, + postClosureAccruals.size(), totalOutstanding, interestOutstanding); + + assertTrue(postClosureAccruals.isEmpty(), "No accrual transactions should be posted after closure. On " + currentDate + + " found " + postClosureAccruals.size() + " accrual(s)"); + }); + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java index 76e9c49ba7b..d652a8c8aa5 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java @@ -56,17 +56,15 @@ public void beforeEach(ExtensionContext context) { } private void closeOpenLoans() { - BusinessDateHelper.runAt(DateTimeFormatter.ofPattern(DATE_FORMAT).format(Utils.getLocalDateOfTenant()), () -> { + LocalDate cleanupDate = determineCleanupDate(); + BusinessDateHelper.runAt(DateTimeFormatter.ofPattern(DATE_FORMAT).format(cleanupDate), () -> { this.loanTransactionHelper = new LoanTransactionHelper(null, null); - // Fully repay ACTIVE loans, so it will not be picked up by any jobs List loanIds = LoanTransactionHelper.getLoanIdsByStatusId(300); loanIds.forEach(loanId -> { GetLoansLoanIdResponse loanResponse = Calls .ok(FineractClientHelper.getFineractClient().loans.retrieveLoan((long) loanId, null, "all", null, null)); if (MathUtil.isLessThan(loanResponse.getApprovedPrincipal(), loanResponse.getProposedPrincipal())) { - // reset approved principal in case it's less than proposed principal so all expected disbursements - // can be properly disbursed PutLoansApprovedAmountRequest request = new PutLoansApprovedAmountRequest().amount(loanResponse.getProposedPrincipal()) .locale("en"); Calls.ok(FineractClientHelper.getFineractClient().loans.modifyLoanApprovedAmount(loanId, request)); @@ -83,7 +81,7 @@ private void closeOpenLoans() { loanResponse = Calls .ok(FineractClientHelper.getFineractClient().loans.retrieveLoan((long) loanId, null, "all", null, null)); GetLoansLoanIdTransactionsTemplateResponse prepayDetail = this.loanTransactionHelper.getPrepaymentAmount(loanId, - dateFormatter.format(Utils.getLocalDateOfTenant()), DATE_FORMAT); + dateFormatter.format(cleanupDate), DATE_FORMAT); LocalDate transactionDate = prepayDetail.getDate(); Double amount = prepayDetail.getAmount(); Double netDisbursalAmount = prepayDetail.getNetDisbursalAmount(); @@ -91,12 +89,10 @@ private void closeOpenLoans() { loanTransactionHelper.makeLoanRepayment(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATE_FORMAT) .transactionDate(dateFormatter.format(transactionDate)).locale("en").transactionAmount(repayAmount)); }); - // Undo APPROVED loans, so the next step can REJECT them, so it will not be picked up by any jobs loanIds = LoanTransactionHelper.getLoanIdsByStatusId(200); loanIds.forEach(loanId -> { loanTransactionHelper.undoApprovalForLoan(loanId, new PostLoansLoanIdRequest()); }); - // Mark SUBMITTED loans, as REJECTED, so it will not be picked up by any jobs loanIds = LoanTransactionHelper.getLoanIdsByStatusId(100); loanIds.forEach(loanId -> { GetLoansLoanIdResponse details = loanTransactionHelper.getLoanDetails((long) loanId); @@ -108,4 +104,26 @@ private void closeOpenLoans() { assertEquals(0, loanIds.size()); }); } + + private LocalDate determineCleanupDate() { + LocalDate tenantDate = Utils.getLocalDateOfTenant(); + try { + List activeLoanIds = LoanTransactionHelper.getLoanIdsByStatusId(300); + LocalDate maxTxDate = tenantDate; + for (Long loanId : activeLoanIds) { + GetLoansLoanIdResponse loanResponse = Calls + .ok(FineractClientHelper.getFineractClient().loans.retrieveLoan(loanId, null, "all", null, null)); + if (loanResponse.getTransactions() != null) { + for (var tx : loanResponse.getTransactions()) { + if (tx.getDate() != null && tx.getDate().isAfter(maxTxDate)) { + maxTxDate = tx.getDate(); + } + } + } + } + return maxTxDate; + } catch (Exception e) { + return tenantDate; + } + } }