FINERACT-2435: fix as of data api

This commit is contained in:
Attila Budai
2026-01-23 15:52:42 +01:00
committed by Arnold Gálovics
parent 2b4de8e655
commit bf879064af
3 changed files with 684 additions and 8 deletions
@@ -285,6 +285,10 @@ public class LoanSummary {
this.totalFeeChargesOutstanding = totalFeeChargesOutstanding; this.totalFeeChargesOutstanding = totalFeeChargesOutstanding;
} }
public void updateFeeChargesCharged(final BigDecimal totalFeeChargesCharged) {
this.totalFeeChargesCharged = totalFeeChargesCharged;
}
public void updatePenaltyChargeOutstanding(final BigDecimal totalPenaltyChargesOutstanding) { public void updatePenaltyChargeOutstanding(final BigDecimal totalPenaltyChargesOutstanding) {
this.totalPenaltyChargesOutstanding = totalPenaltyChargesOutstanding; this.totalPenaltyChargesOutstanding = totalPenaltyChargesOutstanding;
} }
@@ -309,6 +313,29 @@ public class LoanSummary {
this.totalWaived = totalWaived; this.totalWaived = totalWaived;
} }
public void updateTotalExpectedRepayment(final BigDecimal totalExpectedRepayment) {
this.totalExpectedRepayment = totalExpectedRepayment;
}
public void updateTotalExpectedCostOfLoan(final BigDecimal totalExpectedCostOfLoan) {
this.totalExpectedCostOfLoan = totalExpectedCostOfLoan;
}
public void recalculateDerivedTotalsForAdjustedFeeCharged(final BigDecimal adjustedFeeCharged) {
this.totalFeeChargesCharged = adjustedFeeCharged;
this.totalFeeChargesOutstanding = adjustedFeeCharged.subtract(this.totalFeeChargesRepaid).subtract(this.totalFeeChargesWaived)
.subtract(this.totalFeeChargesWrittenOff);
this.totalOutstanding = this.totalPrincipalOutstanding.add(this.totalInterestOutstanding).add(this.totalFeeChargesOutstanding)
.add(this.totalPenaltyChargesOutstanding);
this.totalExpectedRepayment = this.totalPrincipal.add(this.totalInterestCharged).add(adjustedFeeCharged)
.add(this.totalPenaltyChargesCharged);
this.totalExpectedCostOfLoan = this.totalInterestCharged.add(adjustedFeeCharged).add(this.totalPenaltyChargesCharged);
}
protected Money calculateTotalPrincipalRepaid(final List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments, protected Money calculateTotalPrincipalRepaid(final List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments,
final MonetaryCurrency currency) { final MonetaryCurrency currency) {
Money total = Money.zero(currency); Money total = Money.zero(currency);
@@ -32,9 +32,12 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData; import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.arrears.LoanArrearsData; import org.apache.fineract.portfolio.loanaccount.domain.arrears.LoanArrearsData;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -73,15 +76,14 @@ public class LoanPointInTimeServiceImpl implements LoanPointInTimeService {
int afterRemovalTxCount = loan.getLoanTransactions().size(); int afterRemovalTxCount = loan.getLoanTransactions().size();
int afterRemovalChargeCount = loan.getCharges().size(); int afterRemovalChargeCount = loan.getCharges().size();
// In case the loan is cumulative and is being prepaid by the latest repayment tx, we need the boolean needsScheduleRegeneration = txCount != afterRemovalTxCount || chargeCount != afterRemovalChargeCount;
// recalculateFrom and recalculateTill
// set to the same date which is the prepaying transaction's date if (needsScheduleRegeneration) {
// currently this is not implemented and opens up buggy edge cases
// we work this around only for cases when the loan is already closed or the requested date doesn't change
// the loan's state
if (txCount != afterRemovalTxCount || chargeCount != afterRemovalChargeCount) {
ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null, null); ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null, null);
loanScheduleService.regenerateScheduleWithReprocessingTransactions(loan, scheduleGeneratorDTO); loanScheduleService.regenerateScheduleWithReprocessingTransactions(loan, scheduleGeneratorDTO);
recalculateSummaryForInstallmentsUpToDate(loan, date);
} else if (!loan.isClosed()) {
recalculateSummaryForInstallmentsUpToDate(loan, date);
} }
LoanArrearsData arrearsData = arrearsAgingService.calculateArrearsForLoan(loan); LoanArrearsData arrearsData = arrearsAgingService.calculateArrearsForLoan(loan);
@@ -97,7 +99,62 @@ public class LoanPointInTimeServiceImpl implements LoanPointInTimeService {
} }
private void removeAfterDateCharges(Loan loan, LocalDate date) { private void removeAfterDateCharges(Loan loan, LocalDate date) {
loan.removeCharges(c -> DateUtils.isAfter(c.getEffectiveDueDate(), date)); // Don't remove installment fees based on effectiveDueDate since they span multiple installments
// For installment fees, effectiveDueDate returns the first UNPAID installment's due date,
// which would incorrectly remove the entire fee even if some installments are already paid/due
// The recalculateSummaryForInstallmentsUpToDate method handles installment fee adjustments separately
loan.removeCharges(c -> !c.isInstalmentFee() && DateUtils.isAfter(c.getEffectiveDueDate(), date));
}
private void recalculateSummaryForInstallmentsUpToDate(Loan loan, LocalDate date) {
var currency = loan.getCurrency();
var summary = loan.getSummary();
// Calculate fee charged based only on charges due by the specified date.
// This excludes after-date charges and only includes installment fee portions for installments due by the date.
Money feeChargedFromRemainingCharges = calculateTotalFeeChargedFromCharges(loan, date, currency);
// Include fees due at disbursement which are always included regardless of date
Money adjustedFeeCharged = feeChargedFromRemainingCharges.plus(summary.getTotalFeeChargesDueAtDisbursement(currency));
// Only proceed with adjustment if the fee charged differs from summary
if (adjustedFeeCharged.getAmount().compareTo(summary.getTotalFeeChargesCharged()) == 0) {
return;
}
// Delegate to domain to recalculate all derived totals consistently
summary.recalculateDerivedTotalsForAdjustedFeeCharged(adjustedFeeCharged.getAmount());
}
private Money calculateTotalFeeChargedFromCharges(Loan loan, LocalDate date, MonetaryCurrency currency) {
Money total = Money.zero(currency);
for (LoanCharge charge : loan.getCharges()) {
if (charge.isActive() && !charge.isPenaltyCharge() && !charge.isDueAtDisbursement()) {
// For installment fees, calculate the portion up to the date
if (charge.isInstalmentFee()) {
Money installmentTotal = calculateInstallmentFeeUpToDate(charge, date, currency);
total = total.plus(installmentTotal);
} else {
// For one-time charges, include only if due on or before the date
LocalDate chargeDueDate = charge.getEffectiveDueDate();
if (chargeDueDate != null && !DateUtils.isAfter(chargeDueDate, date)) {
total = total.plus(charge.getAmount(currency));
}
}
}
}
return total;
}
private Money calculateInstallmentFeeUpToDate(LoanCharge charge, LocalDate date, MonetaryCurrency currency) {
Money total = Money.zero(currency);
for (var installmentCharge : charge.installmentCharges()) {
var installment = installmentCharge.getInstallment();
if (installment != null && !DateUtils.isAfter(installment.getDueDate(), date)) {
total = total.plus(installmentCharge.getAmount(currency));
}
}
return total;
} }
private void removeAfterDateTransactions(Loan loan, LocalDate date) { private void removeAfterDateTransactions(Loan loan, LocalDate date) {
@@ -19,10 +19,12 @@
package org.apache.fineract.integrationtests.loan.pointintime; package org.apache.fineract.integrationtests.loan.pointintime;
import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.LoanPointInTimeData; import org.apache.fineract.client.models.LoanPointInTimeData;
import org.apache.fineract.client.models.LoanProductChargeData; import org.apache.fineract.client.models.LoanProductChargeData;
import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsRequest;
@@ -33,6 +35,7 @@ import org.apache.fineract.client.models.PostLoansRequestChargeData;
import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.integrationtests.BaseLoanIntegrationTest; import org.apache.fineract.integrationtests.BaseLoanIntegrationTest;
import org.apache.fineract.integrationtests.common.ClientHelper; import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.charges.ChargesHelper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
public class LoanPointInTimeTest extends BaseLoanIntegrationTest { public class LoanPointInTimeTest extends BaseLoanIntegrationTest {
@@ -858,4 +861,593 @@ public class LoanPointInTimeTest extends BaseLoanIntegrationTest {
assertThat(pointInTimeData.getArrears().getTotalOverdue()).isZero(); assertThat(pointInTimeData.getArrears().getTotalOverdue()).isZero();
}); });
} }
@Test
public void test_LoanPointInTimeData_InstallmentFeeAllocation() {
AtomicReference<Long> aLoanId = new AtomicReference<>();
double installmentFeeAmount = 100.0;
runAt("01 October 2025", () -> {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 6;
int repaymentEvery = 1;
Long installmentFeeChargeId = createInstallmentFeeCharge(installmentFeeAmount);
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()
.numberOfRepayments(numberOfRepayments).repaymentEvery(repaymentEvery).installmentAmountInMultiplesOf(null)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()).interestType(InterestType.DECLINING_BALANCE)
.interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)
.interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)
.isInterestRecalculationEnabled(true).recalculationRestFrequencyInterval(1)
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)
.rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT).allowPartialPeriodInterestCalculation(false)
.disallowExpectedDisbursements(false).allowApprovedDisbursedAmountsOverApplied(false).overAppliedNumber(null)
.overAppliedCalculationType(null).multiDisburseLoan(null)
.charges(List.of(new LoanProductChargeData().id(installmentFeeChargeId)));
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
double amount = 6000.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 October 2025", amount, numberOfRepayments)
.repaymentEvery(repaymentEvery).loanTermFrequency(numberOfRepayments)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS).loanTermFrequencyType(RepaymentFrequencyType.MONTHS)
.interestType(InterestType.DECLINING_BALANCE).interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)
.charges(List.of(new PostLoansRequestChargeData().chargeId(installmentFeeChargeId)
.amount(BigDecimal.valueOf(installmentFeeAmount))));
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 October 2025"));
aLoanId.getAndSet(approvedLoanResult.getLoanId());
Long loanId = aLoanId.get();
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 October 2025");
verifyTransactions(loanId, transaction(6000.0, "Disbursement", "01 October 2025"));
});
runAt("01 November 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 November 2025");
verifyTransactions(loanId, transaction(6000.0, "Disbursement", "01 October 2025"),
transaction(1100.0, "Repayment", "01 November 2025"));
});
runAt("01 December 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 December 2025");
verifyTransactions(loanId, transaction(6000.0, "Disbursement", "01 October 2025"),
transaction(1100.0, "Repayment", "01 November 2025"), transaction(1100.0, "Repayment", "01 December 2025"));
});
runAt("01 January 2026", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 January 2026");
verifyTransactions(loanId, transaction(6000.0, "Disbursement", "01 October 2025"),
transaction(1100.0, "Repayment", "01 November 2025"), transaction(1100.0, "Repayment", "01 December 2025"),
transaction(1100.0, "Repayment", "01 January 2026"));
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
BigDecimal regularApiFeeChargesPaid = loanDetails.getSummary().getFeeChargesPaid();
BigDecimal regularApiFeeChargesCharged = loanDetails.getSummary().getFeeChargesCharged();
assertThat(regularApiFeeChargesCharged).isEqualByComparingTo(BigDecimal.valueOf(600.0));
assertThat(regularApiFeeChargesPaid).isEqualByComparingTo(BigDecimal.valueOf(300.0));
});
runAt("08 January 2026", () -> {
Long loanId = aLoanId.get();
LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "08 January 2026");
assertThat(pointInTimeData.getFee().getFeeChargesCharged())
.as("Point-in-time feeChargesCharged should only include fees for installments due by the requested date")
.isEqualByComparingTo(BigDecimal.valueOf(300.0));
assertThat(pointInTimeData.getFee().getFeeChargesPaid())
.as("Point-in-time feeChargesPaid should reflect paid installment fees up to the requested date")
.isEqualByComparingTo(BigDecimal.valueOf(300.0));
assertThat(pointInTimeData.getFee().getFeeChargesOutstanding())
.as("Point-in-time feeChargesOutstanding should be 0 since all due fees are paid")
.isEqualByComparingTo(BigDecimal.ZERO);
verifyOutstanding(pointInTimeData, outstanding(3000.0, 0.0, 0.0, 0.0, 3000.0));
});
}
@Test
public void test_LoanPointInTimeData_ClosedLoanWithInstallmentFees() {
AtomicReference<Long> aLoanId = new AtomicReference<>();
double installmentFeeAmount = 25.0;
runAt("01 October 2023", () -> {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 2;
int repaymentEvery = 1;
Long installmentFeeChargeId = createInstallmentFeeCharge(installmentFeeAmount);
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()
.numberOfRepayments(numberOfRepayments).repaymentEvery(repaymentEvery).installmentAmountInMultiplesOf(null)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()).interestType(InterestType.FLAT)
.interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.isInterestRecalculationEnabled(false).disallowExpectedDisbursements(false)
.allowApprovedDisbursedAmountsOverApplied(false).overAppliedNumber(null).overAppliedCalculationType(null)
.multiDisburseLoan(null).charges(List.of(new LoanProductChargeData().id(installmentFeeChargeId)));
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
double amount = 2000.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 October 2023", amount, numberOfRepayments)
.repaymentEvery(repaymentEvery).loanTermFrequency(numberOfRepayments)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS).loanTermFrequencyType(RepaymentFrequencyType.MONTHS)
.interestType(InterestType.FLAT).interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.charges(List.of(new PostLoansRequestChargeData().chargeId(installmentFeeChargeId)
.amount(BigDecimal.valueOf(installmentFeeAmount))));
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 October 2023"));
aLoanId.getAndSet(approvedLoanResult.getLoanId());
Long loanId = aLoanId.get();
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 October 2023");
verifyTransactions(loanId, transaction(2000.0, "Disbursement", "01 October 2023"));
});
runAt("01 November 2023", () -> {
Long loanId = aLoanId.get();
// First repayment: 1000 principal + 25 fee = 1025
addRepaymentForLoan(loanId, 1025.0, "01 November 2023");
verifyTransactions(loanId, transaction(2000.0, "Disbursement", "01 October 2023"),
transaction(1025.0, "Repayment", "01 November 2023"));
});
runAt("01 December 2023", () -> {
Long loanId = aLoanId.get();
// Second repayment: 1000 principal + 25 fee = 1025 (loan should close)
addRepaymentForLoan(loanId, 1025.0, "01 December 2023");
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
// Verify loan is closed
assertThat(loanDetails.getStatus().getCode()).isEqualTo("loanStatusType.closed.obligations.met");
// Verify fee charges
BigDecimal feeChargesCharged = loanDetails.getSummary().getFeeChargesCharged();
BigDecimal feeChargesPaid = loanDetails.getSummary().getFeeChargesPaid();
assertThat(feeChargesCharged).as("Total fee charges charged should be 50 (2 installments * 25)")
.isEqualByComparingTo(BigDecimal.valueOf(50.0));
assertThat(feeChargesPaid).as("Total fee charges paid should be 50").isEqualByComparingTo(BigDecimal.valueOf(50.0));
// With periodic accrual accounting, an accrual transaction is created for the installment fees
verifyTransactions(loanId, transaction(2000.0, "Disbursement", "01 October 2023"),
transaction(1025.0, "Repayment", "01 November 2023"), transaction(50.0, "Accrual", "01 December 2023"),
transaction(1025.0, "Repayment", "01 December 2023"));
});
runAt("15 December 2023", () -> {
Long loanId = aLoanId.get();
// Query point-in-time data for a date AFTER the loan was closed
LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "15 December 2023");
// For a closed loan, all installment fees should be included (50 total)
assertThat(pointInTimeData.getFee().getFeeChargesCharged())
.as("Point-in-time feeChargesCharged for closed loan should be 50.0 (2 installments * 25)")
.isEqualByComparingTo(BigDecimal.valueOf(50.0));
assertThat(pointInTimeData.getFee().getFeeChargesPaid()).as("Point-in-time feeChargesPaid for closed loan should be 50.0")
.isEqualByComparingTo(BigDecimal.valueOf(50.0));
assertThat(pointInTimeData.getFee().getFeeChargesOutstanding())
.as("Point-in-time feeChargesOutstanding for closed loan should be 0").isEqualByComparingTo(BigDecimal.ZERO);
// Total outstanding should be 0 for a closed loan
verifyOutstanding(pointInTimeData, outstanding(0.0, 0.0, 0.0, 0.0, 0.0));
});
}
@Test
public void test_LoanPointInTimeData_TotalExpectedAmountsConsistency() {
AtomicReference<Long> aLoanId = new AtomicReference<>();
double installmentFeeAmount = 100.0;
runAt("01 October 2025", () -> {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 6;
int repaymentEvery = 1;
Long installmentFeeChargeId = createInstallmentFeeCharge(installmentFeeAmount);
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()
.numberOfRepayments(numberOfRepayments).repaymentEvery(repaymentEvery).installmentAmountInMultiplesOf(null)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()).interestType(InterestType.DECLINING_BALANCE)
.interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)
.interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)
.isInterestRecalculationEnabled(true).recalculationRestFrequencyInterval(1)
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)
.rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT).allowPartialPeriodInterestCalculation(false)
.disallowExpectedDisbursements(false).allowApprovedDisbursedAmountsOverApplied(false).overAppliedNumber(null)
.overAppliedCalculationType(null).multiDisburseLoan(null)
.charges(List.of(new LoanProductChargeData().id(installmentFeeChargeId)));
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
double amount = 6000.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 October 2025", amount, numberOfRepayments)
.repaymentEvery(repaymentEvery).loanTermFrequency(numberOfRepayments)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS).loanTermFrequencyType(RepaymentFrequencyType.MONTHS)
.interestType(InterestType.DECLINING_BALANCE).interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)
.charges(List.of(new PostLoansRequestChargeData().chargeId(installmentFeeChargeId)
.amount(BigDecimal.valueOf(installmentFeeAmount))));
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 October 2025"));
aLoanId.getAndSet(approvedLoanResult.getLoanId());
Long loanId = aLoanId.get();
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 October 2025");
});
runAt("01 November 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 November 2025");
});
runAt("01 December 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 December 2025");
});
runAt("01 January 2026", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 January 2026");
// Verify regular API values (full schedule)
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
BigDecimal regularApiFeeChargesCharged = loanDetails.getSummary().getFeeChargesCharged();
assertThat(regularApiFeeChargesCharged).as("Regular API should show full fees (600)")
.isEqualByComparingTo(BigDecimal.valueOf(600.0));
});
runAt("08 January 2026", () -> {
Long loanId = aLoanId.get();
LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "08 January 2026");
// Fee charges should only include fees for installments due by the requested date (3 installments * 100 =
// 300)
BigDecimal expectedFeeCharged = BigDecimal.valueOf(300.0);
assertThat(pointInTimeData.getFee().getFeeChargesCharged())
.as("Point-in-time feeChargesCharged should be 300 (3 installments * 100)").isEqualByComparingTo(expectedFeeCharged);
// Verify that totalExpectedRepayment is consistent with adjusted fees
// totalExpectedRepayment = principal + interest + fees + penalties
// For this loan: 6000 (principal) + 0 (interest) + 300 (adjusted fees) + 0 (penalties) = 6300
BigDecimal expectedTotalExpectedRepayment = BigDecimal.valueOf(6300.0);
assertThat(pointInTimeData.getTotal().getTotalExpectedRepayment())
.as("Point-in-time totalExpectedRepayment should be consistent with adjusted feeChargesCharged (6000 + 300 = 6300)")
.isEqualByComparingTo(expectedTotalExpectedRepayment);
// Verify that totalExpectedCostOfLoan is consistent with adjusted fees
// totalExpectedCostOfLoan = interest + fees + penalties
// For this loan: 0 (interest) + 300 (adjusted fees) + 0 (penalties) = 300
BigDecimal expectedTotalExpectedCostOfLoan = BigDecimal.valueOf(300.0);
assertThat(pointInTimeData.getTotal().getTotalExpectedCostOfLoan())
.as("Point-in-time totalExpectedCostOfLoan should be consistent with adjusted feeChargesCharged (0 + 300 = 300)")
.isEqualByComparingTo(expectedTotalExpectedCostOfLoan);
});
}
@Test
public void test_LoanPointInTimeData_InterestFieldsRemainCoherent() {
AtomicReference<Long> aLoanId = new AtomicReference<>();
double installmentFeeAmount = 50.0;
double interestRatePerPeriod = 12.0;
runAt("01 October 2025", () -> {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 4;
int repaymentEvery = 1;
Long installmentFeeChargeId = createInstallmentFeeCharge(installmentFeeAmount);
PostLoanProductsRequest product = createOnePeriod30DaysPeriodicAccrualProduct(interestRatePerPeriod)
.numberOfRepayments(numberOfRepayments).repaymentEvery(repaymentEvery).installmentAmountInMultiplesOf(null)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()).interestType(InterestType.FLAT)
.interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.isInterestRecalculationEnabled(false).disallowExpectedDisbursements(false)
.allowApprovedDisbursedAmountsOverApplied(false).overAppliedNumber(null).overAppliedCalculationType(null)
.multiDisburseLoan(null).charges(List.of(new LoanProductChargeData().id(installmentFeeChargeId)));
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
double amount = 4000.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 October 2025", amount, numberOfRepayments)
.repaymentEvery(repaymentEvery).loanTermFrequency(numberOfRepayments)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS).loanTermFrequencyType(RepaymentFrequencyType.MONTHS)
.interestType(InterestType.FLAT).interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.interestRatePerPeriod(BigDecimal.valueOf(interestRatePerPeriod)).charges(List.of(new PostLoansRequestChargeData()
.chargeId(installmentFeeChargeId).amount(BigDecimal.valueOf(installmentFeeAmount))));
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 October 2025"));
aLoanId.getAndSet(approvedLoanResult.getLoanId());
Long loanId = aLoanId.get();
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 October 2025");
});
runAt("01 November 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1170.0, "01 November 2025");
});
runAt("01 December 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1170.0, "01 December 2025");
});
runAt("08 December 2025", () -> {
Long loanId = aLoanId.get();
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
BigDecimal regularApiInterestCharged = loanDetails.getSummary().getInterestCharged();
BigDecimal regularApiInterestPaid = loanDetails.getSummary().getInterestPaid();
BigDecimal regularApiInterestOutstanding = loanDetails.getSummary().getInterestOutstanding();
LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "08 December 2025");
assertThat(pointInTimeData.getInterest().getInterestCharged())
.as("Point-in-time interestCharged should remain unchanged from regular API")
.isEqualByComparingTo(regularApiInterestCharged);
assertThat(pointInTimeData.getInterest().getInterestPaid())
.as("Point-in-time interestPaid should remain unchanged from regular API").isEqualByComparingTo(regularApiInterestPaid);
assertThat(pointInTimeData.getInterest().getInterestOutstanding())
.as("Point-in-time interestOutstanding should remain unchanged from regular API")
.isEqualByComparingTo(regularApiInterestOutstanding);
assertThat(pointInTimeData.getFee().getFeeChargesCharged())
.as("Point-in-time feeChargesCharged should only include fees for 2 installments due by Dec 8")
.isEqualByComparingTo(BigDecimal.valueOf(100.0));
});
}
@Test
public void test_LoanPointInTimeData_PenaltyFieldsRemainCoherent() {
AtomicReference<Long> aLoanId = new AtomicReference<>();
double installmentFeeAmount = 50.0;
double installmentPenaltyAmount = 25.0;
runAt("01 October 2025", () -> {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 4;
int repaymentEvery = 1;
Long installmentFeeChargeId = createInstallmentFeeCharge(installmentFeeAmount);
Long installmentPenaltyChargeId = createInstallmentPenaltyCharge(installmentPenaltyAmount);
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()
.numberOfRepayments(numberOfRepayments).repaymentEvery(repaymentEvery).installmentAmountInMultiplesOf(null)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()).interestType(InterestType.FLAT)
.interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.isInterestRecalculationEnabled(false).disallowExpectedDisbursements(false)
.allowApprovedDisbursedAmountsOverApplied(false).overAppliedNumber(null).overAppliedCalculationType(null)
.multiDisburseLoan(null).charges(List.of(new LoanProductChargeData().id(installmentFeeChargeId),
new LoanProductChargeData().id(installmentPenaltyChargeId)));
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
double amount = 4000.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 October 2025", amount, numberOfRepayments)
.repaymentEvery(repaymentEvery).loanTermFrequency(numberOfRepayments)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS).loanTermFrequencyType(RepaymentFrequencyType.MONTHS)
.interestType(InterestType.FLAT).interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.charges(List.of(
new PostLoansRequestChargeData().chargeId(installmentFeeChargeId)
.amount(BigDecimal.valueOf(installmentFeeAmount)),
new PostLoansRequestChargeData().chargeId(installmentPenaltyChargeId)
.amount(BigDecimal.valueOf(installmentPenaltyAmount))));
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 October 2025"));
aLoanId.getAndSet(approvedLoanResult.getLoanId());
Long loanId = aLoanId.get();
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 October 2025");
});
runAt("01 November 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1075.0, "01 November 2025");
});
runAt("01 December 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1075.0, "01 December 2025");
});
runAt("08 December 2025", () -> {
Long loanId = aLoanId.get();
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
BigDecimal regularApiPenaltyCharged = loanDetails.getSummary().getPenaltyChargesCharged();
BigDecimal regularApiPenaltyPaid = loanDetails.getSummary().getPenaltyChargesPaid();
BigDecimal regularApiPenaltyOutstanding = loanDetails.getSummary().getPenaltyChargesOutstanding();
LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "08 December 2025");
assertThat(pointInTimeData.getPenalty().getPenaltyChargesCharged())
.as("Point-in-time penaltyChargesCharged should remain unchanged from regular API")
.isEqualByComparingTo(regularApiPenaltyCharged);
assertThat(pointInTimeData.getPenalty().getPenaltyChargesPaid())
.as("Point-in-time penaltyChargesPaid should remain unchanged from regular API")
.isEqualByComparingTo(regularApiPenaltyPaid);
assertThat(pointInTimeData.getPenalty().getPenaltyChargesOutstanding())
.as("Point-in-time penaltyChargesOutstanding should remain unchanged from regular API")
.isEqualByComparingTo(regularApiPenaltyOutstanding);
assertThat(pointInTimeData.getFee().getFeeChargesCharged())
.as("Point-in-time feeChargesCharged should only include fees for 2 installments due by Dec 8")
.isEqualByComparingTo(BigDecimal.valueOf(100.0));
});
}
@Test
public void test_LoanPointInTimeData_MatchesRegularApiWhenNoFiltering() {
AtomicReference<Long> aLoanId = new AtomicReference<>();
double installmentFeeAmount = 100.0;
runAt("01 October 2025", () -> {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 3;
int repaymentEvery = 1;
Long installmentFeeChargeId = createInstallmentFeeCharge(installmentFeeAmount);
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()
.numberOfRepayments(numberOfRepayments).repaymentEvery(repaymentEvery).installmentAmountInMultiplesOf(null)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()).interestType(InterestType.FLAT)
.interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.isInterestRecalculationEnabled(false).disallowExpectedDisbursements(false)
.allowApprovedDisbursedAmountsOverApplied(false).overAppliedNumber(null).overAppliedCalculationType(null)
.multiDisburseLoan(null).charges(List.of(new LoanProductChargeData().id(installmentFeeChargeId)));
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
double amount = 3000.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 October 2025", amount, numberOfRepayments)
.repaymentEvery(repaymentEvery).loanTermFrequency(numberOfRepayments)
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS).loanTermFrequencyType(RepaymentFrequencyType.MONTHS)
.interestType(InterestType.FLAT).interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)
.charges(List.of(new PostLoansRequestChargeData().chargeId(installmentFeeChargeId)
.amount(BigDecimal.valueOf(installmentFeeAmount))));
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 October 2025"));
aLoanId.getAndSet(approvedLoanResult.getLoanId());
Long loanId = aLoanId.get();
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 October 2025");
});
runAt("01 November 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 November 2025");
});
runAt("01 December 2025", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 December 2025");
});
runAt("01 January 2026", () -> {
Long loanId = aLoanId.get();
addRepaymentForLoan(loanId, 1100.0, "01 January 2026");
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertThat(loanDetails.getStatus().getCode()).isEqualTo("loanStatusType.closed.obligations.met");
});
runAt("15 January 2026", () -> {
Long loanId = aLoanId.get();
GetLoansLoanIdResponse regularApi = loanTransactionHelper.getLoanDetails(loanId);
LoanPointInTimeData pointInTimeApi = getPointInTimeData(loanId, "15 January 2026");
assertThat(pointInTimeApi.getFee().getFeeChargesCharged())
.as("Closed loan: point-in-time feeChargesCharged should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getFeeChargesCharged());
assertThat(pointInTimeApi.getFee().getFeeChargesPaid()).as("Closed loan: point-in-time feeChargesPaid should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getFeeChargesPaid());
assertThat(pointInTimeApi.getFee().getFeeChargesOutstanding())
.as("Closed loan: point-in-time feeChargesOutstanding should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getFeeChargesOutstanding());
assertThat(pointInTimeApi.getTotal().getTotalExpectedRepayment())
.as("Closed loan: point-in-time totalExpectedRepayment should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getTotalExpectedRepayment());
assertThat(pointInTimeApi.getTotal().getTotalExpectedCostOfLoan())
.as("Closed loan: point-in-time totalExpectedCostOfLoan should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getTotalExpectedCostOfLoan());
assertThat(pointInTimeApi.getPrincipal().getPrincipalOutstanding())
.as("Closed loan: point-in-time principalOutstanding should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getPrincipalOutstanding());
assertThat(pointInTimeApi.getTotal().getTotalOutstanding())
.as("Closed loan: point-in-time totalOutstanding should match regular API")
.isEqualByComparingTo(regularApi.getSummary().getTotalOutstanding());
});
}
private Long createInstallmentFeeCharge(double amount) {
Integer chargeId = ChargesHelper.createCharges(requestSpec, responseSpec,
ChargesHelper.getLoanInstallmentJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, String.valueOf(amount), false));
assertNotNull(chargeId);
return chargeId.longValue();
}
private Long createInstallmentPenaltyCharge(double amount) {
Integer chargeId = ChargesHelper.createCharges(requestSpec, responseSpec,
ChargesHelper.getLoanInstallmentJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, String.valueOf(amount), true));
assertNotNull(chargeId);
return chargeId.longValue();
}
} }