FINERACT-2354: First step - basic implementation of re-aging for Interest bearing loans - Default Behavior, interestRecalculation = true, without dueDate change

This commit is contained in:
mariiaKraievska
2025-10-27 14:22:46 +01:00
committed by Adam Saghy
parent 23c1b2b8af
commit d21a1a98a2
17 changed files with 416 additions and 461 deletions
@@ -47,6 +47,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -97,6 +98,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParamet
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.InterestRefundService;
@@ -2890,76 +2892,149 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
MonetaryCurrency currency = ctx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
AtomicReference<Money> outstandingPrincipalBalance = new AtomicReference<>(Money.zero(currency));
installments.forEach(i -> {
Money principalOutstanding = i.getPrincipalOutstanding(currency);
if (principalOutstanding.isGreaterThanZero()) {
outstandingPrincipalBalance.set(outstandingPrincipalBalance.get().add(principalOutstanding));
i.addToPrincipal(loanTransaction.getTransactionDate(), principalOutstanding.negated());
// re-aging logic for interest-bearing loans
if (ctx instanceof ProgressiveTransactionCtx progressiveTransactionCtx
&& loanTransaction.getLoan().isInterestBearingAndInterestRecalculationEnabled()) {
handleReAgeWithInterestRecalculationEnabled(loanTransaction, progressiveTransactionCtx);
} else if (loanTransaction.getLoan().isInterestBearing() && !loanTransaction.getLoan().isInterestRecalculationEnabled()) {
// TODO: implement interestRecalculation = false logic
throw new NotImplementedException(
"Logic for re-aging when interest bearing loan has interestRecalculation disabled is not implemented");
} else {
AtomicReference<Money> outstandingPrincipalBalance = new AtomicReference<>(Money.zero(currency));
installments.forEach(i -> {
Money principalOutstanding = i.getPrincipalOutstanding(currency);
if (principalOutstanding.isGreaterThanZero()) {
outstandingPrincipalBalance.set(outstandingPrincipalBalance.get().add(principalOutstanding));
i.addToPrincipal(loanTransaction.getTransactionDate(), principalOutstanding.negated());
}
});
loanTransaction.updateComponentsAndTotal(outstandingPrincipalBalance.get(), Money.zero(currency), Money.zero(currency),
Money.zero(currency));
if (outstandingPrincipalBalance.get().isZero()) {
loanTransaction.reverse();
return;
}
Money calculatedPrincipal = Money.zero(currency);
Money adjustCalculatedPrincipal = Money.zero(currency);
if (outstandingPrincipalBalance.get().isGreaterThanZero()) {
calculatedPrincipal = outstandingPrincipalBalance.get()
.dividedBy(loanTransaction.getLoanReAgeParameter().getNumberOfInstallments(), MoneyHelper.getMathContext());
Integer installmentAmountInMultiplesOf = loanTransaction.getLoan().getLoanProductRelatedDetail()
.getInstallmentAmountInMultiplesOf();
if (installmentAmountInMultiplesOf != null) {
calculatedPrincipal = Money.roundToMultiplesOf(calculatedPrincipal, installmentAmountInMultiplesOf);
}
adjustCalculatedPrincipal = outstandingPrincipalBalance.get()
.minus(calculatedPrincipal.multipliedBy(loanTransaction.getLoanReAgeParameter().getNumberOfInstallments()));
}
Optional<LoanRepaymentScheduleInstallment> lastNormalInstallmentOptional = installments.stream().filter(i -> !i.isDownPayment())
.filter(i -> i.getDueDate().isBefore(loanTransaction.getTransactionDate())).reduce((first, second) -> second);
int reAgedInstallmentNumber;
LocalDate fromDate;
Loan loan;
if (lastNormalInstallmentOptional.isEmpty()) {
LoanRepaymentScheduleInstallment firstNormalInstallment = installments.stream().filter(i -> !i.isDownPayment())
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate)).orElseThrow();
reAgedInstallmentNumber = firstNormalInstallment.getInstallmentNumber();
fromDate = firstNormalInstallment.getFromDate();
loan = firstNormalInstallment.getLoan();
} else {
LoanRepaymentScheduleInstallment lastNormalInstallment = lastNormalInstallmentOptional.get();
reAgedInstallmentNumber = lastNormalInstallment.getInstallmentNumber() + 1;
fromDate = lastNormalInstallment.getDueDate();
loan = lastNormalInstallment.getLoan();
}
LoanRepaymentScheduleInstallment reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(loan,
reAgedInstallmentNumber, fromDate, loanTransaction.getLoanReAgeParameter().getStartDate(),
calculatedPrincipal.getAmount());
insertOrReplaceRelatedInstallment(installments, reAgedInstallment, currency, loanTransaction.getTransactionDate());
for (int i = 1; i < loanTransaction.getLoanReAgeParameter().getNumberOfInstallments(); i++) {
LocalDate calculatedDueDate = calculateReAgedInstallmentDueDate(loanTransaction.getLoanReAgeParameter(),
reAgedInstallment.getDueDate());
int nextReAgedInstallmentNumber = reAgedInstallment.getInstallmentNumber() + 1;
reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(reAgedInstallment.getLoan(),
nextReAgedInstallmentNumber, reAgedInstallment.getDueDate(), calculatedDueDate, calculatedPrincipal.getAmount());
if (i + 1 == loanTransaction.getLoanReAgeParameter().getNumberOfInstallments()) {
reAgedInstallment.addToPrincipal(loanTransaction.getTransactionDate(), adjustCalculatedPrincipal);
}
insertOrReplaceRelatedInstallment(installments, reAgedInstallment, currency, loanTransaction.getTransactionDate());
}
int lastReAgedInstallmentNumber = reAgedInstallment.getInstallmentNumber();
List<LoanRepaymentScheduleInstallment> toRemove = installments.stream().filter(i -> i != null && !i.isAdditional()
&& i.getInstallmentNumber() != null && i.getInstallmentNumber() > lastReAgedInstallmentNumber).toList();
toRemove.forEach(installments::remove);
reprocessInstallments(installments);
}
}
private void cleanupInstallmentsAndRepaymentPeriodsAfterReAging(final ProgressiveTransactionCtx ctx) {
final List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
final List<RepaymentPeriod> repaymentPeriods = ctx.getModel().repaymentPeriods();
// Find the last re-aged installment number
final OptionalInt lastReAgedInstallmentNumberOpt = installments.stream().filter(LoanRepaymentScheduleInstallment::isReAged)
.mapToInt(LoanRepaymentScheduleInstallment::getInstallmentNumber).max();
if (lastReAgedInstallmentNumberOpt.isPresent()) {
final int lastReAgedInstallmentNumber = lastReAgedInstallmentNumberOpt.getAsInt();
final LoanRepaymentScheduleInstallment lastReAgedInstallment = installments.stream()
.filter(i -> i.getInstallmentNumber().equals(lastReAgedInstallmentNumber)).findFirst().orElse(null);
// Remove installments with numbers greater than the last re-aged installment
final List<LoanRepaymentScheduleInstallment> installmentsToRemove = installments.stream().filter(i -> i != null
&& !i.isAdditional() && i.getInstallmentNumber() != null && i.getInstallmentNumber() > lastReAgedInstallmentNumber)
.toList();
if (lastReAgedInstallment != null) {
final List<RepaymentPeriod> repaymentPeriodsToRemove = repaymentPeriods.stream()
.filter(rp -> !rp.getFromDate().isBefore(lastReAgedInstallment.getDueDate())).toList();
repaymentPeriodsToRemove.forEach(repaymentPeriods::remove);
}
installmentsToRemove.forEach(installments::remove);
}
}
private void updateInstallmentsByModelForReAging(final LoanTransaction loanTransaction, final ProgressiveTransactionCtx ctx) {
ctx.getModel().repaymentPeriods().forEach(rp -> {
final LoanRepaymentScheduleInstallment installment = ctx.getInstallments().stream().filter(
ri -> ri.getFromDate().equals(rp.getFromDate()) && ri.getDueDate().equals(rp.getDueDate()) && !ri.isDownPayment())
.findFirst().orElse(null);
final LocalDate transactionDate = loanTransaction.getTransactionDate();
if (installment != null) {
installment.setFromDate(rp.getFromDate());
installment.setDueDate(rp.getDueDate());
if (rp.getEmi().isZero()) {
installment.updatePrincipal(BigDecimal.ZERO);
installment.updateInterestCharged(BigDecimal.ZERO);
} else {
installment.updatePrincipal(rp.getDuePrincipal().getAmount());
installment.updateInterestCharged(rp.getDueInterest().getAmount());
}
installment.setReAged(true);
installment.updateObligationsMet(ctx.getCurrency(), transactionDate);
} else {
final LoanRepaymentScheduleInstallment lastInstallment = ctx.getInstallments().getLast();
final LoanRepaymentScheduleInstallment newInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(
loanTransaction.getLoan(), lastInstallment.getInstallmentNumber() + 1, rp.getFromDate(), rp.getDueDate(),
rp.getDuePrincipal().getAmount());
if (rp.getDueInterest().isGreaterThanZero()) {
newInstallment.addToInterest(transactionDate, rp.getDueInterest());
}
newInstallment.updateObligationsMet(ctx.getCurrency(), transactionDate);
ctx.getInstallments().add(newInstallment);
}
});
loanTransaction.updateComponentsAndTotal(outstandingPrincipalBalance.get(), Money.zero(currency), Money.zero(currency),
Money.zero(currency));
if (outstandingPrincipalBalance.get().isZero()) {
loanTransaction.reverse();
return;
}
Money calculatedPrincipal = Money.zero(currency);
Money adjustCalculatedPrincipal = Money.zero(currency);
if (outstandingPrincipalBalance.get().isGreaterThanZero()) {
calculatedPrincipal = outstandingPrincipalBalance.get()
.dividedBy(loanTransaction.getLoanReAgeParameter().getNumberOfInstallments(), MoneyHelper.getMathContext());
Integer installmentAmountInMultiplesOf = loanTransaction.getLoan().getLoanProductRelatedDetail()
.getInstallmentAmountInMultiplesOf();
if (installmentAmountInMultiplesOf != null) {
calculatedPrincipal = Money.roundToMultiplesOf(calculatedPrincipal, installmentAmountInMultiplesOf);
}
adjustCalculatedPrincipal = outstandingPrincipalBalance.get()
.minus(calculatedPrincipal.multipliedBy(loanTransaction.getLoanReAgeParameter().getNumberOfInstallments()));
}
Optional<LoanRepaymentScheduleInstallment> lastNormalInstallmentOptional = installments.stream().filter(i -> !i.isDownPayment())
.filter(i -> i.getDueDate().isBefore(loanTransaction.getTransactionDate())).reduce((first, second) -> second);
int reAgedInstallmentNumber;
LocalDate fromDate;
Loan loan;
if (lastNormalInstallmentOptional.isEmpty()) {
LoanRepaymentScheduleInstallment firstNormalInstallment = installments.stream().filter(i -> !i.isDownPayment())
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate)).orElseThrow();
reAgedInstallmentNumber = firstNormalInstallment.getInstallmentNumber();
fromDate = firstNormalInstallment.getFromDate();
loan = firstNormalInstallment.getLoan();
} else {
LoanRepaymentScheduleInstallment lastNormalInstallment = lastNormalInstallmentOptional.get();
reAgedInstallmentNumber = lastNormalInstallment.getInstallmentNumber() + 1;
fromDate = lastNormalInstallment.getDueDate();
loan = lastNormalInstallment.getLoan();
}
LoanRepaymentScheduleInstallment reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(loan,
reAgedInstallmentNumber, fromDate, loanTransaction.getLoanReAgeParameter().getStartDate(), calculatedPrincipal.getAmount());
insertOrReplaceRelatedInstallment(installments, reAgedInstallment, currency, loanTransaction.getTransactionDate());
for (int i = 1; i < loanTransaction.getLoanReAgeParameter().getNumberOfInstallments(); i++) {
LocalDate calculatedDueDate = calculateReAgedInstallmentDueDate(loanTransaction.getLoanReAgeParameter(),
reAgedInstallment.getDueDate());
int nextReAgedInstallmentNumber = reAgedInstallment.getInstallmentNumber() + 1;
reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(reAgedInstallment.getLoan(),
nextReAgedInstallmentNumber, reAgedInstallment.getDueDate(), calculatedDueDate, calculatedPrincipal.getAmount());
if (i + 1 == loanTransaction.getLoanReAgeParameter().getNumberOfInstallments()) {
reAgedInstallment.addToPrincipal(loanTransaction.getTransactionDate(), adjustCalculatedPrincipal);
}
insertOrReplaceRelatedInstallment(installments, reAgedInstallment, currency, loanTransaction.getTransactionDate());
}
int lastReAgedInstallmentNumber = reAgedInstallment.getInstallmentNumber();
List<LoanRepaymentScheduleInstallment> toRemove = installments.stream().filter(i -> i != null && !i.isAdditional()
&& i.getInstallmentNumber() != null && i.getInstallmentNumber() > lastReAgedInstallmentNumber).toList();
toRemove.forEach(installments::remove);
reprocessInstallments(installments);
cleanupInstallmentsAndRepaymentPeriodsAfterReAging(ctx);
}
private void reprocessInstallments(final List<LoanRepaymentScheduleInstallment> installments) {
@@ -3211,4 +3286,75 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
return false;
}
}
private void handleReAgeWithInterestRecalculationEnabled(final LoanTransaction loanTransaction, final ProgressiveTransactionCtx ctx) {
final MonetaryCurrency currency = ctx.getCurrency();
final Loan loan = loanTransaction.getLoan();
final MathContext mc = MoneyHelper.getMathContext();
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
final List<RepaymentPeriod> repaymentPeriods = ctx.getModel().repaymentPeriods();
final LocalDate reAgingStartDate = loanTransaction.getLoanReAgeParameter().getStartDate();
final List<RepaymentPeriod> periodsBeforeReAging = repaymentPeriods.stream()
.filter(rp -> rp.getFromDate().isBefore(reAgingStartDate) && !rp.isFullyPaid()).toList();
final RepaymentPeriod lastPeriod = periodsBeforeReAging.getLast();
if (!lastPeriod.getDueDate().isEqual(reAgingStartDate)) {
// TODO: implement logic when re-aging changes the due dates
throw new NotImplementedException("Logic when re-aging changes the due dates not implemented");
}
final BigDecimal interestBeforeReAging = emiCalculator
.getPeriodInterestTillDate(ctx.getModel(), lastPeriod.getDueDate(), transactionDate, false).getAmount();
final AtomicReference<Money> outstandingPrincipalBalance = new AtomicReference<>(Money.zero(currency));
installments.forEach(i -> {
final Money principalOutstanding = i.getPrincipalOutstanding(currency);
if (principalOutstanding.isGreaterThanZero()) {
outstandingPrincipalBalance.set(outstandingPrincipalBalance.get().add(principalOutstanding));
}
});
final AtomicReference<BigDecimal> interestFromZeroedInstallments = new AtomicReference<>(interestBeforeReAging);
installments.stream()
.filter(installment -> !installment.isObligationsMet() && !installment.getDueDate().isAfter(lastPeriod.getFromDate()))
.forEach(installment -> {
final BigDecimal currentInterest = interestFromZeroedInstallments.get();
final BigDecimal additionalInterest = MathUtil.nullToZero(installment.getInterestOutstanding(currency).getAmount()
.add(MathUtil.nullToZero(installment.getCreditedInterest()).negate()));
interestFromZeroedInstallments.set(currentInterest.add(additionalInterest));
});
final BigDecimal interestRate = loan.getLoanRepaymentScheduleDetail().getAnnualNominalInterestRate();
final LoanApplicationTerms loanApplicationTerms = new LoanApplicationTerms.Builder().currency(currency.getCurrencyData())
.repaymentsStartingFromDate(reAgingStartDate).principal(outstandingPrincipalBalance.get())
.loanTermFrequency(loanTransaction.getLoanReAgeParameter().getNumberOfInstallments())
.loanTermPeriodFrequencyType(loanTransaction.getLoanReAgeParameter().getFrequencyType())
.numberOfRepayments(loanTransaction.getLoanReAgeParameter().getNumberOfInstallments())
.repaymentEvery(loanTransaction.getLoanReAgeParameter().getFrequencyNumber())
.repaymentPeriodFrequencyType(loanTransaction.getLoanReAgeParameter().getFrequencyType())
.interestRatePerPeriod(interestRate)
.interestRatePeriodFrequencyType(loan.getLoanRepaymentScheduleDetail().getRepaymentPeriodFrequencyType())
.annualNominalInterestRate(interestRate).daysInMonthType(loan.getLoanProduct().fetchDaysInMonthType())
.daysInYearType(loan.getLoanProduct().fetchDaysInYearType()).inArrearsTolerance(Money.zero(currency, mc))
.isDownPaymentEnabled(false).downPaymentPercentage(ZERO).seedDate(reAgingStartDate)
.interestRecognitionOnDisbursementDate(
loan.getLoanProduct().getLoanProductRelatedDetail().isInterestRecognitionOnDisbursementDate())
.daysInYearCustomStrategy(loan.getLoanProduct().getLoanProductRelatedDetail().getDaysInYearCustomStrategy())
.interestMethod(loan.getLoanProductRelatedDetail().getInterestMethod()).allowPartialPeriodInterestCalculation(
loan.getLoanProduct().getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalculation())
.mc(mc).build();
// Update the existing model with re-aged periods
emiCalculator.updateModelRepaymentPeriodsDuringReAge(ctx.getModel(), loanTransaction, loanApplicationTerms, mc);
updateInstallmentsByModelForReAging(loanTransaction, ctx);
loanTransaction.updateComponentsAndTotal(outstandingPrincipalBalance.get(),
Money.of(currency, interestFromZeroedInstallments.get()), Money.zero(currency), Money.zero(currency));
reprocessInstallments(installments);
}
}
@@ -27,6 +27,8 @@ import java.util.Optional;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.calc.data.OutstandingDetails;
import org.apache.fineract.portfolio.loanproduct.calc.data.PeriodDueDetails;
@@ -140,4 +142,7 @@ public interface EMICalculator {
* interest paused.
*/
void applyInterestPause(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate fromDate, LocalDate endDate);
void updateModelRepaymentPeriodsDuringReAge(ProgressiveLoanInterestScheduleModel scheduleModel, LoanTransaction loanTransaction,
LoanApplicationTerms loanApplicationTerms, MathContext mc);
}
@@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.apache.fineract.portfolio.common.domain.DaysInYearCustomStrategyType;
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
@@ -47,7 +48,11 @@ import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariationType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.ScheduledDateGenerator;
import org.apache.fineract.portfolio.loanproduct.calc.data.EmiAdjustment;
import org.apache.fineract.portfolio.loanproduct.calc.data.EmiChangeOperation;
import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod;
@@ -66,6 +71,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
private static final BigDecimal DIVISOR_100 = new BigDecimal("100");
private static final BigDecimal ONE_WEEK_IN_DAYS = BigDecimal.valueOf(7);
private final ScheduledDateGenerator scheduledDateGenerator;
@Override
@NotNull
public ProgressiveLoanInterestScheduleModel generatePeriodInterestScheduleModel(@NotNull List<LoanScheduleModelRepaymentPeriod> periods,
@@ -559,6 +566,129 @@ public final class ProgressiveEMICalculator implements EMICalculator {
}
}
@Override
public void updateModelRepaymentPeriodsDuringReAge(final ProgressiveLoanInterestScheduleModel scheduleModel,
final LoanTransaction loanTransaction, final LoanApplicationTerms loanApplicationTerms, final MathContext mc) {
final LoanReAgeParameter loanReAgeParameter = loanTransaction.getLoanReAgeParameter();
final LocalDate reAgingStartDate = loanReAgeParameter.getStartDate();
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final List<RepaymentPeriod> existingRepaymentPeriods = scheduleModel.repaymentPeriods();
moveOutstandingAmountsFromPeriodsBeforeReAging(existingRepaymentPeriods, reAgingStartDate);
final LocalDate periodStartDate = calculateFirstReAgedPeriodStartDate(loanReAgeParameter);
final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generateTemporaryReAgedScheduleModel(loanApplicationTerms,
mc, periodStartDate, transactionDate);
mergeNewInterestScheduleModelWithExistingOne(scheduleModel, temporaryReAgedScheduleModel, loanTransaction);
}
/**
* * Merging the new temporary model of re-aged repayment periods and existing one together. After that recalculate
* the balances of the updated model and also recalculate the EMI if the EMI of the last repayment period differs
* significantly from other periods.
*/
private void mergeNewInterestScheduleModelWithExistingOne(final ProgressiveLoanInterestScheduleModel scheduleModel,
final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel, final LoanTransaction loanTransaction) {
final List<RepaymentPeriod> newPeriods = temporaryReAgedScheduleModel.repaymentPeriods();
if (newPeriods.isEmpty()) {
return;
}
final List<RepaymentPeriod> existingRepaymentPeriods = scheduleModel.repaymentPeriods();
final LocalDate reAgingStartDate = loanTransaction.getLoanReAgeParameter().getStartDate();
final Optional<RepaymentPeriod> firstExistingRepaymentPeriodOpt = existingRepaymentPeriods.stream()
.filter(period -> period.getDueDate().equals(reAgingStartDate)).findFirst();
for (final RepaymentPeriod newPeriod : newPeriods) {
final Optional<RepaymentPeriod> existingRepaymentPeriodOpt = existingRepaymentPeriods.stream().filter(
period -> period.getFromDate().equals(newPeriod.getFromDate()) && period.getDueDate().equals(newPeriod.getDueDate()))
.findFirst();
Optional<RepaymentPeriod> previousExistingRepaymentPeriodOpt = Optional.empty();
if (existingRepaymentPeriodOpt.isPresent() && firstExistingRepaymentPeriodOpt.isPresent()
&& existingRepaymentPeriodOpt.get().equals(firstExistingRepaymentPeriodOpt.get())) {
previousExistingRepaymentPeriodOpt = existingRepaymentPeriodOpt.get().getPrevious();
}
final Money newPrincipal = newPeriod.getDuePrincipal();
final Money newInterest = newPeriod.getDueInterest();
final RepaymentPeriod rp = RepaymentPeriod.create(
previousExistingRepaymentPeriodOpt.orElseGet(existingRepaymentPeriods::getLast), newPeriod.getFromDate(),
newPeriod.getDueDate(), newPrincipal.add(newInterest), MoneyHelper.getMathContext(),
loanTransaction.getLoan().getLoanProductRelatedDetail());
rp.setTotalDisbursedAmount(scheduleModel.repaymentPeriods().getFirst().getTotalDisbursedAmount());
existingRepaymentPeriodOpt.ifPresent(existingRepaymentPeriods::remove);
existingRepaymentPeriods.add(rp);
calculateRateFactorForRepaymentPeriod(rp, scheduleModel);
}
final RepaymentPeriod lastReAgedInstallment = newPeriods.getLast();
final List<RepaymentPeriod> reAgedRepaymentPeriods = existingRepaymentPeriods.stream()
.filter(repaymentPeriod -> (!repaymentPeriod.getFromDate().isBefore(reAgingStartDate)
|| repaymentPeriod.getDueDate().isEqual(reAgingStartDate))
&& !repaymentPeriod.getDueDate().isAfter(lastReAgedInstallment.getDueDate()))
.toList();
calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel, loanTransaction.getTransactionDate());
checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(scheduleModel, reAgedRepaymentPeriods);
}
/**
* * Generates temporary interestScheduleModel with re-aged repayment periods
*/
@NotNull
private ProgressiveLoanInterestScheduleModel generateTemporaryReAgedScheduleModel(final LoanApplicationTerms loanApplicationTerms,
final MathContext mc, final LocalDate periodStartDate, final LocalDate transactionDate) {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = scheduledDateGenerator.generateRepaymentPeriods(mc,
periodStartDate, loanApplicationTerms, null);
final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanApplicationTerms.toLoanProductRelatedDetailMinimumData(), null,
loanApplicationTerms.getInstallmentAmountInMultiplesOf(), mc);
addDisbursement(temporaryReAgedScheduleModel, EmiChangeOperation.disburse(transactionDate, loanApplicationTerms.getPrincipal()));
return temporaryReAgedScheduleModel;
}
/**
* * Based on the re-aging start date and frequency data calculates start date for the first re-aged period, which
* is used to generate re-aged repayment periods
*/
@NotNull
private static LocalDate calculateFirstReAgedPeriodStartDate(final LoanReAgeParameter loanReAgeParameter) {
final LocalDate reAgingStartDate = loanReAgeParameter.getStartDate();
return switch (loanReAgeParameter.getFrequencyType()) {
case DAYS -> reAgingStartDate.minusDays(loanReAgeParameter.getFrequencyNumber());
case WEEKS -> reAgingStartDate.minusWeeks(loanReAgeParameter.getFrequencyNumber());
case MONTHS -> reAgingStartDate.minusMonths(loanReAgeParameter.getFrequencyNumber());
case YEARS -> reAgingStartDate.minusYears(loanReAgeParameter.getFrequencyNumber());
case WHOLE_TERM -> throw new IllegalStateException("Unexpected RecalculationFrequencyType: WHOLE_TERM");
case INVALID -> throw new IllegalStateException("Unexpected RecalculationFrequencyType: INVALID");
};
}
/**
* * Zeroing out the EMI of the repayment periods, that are before re-aging and not been fully paid. And decreases
* the balance correction amount (added during interest recalculation for the business date) by the amount of the
* principal that was moved.
*/
private static void moveOutstandingAmountsFromPeriodsBeforeReAging(final List<RepaymentPeriod> existingRepaymentPeriods,
final LocalDate reAgingStartDate) {
final List<RepaymentPeriod> periodsBeforeReAging = existingRepaymentPeriods.stream()
.filter(rp -> rp.getFromDate().isBefore(reAgingStartDate) && !rp.isFullyPaid()).toList();
periodsBeforeReAging.forEach(rp -> {
final InterestPeriod lastInterestPeriod = rp.getInterestPeriods().getLast();
lastInterestPeriod.addBalanceCorrectionAmount(rp.getOutstandingPrincipal().negated());
rp.setEmi(rp.getTotalPaidAmount());
});
}
private void calculateLastUnpaidRepaymentPeriodEMI(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate tillDate) {
Optional<RepaymentPeriod> findLastUnpaidRepaymentPeriod = scheduleModel.repaymentPeriods().stream().filter(rp -> !rp.isFullyPaid())
.reduce((first, second) -> second);
@@ -44,7 +44,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoanScheduleGeneratorTest {
private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator();
private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator(mock(ScheduledDateGenerator.class));
private static final ApplicationCurrency APPLICATION_CURRENCY = new ApplicationCurrency("USD", "USD", 2, 1, "USD", "$");
private static final CurrencyData CURRENCY = APPLICATION_CURRENCY.toData();
private static final BigDecimal DISBURSEMENT_AMOUNT = BigDecimal.valueOf(192.22);
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.loanproduct.calc;
import static org.mockito.Mockito.mock;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
@@ -39,6 +41,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariationType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.ScheduledDateGenerator;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanInterestScheduleModelParserServiceGsonImpl;
import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod;
import org.apache.fineract.portfolio.loanproduct.calc.data.PeriodDueDetails;
@@ -64,7 +67,7 @@ import org.springframework.lang.NonNull;
@ExtendWith(MockitoExtension.class)
class ProgressiveEMICalculatorTest {
private static ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator();
private static ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator(mock(ScheduledDateGenerator.class));
private static MockedStatic<ThreadLocalContextUtil> threadLocalContextUtil = Mockito.mockStatic(ThreadLocalContextUtil.class);
private static MockedStatic<MoneyHelper> moneyHelper = Mockito.mockStatic(MoneyHelper.class);