From f5aae95cd8d606138885ba0cbd9f53f35407beec Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Mon, 19 Jan 2026 15:27:10 +0100 Subject: [PATCH] FINERACT-2435: fix as of data api --- .../loanaccount/domain/LoanSummary.java | 27 + .../service/LoanPointInTimeServiceImpl.java | 73 ++- .../loan/pointintime/LoanPointInTimeTest.java | 593 ++++++++++++++++++ 3 files changed, 685 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 05d97ccb212..b025ae2b535 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 void updateFeeChargeOutstanding(final BigDecimal totalFeeChargesOutstandi 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 void updateTotalWaived(final BigDecimal 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 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 b0a3e91c06f..94f383c3c12 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.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 LoanPointInTimeData retrieveAt(Long loanId, LocalDate date) { 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 LoanPointInTimeData retrieveAt(Long loanId, 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) { 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 c06f8898b91..4901642ec47 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,13 @@ package org.apache.fineract.integrationtests.loan.pointintime; import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; +import static org.assertj.core.api.Assertions.assertThat; +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 +36,7 @@ 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 +862,593 @@ public void test_LoanPointInTimeDataWorks_ForArrearsDataCalculation_ForFutureDat assertThat(pointInTimeData.getArrears().getTotalOverdue()).isZero(); }); } + + @Test + public void test_LoanPointInTimeData_InstallmentFeeAllocation() { + AtomicReference 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 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 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 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 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 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(); + } }