FINERACT-2421: Fix phantom interest from overdue balance corrections on model

This commit is contained in:
Oleksii Novikov
2026-02-18 16:15:19 +00:00
committed by Adam Saghy
parent e2fa9e9762
commit e65257835b
4 changed files with 207 additions and 6 deletions
@@ -368,6 +368,18 @@ public final class ProgressiveEMICalculator implements EMICalculator {
});
}
public void addOverdueBalanceCorrection(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate correctionDate,
final Money overdueAmount) {
scheduleModel
.changeOutstandingBalanceAndUpdateInterestPeriods(correctionDate, scheduleModel.zero(), overdueAmount, scheduleModel.zero())
.ifPresent(repaymentPeriod -> {
scheduleModel.recordOverdueCorrection(correctionDate, overdueAmount, repaymentPeriod.getDueDate());
calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel);
calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel, correctionDate);
});
}
@Override
public void payInterest(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate repaymentPeriodFromDate,
LocalDate repaymentPeriodDueDate, LocalDate transactionDate, Money interestAmount) {
@@ -919,9 +931,9 @@ public final class ProgressiveEMICalculator implements EMICalculator {
if (!currentDate.equals(model.lastOverdueBalanceChange())) {
if (model.lastOverdueBalanceChange() == null || currentInstallment.getFromDate().isAfter(model.lastOverdueBalanceChange())) {
addBalanceCorrection(model, fromDate, overduePrincipal);
addOverdueBalanceCorrection(model, fromDate, overduePrincipal);
} else {
addBalanceCorrection(model, model.lastOverdueBalanceChange(), overduePrincipal);
addOverdueBalanceCorrection(model, model.lastOverdueBalanceChange(), overduePrincipal);
}
if (currentDate.isAfter(fromDate) && !currentDate.isAfter(toDate)) {
@@ -931,7 +943,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
} else {
lastOverdueBalanceChange = currentDate;
}
addBalanceCorrection(model, lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated());
addOverdueBalanceCorrection(model, lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated());
model.lastOverdueBalanceChange(lastOverdueBalanceChange);
}
return true;
@@ -1125,7 +1137,24 @@ public final class ProgressiveEMICalculator implements EMICalculator {
RepaymentPeriod repaymentPeriod, LocalDate tillDate) {
MathContext mc = scheduleModel.mc();
ProgressiveLoanInterestScheduleModel scheduleModelCopy = scheduleModel.deepCopy(mc);
// Reverse overdue corrections on the copy when they exist ON the target period
// but NOT beyond it. Beyond-target corrections provide legitimate cascading interest.
// Without reversal, overdue-inflated IPs create phantom futureUnrecognizedInterest.
final boolean hasOverdueCorrectionsBeyondTarget = scheduleModelCopy.hasOverdueCorrectionsBeyondDate(repaymentPeriod.getDueDate());
final boolean hasOverdueCorrectionsOnTarget = scheduleModelCopy.hasOverdueCorrectionsOnDate(repaymentPeriod.getDueDate());
final boolean shouldResetOverdue = !hasOverdueCorrectionsBeyondTarget && hasOverdueCorrectionsOnTarget;
if (shouldResetOverdue) {
scheduleModelCopy.reverseOverdueCorrections();
}
calculateRateFactorForScheduleTillDateInclusive(scheduleModelCopy, tillDate);
if (shouldResetOverdue) {
calculateOutstandingBalance(scheduleModelCopy);
}
Optional<RepaymentPeriod> futureUnrecognizedInterestPeriod = getPeriodWithUnrecognizedInterest(repaymentPeriod, scheduleModelCopy);
futureUnrecognizedInterestPeriod.ifPresent(period -> {
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanproduct.calc.data;
import java.time.LocalDate;
import org.apache.fineract.organisation.monetary.domain.Money;
/**
* Records an overdue balance correction applied to the schedule model. Used for detecting and reversing overdue
* corrections on model copies to prevent phantom futureUnrecognizedInterest.
*
* @param correctionDate
* the date where the correction was applied (matches an InterestPeriod's dueDate after IP split)
* @param amount
* the correction amount (+X for inflation, -X for deflation)
* @param affectedRpDueDate
* the dueDate of the RepaymentPeriod that received this correction (for beyond/onTarget detection)
*/
public record OverdueBalanceCorrection(LocalDate correctionDate, Money amount, LocalDate affectedRpDueDate) {
}
@@ -68,6 +68,7 @@ public class ProgressiveLoanInterestScheduleModel {
@Setter
private LocalDate lastOverdueBalanceChange;
private List<OverdueBalanceCorrection> overdueCorrections = new ArrayList<>();
public ProgressiveLoanInterestScheduleModel(final List<RepaymentPeriod> repaymentPeriods,
final ILoanConfigurationDetails loanProductRelatedDetail, final Integer installmentAmountInMultiplesOf, final MathContext mc) {
@@ -95,9 +96,35 @@ public class ProgressiveLoanInterestScheduleModel {
loanProductRelatedDetail.isInterestRecalculationEnabled()));
}
public ProgressiveLoanInterestScheduleModel deepCopy(MathContext mc) {
return new ProgressiveLoanInterestScheduleModel(repaymentPeriods, interestRates, loanProductRelatedDetail,
installmentAmountInMultiplesOf, mc, false);
public void recordOverdueCorrection(final LocalDate correctionDate, final Money amount, final LocalDate affectedRpDueDate) {
overdueCorrections.add(new OverdueBalanceCorrection(correctionDate, amount, affectedRpDueDate));
}
public boolean hasOverdueCorrectionsBeyondDate(final LocalDate targetDueDate) {
return overdueCorrections.stream().anyMatch(oc -> oc.affectedRpDueDate().isAfter(targetDueDate));
}
public boolean hasOverdueCorrectionsOnDate(final LocalDate targetDueDate) {
return overdueCorrections.stream().anyMatch(oc -> oc.affectedRpDueDate().isEqual(targetDueDate));
}
/**
* Reverses all recorded overdue corrections on this model by subtracting each correction's amount from the
* corresponding InterestPeriod's balanceCorrectionAmount.
*/
public void reverseOverdueCorrections() {
for (final OverdueBalanceCorrection oc : overdueCorrections) {
changeOutstandingBalanceAndUpdateInterestPeriods(oc.correctionDate(), zero(), oc.amount().negated(), zero());
}
overdueCorrections.clear();
this.lastOverdueBalanceChange = null;
}
public ProgressiveLoanInterestScheduleModel deepCopy(final MathContext mc) {
final ProgressiveLoanInterestScheduleModel copy = new ProgressiveLoanInterestScheduleModel(repaymentPeriods, interestRates,
loanProductRelatedDetail, installmentAmountInMultiplesOf, mc, false);
copy.overdueCorrections = new ArrayList<>(this.overdueCorrections);
return copy;
}
public ProgressiveLoanInterestScheduleModel copyWithoutPaidAmounts() {