This is an automated email from the ASF dual-hosted git repository.
arnold pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new bf879064af FINERACT-2435: fix as of data api
bf879064af is described below
commit bf879064afbf15c43667933dc9080cd2124c5980
Author: Attila Budai <[email protected]>
AuthorDate: Mon Jan 19 15:27:10 2026 +0100
FINERACT-2435: fix as of data api
---
.../portfolio/loanaccount/domain/LoanSummary.java | 27 +
.../service/LoanPointInTimeServiceImpl.java | 73 ++-
.../loan/pointintime/LoanPointInTimeTest.java | 592 +++++++++++++++++++++
3 files changed, 684 insertions(+), 8 deletions(-)
diff --git
a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanSummary.java
b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanSummary.java
index 05d97ccb21..b025ae2b53 100644
---
a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanSummary.java
+++
b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanSummary.java
@@ -285,6 +285,10 @@ public class LoanSummary {
this.totalFeeChargesOutstanding = totalFeeChargesOutstanding;
}
+ public void updateFeeChargesCharged(final BigDecimal
totalFeeChargesCharged) {
+ this.totalFeeChargesCharged = totalFeeChargesCharged;
+ }
+
public void updatePenaltyChargeOutstanding(final BigDecimal
totalPenaltyChargesOutstanding) {
this.totalPenaltyChargesOutstanding = totalPenaltyChargesOutstanding;
}
@@ -309,6 +313,29 @@ public class LoanSummary {
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,
final MonetaryCurrency currency) {
Money total = Money.zero(currency);
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java
index b0a3e91c06..94f383c3c1 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java
@@ -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.service.DateUtils;
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.ScheduleGeneratorDTO;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -73,15 +76,14 @@ public class LoanPointInTimeServiceImpl implements
LoanPointInTimeService {
int afterRemovalTxCount = loan.getLoanTransactions().size();
int afterRemovalChargeCount = loan.getCharges().size();
- // In case the loan is cumulative and is being prepaid by the
latest repayment tx, we need the
- // recalculateFrom and recalculateTill
- // set to the same date which is the prepaying transaction's date
- // 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) {
+ boolean needsScheduleRegeneration = txCount != afterRemovalTxCount
|| chargeCount != afterRemovalChargeCount;
+
+ if (needsScheduleRegeneration) {
ScheduleGeneratorDTO scheduleGeneratorDTO =
loanUtilService.buildScheduleGeneratorDTO(loan, null, null);
loanScheduleService.regenerateScheduleWithReprocessingTransactions(loan,
scheduleGeneratorDTO);
+ recalculateSummaryForInstallmentsUpToDate(loan, date);
+ } else if (!loan.isClosed()) {
+ recalculateSummaryForInstallmentsUpToDate(loan, date);
}
LoanArrearsData arrearsData =
arrearsAgingService.calculateArrearsForLoan(loan);
@@ -97,7 +99,62 @@ public class LoanPointInTimeServiceImpl implements
LoanPointInTimeService {
}
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) {
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java
index c06f8898b9..17bd2eb7b5 100644
---
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java
@@ -19,10 +19,12 @@
package org.apache.fineract.integrationtests.loan.pointintime;
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.util.List;
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.LoanProductChargeData;
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.integrationtests.BaseLoanIntegrationTest;
import org.apache.fineract.integrationtests.common.ClientHelper;
+import org.apache.fineract.integrationtests.common.charges.ChargesHelper;
import org.junit.jupiter.api.Test;
public class LoanPointInTimeTest extends BaseLoanIntegrationTest {
@@ -858,4 +861,593 @@ public class LoanPointInTimeTest extends
BaseLoanIntegrationTest {
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();
+ }
}