FINERACT-2181: Fix for certain interest recalculation settings making prepay api and payment allocation inconsistent

This commit is contained in:
mark.vituska
2025-07-07 17:04:17 +02:00
committed by Adam Saghy
parent 01f850aecc
commit df995bc932
11 changed files with 332 additions and 146 deletions
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.im
import static java.math.BigDecimal.ZERO;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toList;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper.isInPeriod;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction.accrualAdjustment;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction.accrueTransaction;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.CHARGEBACK;
@@ -67,6 +68,7 @@ import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.data.TransactionChangeData;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
@@ -74,6 +76,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeOffBehaviour;
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalculationDetails;
import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper;
@@ -96,6 +99,7 @@ import org.apache.fineract.portfolio.loanaccount.service.InterestRefundService;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.schedule.LoanScheduleComponent;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.apache.fineract.portfolio.loanproduct.calc.data.OutstandingDetails;
import org.apache.fineract.portfolio.loanproduct.calc.data.PeriodDueDetails;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.calc.data.RepaymentPeriod;
@@ -103,7 +107,6 @@ import org.apache.fineract.portfolio.loanproduct.domain.AllocationType;
import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType;
import org.apache.fineract.portfolio.loanproduct.domain.DueType;
import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule;
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType;
import org.apache.fineract.util.LoopContext;
@@ -326,6 +329,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
public ChangedTransactionDetail processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) {
// If we are behind, we might need to first recalculate interest
if (ctx instanceof ProgressiveTransactionCtx progressiveTransactionCtx) {
if (loanTransaction.isRepaymentLikeType() && loanTransaction.isNotReversed()) {
progressiveTransactionCtx.setPrepayAttempt(calculateIsPrepayAttempt(loanTransaction, progressiveTransactionCtx));
}
recalculateInterestForDate(loanTransaction.getTransactionDate(), progressiveTransactionCtx);
}
switch (loanTransaction.getTypeOf()) {
@@ -1523,13 +1529,16 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
if (currentDate.isAfter(fromDate) && !currentDate.isAfter(toDate)) {
LocalDate lastOverdueBalanceChange;
if (currentInstallment.getLoan().getLoanInterestRecalculationDetails().getRestFrequencyType().isSameAsRepayment()) {
if (shouldRecalculateTillInstallmentDueDate(currentInstallment.getLoan().getLoanInterestRecalculationDetails(),
ctx.isPrepayAttempt())) {
lastOverdueBalanceChange = toDate;
} else {
lastOverdueBalanceChange = currentDate;
}
emiCalculator.addBalanceCorrection(ctx.getModel(), lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated());
ctx.getModel().lastOverdueBalanceChange(lastOverdueBalanceChange);
if (DateUtils.isBefore(ctx.getModel().lastOverdueBalanceChange(), toDate)) {
emiCalculator.addBalanceCorrection(ctx.getModel(), lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated());
ctx.getModel().lastOverdueBalanceChange(lastOverdueBalanceChange);
}
}
}
@@ -2419,7 +2428,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
if (DueType.IN_ADVANCE.equals(paymentAllocationType.getDueType())) {
payDate = calculateNewPayDateInCaseOfInAdvancePayment(loanTransaction, installment);
payDate = calculateNewPayDateInCaseOfInAdvancePayment(loanTransaction, installment, ctx.isPrepayAttempt());
updateRepaymentPeriodBalances(paymentAllocationType, installment, ctx, payDate);
}
@@ -2465,19 +2474,54 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
}
private LocalDate calculateNewPayDateInCaseOfInAdvancePayment(LoanTransaction loanTransaction,
LoanRepaymentScheduleInstallment inAdvanceInstallment) {
LoanPreCloseInterestCalculationStrategy strategy = loanTransaction.getLoan().getLoanInterestRecalculationDetails()
.getPreCloseInterestCalculationStrategy();
private boolean calculateIsPrepayAttempt(LoanTransaction loanTransaction, ProgressiveTransactionCtx progressiveTransactionCtx) {
Loan loan = loanTransaction.getLoan();
LoanRepaymentScheduleInstallment installment = loan.getRelatedRepaymentScheduleInstallment(loanTransaction.getTransactionDate());
if (installment == null) {
return false;
}
if (loan.getLoanInterestRecalculationDetails() == null) {
return false;
}
OutstandingDetails outstandingAmounts = emiCalculator.getOutstandingAmountsTillDate(progressiveTransactionCtx.getModel(),
installment.getDueDate());
OutstandingAmountsDTO result = new OutstandingAmountsDTO(progressiveTransactionCtx.getCurrency()) //
.principal(outstandingAmounts.getOutstandingPrincipal()) //
.interest(outstandingAmounts.getOutstandingInterest());//
return switch (strategy) {
case TILL_PRE_CLOSURE_DATE -> loanTransaction.getTransactionDate();
// TODO use isInPeriod
case TILL_REST_FREQUENCY_DATE -> loanTransaction.getTransactionDate().isAfter(inAdvanceInstallment.getFromDate()) //
&& !loanTransaction.getTransactionDate().isAfter(inAdvanceInstallment.getDueDate()) //
? inAdvanceInstallment.getDueDate() //
: loanTransaction.getTransactionDate(); //
case NONE -> throw new IllegalStateException("Unexpected PreClosureInterestCalculationStrategy: NONE");
return loanTransaction.getAmount(progressiveTransactionCtx.getCurrency()).isGreaterThanOrEqualTo(result.getTotalOutstanding());
}
private LocalDate calculateNewPayDateInCaseOfInAdvancePayment(LoanTransaction loanTransaction,
LoanRepaymentScheduleInstallment inAdvanceInstallment, boolean prepayAttempt) {
if (shouldRecalculateTillInstallmentDueDate(loanTransaction.getLoan().getLoanInterestRecalculationDetails(), prepayAttempt)) {
return isInPeriod(loanTransaction.getTransactionDate(), inAdvanceInstallment, false) ? inAdvanceInstallment.getDueDate()
: loanTransaction.getTransactionDate();
} else {
return loanTransaction.getTransactionDate();
}
}
private boolean shouldRecalculateTillInstallmentDueDate(LoanInterestRecalculationDetails recalculationDetails,
boolean isPrepayAttempt) {
// Rest frequency type and pre close interest calculation strategy can be controversial
// if restFrequencyType == DAILY and preCloseInterestCalculationStrategy == TILL_PRE_CLOSURE_DATE
// no problem. Calculate till transaction date
// if restFrequencyType == SAME_AS_REPAYMENT_PERIOD and preCloseInterestCalculationStrategy ==
// TILL_REST_FREQUENCY_DATE
// again, no problem. Calculate till due date of current installment
// if restFrequencyType == DAILY and preCloseInterestCalculationStrategy == TILL_REST_FREQUENCY_DATE
// or restFrequencyType == SAME_AS_REPAYMENT_PERIOD and preCloseInterestCalculationStrategy ==
// TILL_PRE_CLOSURE_DATE
// we cannot harmonize the two configs. Behaviour should mimic prepay api.
return switch (recalculationDetails.getRestFrequencyType()) {
case DAILY ->
isPrepayAttempt && recalculationDetails.getPreCloseInterestCalculationStrategy().calculateTillRestFrequencyEnabled();
case SAME_AS_REPAYMENT_PERIOD ->
recalculationDetails.getPreCloseInterestCalculationStrategy().calculateTillRestFrequencyEnabled();
case WEEKLY -> throw new IllegalStateException("Unexpected RecalculationFrequencyType: WEEKLY");
case MONTHLY -> throw new IllegalStateException("Unexpected RecalculationFrequencyType: MONTHLY");
case INVALID -> throw new IllegalStateException("Unexpected RecalculationFrequencyType: INVALID");
};
}
@@ -44,6 +44,8 @@ public class ProgressiveTransactionCtx extends TransactionCtx {
private boolean isChargedOff = false;
@Setter
private boolean isContractTerminated = false;
@Setter
private boolean isPrepayAttempt = false;
private List<LoanRepaymentScheduleInstallment> skipRepaymentScheduleInstallments = new ArrayList<>();
public ProgressiveTransactionCtx(MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
@@ -203,7 +203,7 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
Optional<ProgressiveLoanInterestScheduleModel> savedModel = interestScheduleModelRepositoryWrapper.getSavedModel(loan,
transactionDate);
ProgressiveLoanInterestScheduleModel model = savedModel
.orElseGet(() -> processor.calculateInterestScheduleModel(loan.getId(), onDate));
.orElseGet(() -> processor.calculateInterestScheduleModel(loan.getId(), transactionDate));
OutstandingDetails outstandingAmounts = emiCalculator.getOutstandingAmountsTillDate(model, transactionDate);
// TODO: We should add all the past due outstanding amounts as well
OutstandingAmountsDTO result = new OutstandingAmountsDTO(currency) //