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();
+    }
 }


Reply via email to