FINERACT-2421: fix loan accrual posts after loan close
This commit is contained in:
committed by
Arnold Gálovics
parent
2ee6f22713
commit
3e98cf791a
+3
-2
@@ -1462,7 +1462,7 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
|
||||
}
|
||||
}
|
||||
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(),
|
||||
|
||||
+11
-6
@@ -487,14 +487,19 @@ public class LoanAccrualsProcessingServiceImpl implements LoanAccrualsProcessing
|
||||
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;
|
||||
|
||||
+13
@@ -23,6 +23,7 @@ import java.util.function.Function;
|
||||
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 @@ public abstract class FeignLoanTestBase extends FeignIntegrationTest implements
|
||||
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 @@ public abstract class FeignLoanTestBase extends FeignIntegrationTest implements
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -28,10 +28,14 @@ import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
|
||||
|
||||
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 class FeignBusinessDateHelper {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -37,8 +37,11 @@ public class FeignClientHelper {
|
||||
}
|
||||
|
||||
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)//
|
||||
|
||||
+13
@@ -20,8 +20,11 @@ package org.apache.fineract.integrationtests.client.feign.helpers;
|
||||
|
||||
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 class FeignTransactionHelper {
|
||||
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")));
|
||||
|
||||
+12
@@ -233,6 +233,18 @@ public interface LoanProductTemplates {
|
||||
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<PostLoanProductsRequest, PostLoanProductsRequest> customizer) {
|
||||
return customizer.apply(template);
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ public final class LoanRequestBuilders {
|
||||
.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);
|
||||
|
||||
+1
@@ -111,6 +111,7 @@ public final class LoanTestData {
|
||||
|
||||
public static final class AmortizationType {
|
||||
|
||||
public static final Integer EQUAL_PRINCIPAL = 0;
|
||||
public static final Integer EQUAL_INSTALLMENTS = 1;
|
||||
|
||||
private AmortizationType() {}
|
||||
|
||||
+820
@@ -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<GetLoansLoanIdTransactions> transactions = loanAfterCOB.getTransactions();
|
||||
assertNotNull(transactions);
|
||||
|
||||
LocalDate prepayDate = LocalDate.of(2026, 2, 15);
|
||||
List<GetLoansLoanIdTransactions> 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<GetLoansLoanIdTransactions> transactions = loanAfterCOB.getTransactions();
|
||||
assertNotNull(transactions);
|
||||
|
||||
LocalDate prepayDate = LocalDate.of(2026, 4, 15);
|
||||
List<GetLoansLoanIdTransactions> 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<GetLoansLoanIdRepaymentPeriod> 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<GetLoansLoanIdRepaymentPeriod> 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<GetLoansLoanIdTransactions> 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<GetLoansLoanIdTransactions> 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)");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-7
@@ -56,17 +56,15 @@ public class LoanTestLifecycleExtension implements AfterEachCallback, BeforeEach
|
||||
}
|
||||
|
||||
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<Long> 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 @@ public class LoanTestLifecycleExtension implements AfterEachCallback, BeforeEach
|
||||
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 @@ public class LoanTestLifecycleExtension implements AfterEachCallback, BeforeEach
|
||||
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 @@ public class LoanTestLifecycleExtension implements AfterEachCallback, BeforeEach
|
||||
assertEquals(0, loanIds.size());
|
||||
});
|
||||
}
|
||||
|
||||
private LocalDate determineCleanupDate() {
|
||||
LocalDate tenantDate = Utils.getLocalDateOfTenant();
|
||||
try {
|
||||
List<Long> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user