Skip to content

Commit

Permalink
FINERACT-1981: pay off schedule handling
Browse files Browse the repository at this point in the history
  • Loading branch information
kjozsa committed Sep 2, 2024
1 parent d5d7f7f commit 88db043
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.loanaccount.domain;

import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -488,6 +490,10 @@ public void resetChargesCharged() {
this.penaltyCharges = null;
}

public boolean isCurrentInstallment(LocalDate transactionDate) {
return getFromDate().isBefore(transactionDate) && !getDueDate().isBefore(transactionDate);
}

public interface PaymentFunction {

Money accept(LocalDate transactionDate, Money transactionAmountRemaining);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.ProgressiveLoanScheduleGenerator;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.apache.fineract.portfolio.loanproduct.domain.AllocationType;
import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType;
Expand Down Expand Up @@ -281,7 +282,7 @@ protected LoanTransaction findOriginalTransaction(LoanTransaction loanTransactio
}
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()
Expand Down Expand Up @@ -982,6 +983,8 @@ private Money refundTransactionHorizontally(LoanTransaction loanTransaction, Mon
break outerLoop;
}
}
default -> {
}
}
}
} while (installments.stream().anyMatch(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
Expand Down Expand Up @@ -1157,22 +1160,22 @@ private Money processAllocationsHorizontally(LoanTransaction loanTransaction, Mo

// For having similar logic we are populating installment list even when the future installment
// allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one element.
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = switch (futureInstallmentAllocationRule) {
case REAMORTIZATION -> installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> loanTransaction.isBefore(e.getDueDate())).toList();
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
}
case NEXT_INSTALLMENT -> // first future unpaid installment
installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
case LAST_INSTALLMENT -> // last future unpaid installment
installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
};

int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments);

Money originalInterestOfCurrentInstallment = null;
for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) {
switch (paymentAllocationType.getDueType()) {
case PAST_DUE -> {
Expand Down Expand Up @@ -1206,6 +1209,51 @@ private Money processAllocationsHorizontally(LoanTransaction loanTransaction, Mo
case IN_ADVANCE -> {
int numberOfInstallments = inAdvanceInstallments.size();
if (numberOfInstallments > 0) {
if (loanTransaction.getLoan().getLoanProduct().isInterestRecalculationEnabled()) {
AtomicReference<Money> sumAdjusted = new AtomicReference<>(Money.zero(currency));

// recalculate interest before processing payment
for (LoanRepaymentScheduleInstallment installment : inAdvanceInstallments) {
LocalDate transactionDate = loanTransaction.getTransactionDate();
if (installment.isCurrentInstallment(transactionDate)) {
switch (paymentAllocationType) {
case IN_ADVANCE_INTEREST -> {
Money payableInterest = ProgressiveLoanScheduleGenerator
.calculatePayableInterest(installment, transactionDate);
originalInterestOfCurrentInstallment = installment.getInterestCharged(currency);
installment.updateInterestCharged(payableInterest.getAmount());
}
case IN_ADVANCE_PRINCIPAL -> {
Money interestDelta = calculateInterestDelta(originalInterestOfCurrentInstallment,
installment, transactionDate, currency);
sumAdjusted.updateAndGet(v -> v.add(interestDelta));
BigDecimal newPrincipal = installment.getPrincipal(currency).plus(interestDelta)
.getAmount();
installment.updatePrincipal(newPrincipal);
}
}

int lastInstallmentNumber = installments.stream() //
.mapToInt(LoanRepaymentScheduleInstallment::getInstallmentNumber) //
.max().orElse(0);

// update later installments with zero interest and increased principal
installments.stream().filter(it -> it.getInstallmentNumber() > installment.getInstallmentNumber())
.forEach(it -> {
Money interestCharged = it.getInterestCharged(currency);
sumAdjusted.updateAndGet(v -> v.add(interestCharged));
it.updateInterestCharged(BigDecimal.ZERO);
BigDecimal newPrincipal = it.getPrincipal(currency).plus(interestCharged).getAmount();
if (it.getInstallmentNumber() == lastInstallmentNumber) {
// adjust last installment to match the outstanding balance
newPrincipal = newPrincipal.subtract(sumAdjusted.get().getAmount());
}
it.updatePrincipal(newPrincipal);
});
}
}
}

// This will be the same amount as transactionAmountUnprocessed in case of the future
// installment allocation is NEXT_INSTALLMENT or LAST_INSTALLMENT
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getRoundingMode());
Expand Down Expand Up @@ -1239,6 +1287,19 @@ private Money processAllocationsHorizontally(LoanTransaction loanTransaction, Mo
return transactionAmountUnprocessed;
}

private Money calculateInterestDelta(Money originalInterestOfCurrentInstallment, LoanRepaymentScheduleInstallment installment,
LocalDate transactionDate, MonetaryCurrency currency) {
if (originalInterestOfCurrentInstallment == null) {
// interest was not recalculated yet
Money payableInterest = ProgressiveLoanScheduleGenerator.calculatePayableInterest(installment, transactionDate);
originalInterestOfCurrentInstallment = installment.getInterestCharged(currency);
return originalInterestOfCurrentInstallment.minus(payableInterest);
} else {
// interest was already recalculated
return originalInterestOfCurrentInstallment.minus(installment.getInterestCharged(currency));
}
}

@NotNull
private static Set<LoanCharge> getLoanChargesOfInstallment(Set<LoanCharge> charges, LoanRepaymentScheduleInstallment currentInstallment,
int firstNormalInstallmentNumber) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (isInstallmentAfterPayoff) {
if (firstAfterPayoff.getAndSet(false)) {
outstandingAmounts.plusInterest(calculatePayableInterest(loan, installment, onDate));
outstandingAmounts.plusInterest(calculatePayableInterest(installment, onDate));
} else {
log.debug("Installment {} - {} is after payoff, not counting interest", installment.getFromDate(),
installment.getDueDate());
Expand Down Expand Up @@ -290,9 +290,9 @@ public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency
};
}

private Money calculatePayableInterest(Loan loan, LoanRepaymentScheduleInstallment installment, LocalDate onDate) {
public static Money calculatePayableInterest(LoanRepaymentScheduleInstallment installment, LocalDate onDate) {
RoundingMode roundingMode = MoneyHelper.getRoundingMode();
MonetaryCurrency currency = loan.getCurrency();
MonetaryCurrency currency = installment.getLoan().getCurrency();
Money originalInterest = installment.getInterestCharged(currency);
log.debug("calculating interest for {} from {} to {}", originalInterest, installment.getFromDate(), installment.getDueDate());

Expand Down

0 comments on commit 88db043

Please sign in to comment.