From a950e4e36a37f352885a8e88f99814d32824e5f6 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Tue, 7 Oct 2025 20:12:12 +0200 Subject: [PATCH] FINERACT-2326: Loan point in time API now properly handles future dates --- .../service/LoanPointInTimeServiceImpl.java | 17 ++- .../BaseLoanIntegrationTest.java | 2 +- .../loan/pointintime/LoanPointInTimeTest.java | 112 +++++++++++++++++- 3 files changed, 124 insertions(+), 7 deletions(-) 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 278c2a9a7..da5c8db76 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 @@ -65,11 +65,24 @@ public class LoanPointInTimeServiceImpl implements LoanPointInTimeService { ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, date))); Loan loan = loanAssembler.assembleFrom(loanId); + + int txCount = loan.getLoanTransactions().size(); + int chargeCount = loan.getCharges().size(); removeAfterDateTransactions(loan, date); removeAfterDateCharges(loan, date); + int afterRemovalTxCount = loan.getLoanTransactions().size(); + int afterRemovalChargeCount = loan.getCharges().size(); - ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null, null); - loanScheduleService.recalculateSchedule(loan, scheduleGeneratorDTO); + // 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) { + ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null, null); + loanScheduleService.recalculateSchedule(loan, scheduleGeneratorDTO); + } LoanArrearsData arrearsData = arrearsAgingService.calculateArrearsForLoan(loan); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index df8cc9d84..ba07af2d6 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -992,7 +992,7 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { } } - protected void verifyArreals(LoanPointInTimeData pointInTimeData, boolean isOverDue, String overdueSince) { + protected void verifyArrears(LoanPointInTimeData pointInTimeData, boolean isOverDue, String overdueSince) { assertThat(Objects.requireNonNull(pointInTimeData.getArrears()).getOverdue()).isEqualTo(isOverDue); if (isOverDue) { assertThat(Objects.requireNonNull(pointInTimeData.getArrears().getOverDueSince()).toString()).isEqualTo(overdueSince); 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 969a87032..81705b52d 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 @@ -658,7 +658,7 @@ public class LoanPointInTimeTest extends BaseLoanIntegrationTest { } @Test - public void test_LoanPointInTimeDataWorks_ForArrealDataCalculation() { + public void test_LoanPointInTimeDataWorks_ForArrearsDataCalculation() { AtomicReference aLoanId = new AtomicReference<>(); runAt("01 January 2023", () -> { @@ -683,7 +683,6 @@ public class LoanPointInTimeTest extends BaseLoanIntegrationTest { .interestType(InterestType.DECLINING_BALANCE)// .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// - .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// .isInterestRecalculationEnabled(true)// .recalculationRestFrequencyInterval(1)// .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// @@ -737,14 +736,14 @@ public class LoanPointInTimeTest extends BaseLoanIntegrationTest { LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "10 February 2023"); verifyOutstanding(pointInTimeData, outstanding(5000.0, 0.0, 0.0, 0.0, 5000.0)); - verifyArreals(pointInTimeData, true, "2023-02-01"); + verifyArrears(pointInTimeData, true, "2023-02-01"); // repay 500 addRepaymentForLoan(loanId, 2500.0, "01 February 2023"); LoanPointInTimeData pointInTimeDataAfterRepay = getPointInTimeData(loanId, "10 February 2023"); verifyOutstanding(pointInTimeDataAfterRepay, outstanding(2500.0, 0.0, 0.0, 0.0, 2500.0)); - verifyArreals(pointInTimeDataAfterRepay, false, null); + verifyArrears(pointInTimeDataAfterRepay, false, null); // verify transactions verifyTransactions(loanId, // @@ -754,4 +753,109 @@ public class LoanPointInTimeTest extends BaseLoanIntegrationTest { ); }); } + + @Test + public void test_LoanPointInTimeDataWorks_ForArrearsDataCalculation_ForFutureDate_WithInterest() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create charges + double charge1Amount = 1.0; + double charge2Amount = 1.5; + Long charge1Id = createDisbursementPercentageCharge(charge1Amount); + Long charge2Id = createDisbursementPercentageCharge(charge2Amount); + + // Create Loan Product + double interestRatePerPeriod = 10.0; + PostLoanProductsRequest product = createOnePeriod30DaysPeriodicAccrualProduct(interestRatePerPeriod) // + .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)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new LoanProductChargeData().id(charge1Id), new LoanProductChargeData().id(charge2Id)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .interestRatePerPeriod(BigDecimal.valueOf(interestRatePerPeriod)).loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .charges(List.of(// + new PostLoansRequestChargeData().chargeId(charge1Id).amount(BigDecimal.valueOf(charge1Amount)), // + new PostLoansRequestChargeData().chargeId(charge2Id).amount(BigDecimal.valueOf(charge2Amount))// + ));// + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023") // + ); + }); + + runAt("05 March 2023", () -> { + Long loanId = aLoanId.get(); + + // repay + addRepaymentForLoan(loanId, 5897.89, "05 March 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(5897.89, "Repayment", "05 March 2023"), // + transaction(897.89, "Accrual", "05 March 2023") // + ); + }); + + runAt("05 June 2023", () -> { + Long loanId = aLoanId.get(); + + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "05 June 2023"); + + verifyOutstanding(pointInTimeData, outstanding(0.0, 0.0, 0.0, 0.0, 0.0)); + verifyArrears(pointInTimeData, false, null); + assertThat(pointInTimeData.getArrears().getPrincipalOverdue()).isZero(); + assertThat(pointInTimeData.getArrears().getFeeOverdue()).isZero(); + assertThat(pointInTimeData.getArrears().getInterestOverdue()).isZero(); + assertThat(pointInTimeData.getArrears().getPenaltyOverdue()).isZero(); + assertThat(pointInTimeData.getArrears().getTotalOverdue()).isZero(); + }); + } }