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 4, 2024
1 parent 54d9e6b commit 9d63c17
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,24 @@ public Money getTotalOutstanding() {
.plus(penaltyCharges());
}

public void plusPrincipal(Money principal) {
public OutstandingAmountsDTO plusPrincipal(Money principal) {
this.principal = this.principal.plus(principal);
return this;
}

public void plusInterest(Money interest) {
public OutstandingAmountsDTO plusInterest(Money interest) {
this.interest = this.interest.plus(interest);
return this;
}

public void plusFeeCharges(Money feeCharges) {
public OutstandingAmountsDTO plusFeeCharges(Money feeCharges) {
this.feeCharges = this.feeCharges.plus(feeCharges);
return this;
}

public void plusPenaltyCharges(Money penaltyCharges) {
public OutstandingAmountsDTO plusPenaltyCharges(Money penaltyCharges) {
this.penaltyCharges = this.penaltyCharges.plus(penaltyCharges);
return this;
}

}
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 All @@ -88,6 +89,7 @@
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.apache.commons.lang3.tuple.Pair;

@Slf4j
@RequiredArgsConstructor
Expand Down Expand Up @@ -143,12 +145,13 @@ public Money handleRepaymentSchedule(List<LoanTransaction> transactionsPostDisbu
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) {
Expand Down Expand Up @@ -185,10 +188,18 @@ public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursement
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
Expand Down Expand Up @@ -281,7 +292,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 +993,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 +1170,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 +1219,53 @@ 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);
}
default -> {
}
}

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 +1299,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
Loading

0 comments on commit 9d63c17

Please sign in to comment.