FINERACT-1981: Pay-off schedule handling

This commit is contained in:
Janos Meszaros
2024-09-13 13:26:32 +02:00
committed by Adam Saghy
parent e94ce7f1df
commit 49e9005109
10 changed files with 352 additions and 304 deletions
@@ -55,6 +55,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -76,6 +77,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.data.ProgressiveLoanInterestRepaymentModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
@@ -140,12 +142,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
throw new NotImplementedException();
}
@Override
public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List<LoanTransaction> loanTransactions,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
// only for progressive loans
public Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> reprocessProgressiveLoanTransactions(
LocalDate disbursementDate, List<LoanTransaction> loanTransactions, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
final ChangedTransactionDetail changedTransactionDetail = new ChangedTransactionDetail();
if (loanTransactions.isEmpty()) {
return changedTransactionDetail;
return Pair.of(changedTransactionDetail, null);
}
if (charges != null) {
for (final LoanCharge loanCharge : charges) {
@@ -180,10 +183,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
chargeOrTransaction.getLoanCharge()
.ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate));
}
List<LoanTransaction> txs = chargeOrTransactions.stream().map(ChargeOrTransaction::getLoanTransaction).filter(Optional::isPresent)
List<LoanTransaction> txs = chargeOrTransactions.stream() //
.map(ChargeOrTransaction::getLoanTransaction) //
.filter(Optional::isPresent) //
.map(Optional::get).toList();
reprocessInstallments(disbursementDate, txs, installments, currency);
return changedTransactionDetail;
return Pair.of(changedTransactionDetail, scheduleModel);
}
@Override
public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List<LoanTransaction> loanTransactions,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
return reprocessProgressiveLoanTransactions(disbursementDate, loanTransactions, currency, installments, charges).getLeft();
}
@Override
@@ -276,7 +287,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
return originalTransaction.get();
} else { // when there is no id, then it might be that the original transaction is changed, so we need to look
// it up from the Ctx.
// it up from the Ctx.
Long originalChargebackTransactionId = ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction);
Collection<LoanTransaction> updatedTransactions = ctx.getChangedTransactionDetail().getNewTransactionMappings().values();
Optional<LoanTransaction> updatedTransaction = updatedTransactions.stream().filter(tr -> tr.getLoanTransactionRelations()
@@ -733,19 +744,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow();
Money transactionAmountUnprocessed = null;
Money transactionAmountUnprocessed = transactionCtx.getOverpaymentHolder().getMoneyObject();
Money zero = Money.zero(transactionCtx.getCurrency());
Balances balances = new Balances(zero, zero, zero, zero);
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(),
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
defaultPaymentAllocationRule, transactionMappings, Set.of(), balances);
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(),
defaultPaymentAllocationRule, transactionMappings, Set.of(), balances);
transactionCtx.getInstallments(), transactionAmountUnprocessed, defaultPaymentAllocationRule, transactionMappings,
Set.of(), balances);
}
transactionCtx.getOverpaymentHolder().setMoneyObject(transactionAmountUnprocessed);
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings);
@@ -1104,9 +1114,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings,
transactionCtx.getCharges(), balances);
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationRule, transactionMappings, transactionCtx.getCharges(), balances);
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(),
@@ -1120,26 +1129,31 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx.getOverpaymentHolder());
}
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Money transactionAmountUnprocessed,
LoanPaymentAllocationRule paymentAllocationRule, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges, Balances balances) {
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
LinkedHashMap<DueType, List<PaymentAllocationType>> paymentAllocationsMap = paymentAllocationRule.getAllocationTypes().stream()
.collect(Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new,
mapping(Function.identity(), toList())));
for (Map.Entry<DueType, List<PaymentAllocationType>> paymentAllocationsEntry : paymentAllocationsMap.entrySet()) {
transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, currency, installments,
transactionAmountUnprocessed, paymentAllocationsEntry.getValue(),
paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings, charges, balances);
transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationsEntry.getValue(), paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings,
charges, balances);
}
return transactionAmountUnprocessed;
}
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Money transactionAmountUnprocessed,
List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, List<PaymentAllocationType> paymentAllocationTypes,
FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
if (transactionAmountUnprocessed.isZero()) {
return transactionAmountUnprocessed;
}
MonetaryCurrency currency = transactionCtx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = transactionCtx.getInstallments();
Money paidPortion;
boolean exit = false;
do {
@@ -1209,15 +1223,55 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) {
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment,
firstNormalInstallmentNumber);
// Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, inAdvanceInstallment, currency);
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.PAY);
Loan loan = loanTransaction.getLoan();
if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing()
&& loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) {
ProgressiveLoanInterestScheduleModel model = ctx.getModel();
LocalDate transactionDate = loanTransaction.getTransactionDate();
LocalDate payDate = inAdvanceInstallment.getFromDate().isAfter(transactionDate)
? inAdvanceInstallment.getFromDate()
: transactionDate;
ProgressiveLoanInterestRepaymentModel payableDetails = emiCalculator
.getPayableDetails(model, inAdvanceInstallment.getDueDate(), payDate).orElseThrow();
switch (paymentAllocationType) {
case IN_ADVANCE_INTEREST ->
inAdvanceInstallment.updateInterestCharged(payableDetails.getInterestDue().getAmount());
case IN_ADVANCE_PRINCIPAL ->
inAdvanceInstallment.updatePrincipal(payableDetails.getPrincipalDue().getAmount());
default -> {
}
}
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
inAdvanceInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
switch (paymentAllocationType) {
case IN_ADVANCE_PRINCIPAL -> {
emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getOutstandingBalance().multipliedBy(-1));
emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getPrincipalDue().minus(paidPortion));
}
case IN_ADVANCE_INTEREST -> emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getInterestDue().minus(paidPortion));
default -> {
}
}
} else {
// Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.PAY);
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
}
} else {
@@ -18,11 +18,10 @@
*/
package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import static java.time.temporal.ChronoUnit.DAYS;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
@@ -30,14 +29,12 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
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.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
@@ -46,6 +43,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
@@ -247,77 +245,38 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO,
LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) {
return switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) {
case TILL_PRE_CLOSURE_DATE -> {
log.debug("calculating prepayment amount till pre closure date (Strategy A)");
OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency);
AtomicBoolean firstAfterPayoff = new AtomicBoolean(true);
loan.getRepaymentScheduleInstallments().forEach(installment -> {
boolean isInstallmentAfterPayoff = installment.getDueDate().isAfter(onDate);
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (isInstallmentAfterPayoff) {
if (firstAfterPayoff.getAndSet(false)) {
outstandingAmounts.plusInterest(calculatePayableInterest(loan, installment, onDate));
} else {
log.debug("Installment {} - {} is after payoff, not counting interest", installment.getFromDate(),
installment.getDueDate());
}
} else {
log.debug("adding interest for {} - {}: {}", installment.getFromDate(), installment.getDueDate(),
installment.getInterestOutstanding(currency));
outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency));
}
outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency));
outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency));
});
yield outstandingAmounts;
}
case TILL_REST_FREQUENCY_DATE -> {
log.debug("calculating prepayment amount till rest frequency date (Strategy B)");
OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency);
loan.getRepaymentScheduleInstallments().forEach(installment -> {
boolean isPayoffBeforeInstallment = installment.getFromDate().isBefore(onDate);
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (isPayoffBeforeInstallment) {
outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency));
} else {
log.debug("Payoff after installment {}, not counting interest", installment.getDueDate());
}
outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency));
outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency));
});
yield outstandingAmounts;
}
case NONE -> throw new UnsupportedOperationException("Pre-closure interest calculation strategy not supported");
LocalDate transactionDate = switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) {
case TILL_PRE_CLOSURE_DATE -> onDate;
case TILL_REST_FREQUENCY_DATE -> // find due date of current installment
installments.stream().filter(it -> it.getFromDate().isBefore(onDate) && it.getDueDate().isAfter(onDate)).findFirst()
.orElseThrow(() -> new IllegalStateException("No installment found for transaction date: " + onDate)).getDueDate();
case NONE -> throw new IllegalStateException("Unexpected PreClosureInterestCalculationStrategy: NONE");
};
}
private Money calculatePayableInterest(Loan loan, LoanRepaymentScheduleInstallment installment, LocalDate onDate) {
RoundingMode roundingMode = MoneyHelper.getRoundingMode();
MonetaryCurrency currency = loan.getCurrency();
Money originalInterest = installment.getInterestCharged(currency);
log.debug("calculating interest for {} from {} to {}", originalInterest, installment.getFromDate(), installment.getDueDate());
LocalDate start = installment.getFromDate();
Money payableInterest = Money.zero(currency);
while (!start.isEqual(onDate)) {
long between = DAYS.between(start, installment.getDueDate());
Money dailyInterest = originalInterest.minus(payableInterest).dividedBy(between, roundingMode);
log.debug("Daily interest is {}: {} / {}, total: {}", dailyInterest, originalInterest.minus(payableInterest), between,
payableInterest.add(dailyInterest));
payableInterest = payableInterest.add(dailyInterest);
start = start.plusDays(1);
if (!(loanRepaymentScheduleTransactionProcessor instanceof AdvancedPaymentScheduleTransactionProcessor processor)) {
throw new IllegalStateException("Expected an AdvancedPaymentScheduleTransactionProcessor");
}
ProgressiveLoanInterestScheduleModel model = processor.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(),
loan.retrieveListOfTransactionsForReprocessing(), currency, installments, loan.getActiveCharges()).getRight();
payableInterest = payableInterest.minus(installment.getInterestPaid(currency).minus(installment.getInterestWaived(currency)));
LoanRepaymentScheduleInstallment actualInstallment = installments.stream()
.filter(it -> transactionDate.isAfter(it.getFromDate()) && !transactionDate.isAfter(it.getDueDate())).findFirst()
.orElse(installments.get(0));
log.debug("Payable interest is {}", payableInterest);
return payableInterest;
ProgressiveLoanInterestRepaymentModel result = emiCalculator
.getPayableDetails(model, actualInstallment.getDueDate(), transactionDate).orElseThrow();
OutstandingAmountsDTO amounts = new OutstandingAmountsDTO(currency) //
.principal(result.getOutstandingBalance()) //
.interest(result.getInterestDue());
installments.forEach(installment -> amounts //
.plusFeeCharges(installment.getFeeChargesOutstanding(currency))
.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency)));
return amounts;
}
// Private, internal methods