FINERACT-2081: Payments after loan charge - COB accrual

This commit is contained in:
Marta Jankovics
2024-09-13 12:12:06 +02:00
committed by Adam Saghy
parent 889fc8d2cb
commit e94ce7f1df
9 changed files with 199 additions and 102 deletions
@@ -60,18 +60,10 @@ public class LoanChargePaidBy extends AbstractPersistableCustom<Long> {
return this.loanTransaction;
}
public void setLoanTransaction(final LoanTransaction loanTransaction) {
this.loanTransaction = loanTransaction;
}
public LoanCharge getLoanCharge() {
return this.loanCharge;
}
public void setLoanCharge(final LoanCharge loanCharge) {
this.loanCharge = loanCharge;
}
public BigDecimal getAmount() {
return this.amount;
}
@@ -83,4 +75,8 @@ public class LoanChargePaidBy extends AbstractPersistableCustom<Long> {
public Integer getInstallmentNumber() {
return this.installmentNumber;
}
public void setInstallmentNumber(Integer installmentNumber) {
this.installmentNumber = installmentNumber;
}
}
@@ -245,10 +245,14 @@ public class LoanRepaymentScheduleProcessingWrapper {
public static boolean isInPeriod(LocalDate transactionDate, LoanRepaymentScheduleInstallment targetInstallment,
List<LoanRepaymentScheduleInstallment> installments) {
int firstPeriod = fetchFirstNormalInstallmentNumber(installments);
return (targetInstallment.getInstallmentNumber().equals(firstPeriod)
? !DateUtils.isBefore(transactionDate, targetInstallment.getFromDate())
: DateUtils.isAfter(transactionDate, targetInstallment.getFromDate()))
&& !DateUtils.isAfter(transactionDate, targetInstallment.getDueDate());
return isInPeriod(transactionDate, targetInstallment, targetInstallment.getInstallmentNumber().equals(firstPeriod));
}
private static boolean isInPeriod(LocalDate transactionDate, LoanRepaymentScheduleInstallment targetInstallment,
boolean isFirstPeriod) {
LocalDate fromDate = targetInstallment.getFromDate();
LocalDate dueDate = targetInstallment.getDueDate();
return isFirstPeriod ? DateUtils.occursOnDayFromAndUpToAndIncluding(fromDate, dueDate, transactionDate)
: DateUtils.occursOnDayFromExclusiveAndUpToAndIncluding(fromDate, dueDate, transactionDate);
}
}
@@ -18,14 +18,16 @@
*/
package org.apache.fineract.portfolio.loanaccount.domain;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.function.Predicate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.jetbrains.annotations.NotNull;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
/**
* A wrapper around loan schedule related data exposing needed behaviour by loan.
@@ -34,42 +36,61 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapper {
public void reprocess(final MonetaryCurrency currency, final LocalDate disbursementDate,
final List<LoanRepaymentScheduleInstallment> repaymentPeriods, LoanCharge loanCharge) {
Money totalInterest = Money.zero(currency);
Money totalPrincipal = Money.zero(currency);
Loan loan = loanCharge.getLoan();
Money zero = Money.zero(currency);
Money totalInterest = zero;
Money totalPrincipal = zero;
for (final LoanRepaymentScheduleInstallment installment : repaymentPeriods) {
totalInterest = totalInterest.plus(installment.getInterestCharged(currency));
totalPrincipal = totalPrincipal.plus(installment.getPrincipal(currency));
}
LoanChargePaidBy accrualBy = null;
if (!loan.isInterestBearing() && loanCharge.isSpecifiedDueDate()) { // TODO: why only if not interest bearing
LoanRepaymentScheduleInstallment addedPeriod = addChargeOnlyRepaymentInstallmentIfRequired(loanCharge, repaymentPeriods);
if (addedPeriod != null) {
addedPeriod.updateObligationsMet(currency, disbursementDate);
}
accrualBy = loanCharge.getLoanChargePaidBySet().stream().filter(e -> e.getLoanTransaction().isAccrual()).findFirst()
.orElse(null);
}
LocalDate startDate = disbursementDate;
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(repaymentPeriods);
for (final LoanRepaymentScheduleInstallment period : repaymentPeriods) {
if (!period.isDownPayment()) {
boolean isFirstNonDownPaymentPeriod = period.getInstallmentNumber().equals(firstNormalInstallmentNumber);
final Money feeChargesDueForRepaymentPeriod = feeChargesDueWithin(startDate, period.getDueDate(), loanCharge, currency,
period, totalPrincipal, totalInterest, !period.isRecalculatedInterestComponent(), isFirstNonDownPaymentPeriod);
final Money feeChargesWaivedForRepaymentPeriod = chargesWaivedWithin(startDate, period.getDueDate(), loanCharge, currency,
!period.isRecalculatedInterestComponent(), isFirstNonDownPaymentPeriod, feeCharge());
final Money feeChargesWrittenOffForRepaymentPeriod = loanChargesWrittenOffWithin(startDate, period.getDueDate(), loanCharge,
currency, !period.isRecalculatedInterestComponent(), isFirstNonDownPaymentPeriod, feeCharge());
final Money penaltyChargesDueForRepaymentPeriod = penaltyChargesDueWithin(startDate, period.getDueDate(), loanCharge,
currency, period, totalPrincipal, totalInterest, !period.isRecalculatedInterestComponent(),
isFirstNonDownPaymentPeriod);
final Money penaltyChargesWaivedForRepaymentPeriod = chargesWaivedWithin(startDate, period.getDueDate(), loanCharge,
currency, !period.isRecalculatedInterestComponent(), isFirstNonDownPaymentPeriod, LoanCharge::isPenaltyCharge);
final Money penaltyChargesWrittenOffForRepaymentPeriod = loanChargesWrittenOffWithin(startDate, period.getDueDate(),
loanCharge, currency, !period.isRecalculatedInterestComponent(), isFirstNonDownPaymentPeriod,
LoanCharge::isPenaltyCharge);
period.addToChargePortion(feeChargesDueForRepaymentPeriod, feeChargesWaivedForRepaymentPeriod,
feeChargesWrittenOffForRepaymentPeriod, penaltyChargesDueForRepaymentPeriod, penaltyChargesWaivedForRepaymentPeriod,
penaltyChargesWrittenOffForRepaymentPeriod);
startDate = period.getDueDate();
if (period.isDownPayment()) {
continue;
}
boolean installmentChargeApplicable = !period.isRecalculatedInterestComponent();
boolean isFirstNonDownPaymentPeriod = period.getInstallmentNumber().equals(firstNormalInstallmentNumber);
LocalDate dueDate = period.getDueDate();
final Money feeChargesDueForRepaymentPeriod = feeChargesDueWithin(startDate, dueDate, loanCharge, currency, period,
totalPrincipal, totalInterest, installmentChargeApplicable, isFirstNonDownPaymentPeriod);
final Money feeChargesWaivedForRepaymentPeriod = chargesWaivedWithin(startDate, dueDate, loanCharge, currency,
installmentChargeApplicable, isFirstNonDownPaymentPeriod, feeCharge());
final Money feeChargesWrittenOffForRepaymentPeriod = loanChargesWrittenOffWithin(startDate, dueDate, loanCharge, currency,
installmentChargeApplicable, isFirstNonDownPaymentPeriod, feeCharge());
Predicate<LoanCharge> penaltyPredicate = LoanCharge::isPenaltyCharge;
final Money penaltyChargesDueForRepaymentPeriod = penaltyChargesDueWithin(startDate, dueDate, loanCharge, currency, period,
totalPrincipal, totalInterest, installmentChargeApplicable, isFirstNonDownPaymentPeriod);
final Money penaltyChargesWaivedForRepaymentPeriod = chargesWaivedWithin(startDate, dueDate, loanCharge, currency,
installmentChargeApplicable, isFirstNonDownPaymentPeriod, penaltyPredicate);
final Money penaltyChargesWrittenOffForRepaymentPeriod = loanChargesWrittenOffWithin(startDate, dueDate, loanCharge, currency,
installmentChargeApplicable, isFirstNonDownPaymentPeriod, penaltyPredicate);
period.addToChargePortion(feeChargesDueForRepaymentPeriod, feeChargesWaivedForRepaymentPeriod,
feeChargesWrittenOffForRepaymentPeriod, penaltyChargesDueForRepaymentPeriod, penaltyChargesWaivedForRepaymentPeriod,
penaltyChargesWrittenOffForRepaymentPeriod);
if (accrualBy != null && period.isAdditional()
&& loanChargeIsDue(startDate, dueDate, isFirstNonDownPaymentPeriod, loanCharge)) {
Money amount = Money.of(currency, accrualBy.getAmount());
boolean isFee = loanCharge.isFeeCharge();
period.updateAccrualPortion(period.getInterestAccrued(currency),
MathUtil.plus(period.getFeeAccrued(currency), (isFee ? amount : null)),
MathUtil.plus(period.getPenaltyAccrued(currency), (isFee ? null : amount)));
accrualBy.setInstallmentNumber(period.getInstallmentNumber());
}
startDate = dueDate;
}
}
@@ -198,17 +219,53 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapper {
}
@NotNull
private BigDecimal getBaseAmount(MonetaryCurrency monetaryCurrency, LoanRepaymentScheduleInstallment period, LoanCharge loanCharge,
private BigDecimal getBaseAmount(MonetaryCurrency currency, LoanRepaymentScheduleInstallment period, LoanCharge loanCharge,
BigDecimal amount) {
if (loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest()) {
amount = amount.add(period.getPrincipal(monetaryCurrency).getAmount())
.add(period.getInterestCharged(monetaryCurrency).getAmount());
} else if (loanCharge.getChargeCalculation().isPercentageOfInterest()) {
amount = amount.add(period.getInterestCharged(monetaryCurrency).getAmount());
} else {
amount = amount.add(period.getPrincipal(monetaryCurrency).getAmount());
}
return amount;
BigDecimal baseAmount = getBaseAmount(loanCharge, period.getPrincipal(currency).getAmount(),
period.getInterestCharged(currency).getAmount());
return MathUtil.add(amount, baseAmount);
}
@NotNull
private BigDecimal getBaseAmount(LoanCharge loanCharge, BigDecimal principal, BigDecimal interest) {
ChargeCalculationType calcType = loanCharge.getChargeCalculation();
if (calcType.isPercentageOfAmountAndInterest()) {
return MathUtil.add(principal, interest);
}
if (calcType.isPercentageOfInterest()) {
return interest;
}
return principal;
}
/**
* @return newly added period if there is any
*/
public LoanRepaymentScheduleInstallment addChargeOnlyRepaymentInstallmentIfRequired(@NotNull LoanCharge loanCharge,
List<LoanRepaymentScheduleInstallment> installments) {
if (installments == null) {
return null;
}
if (!loanCharge.isSpecifiedDueDate()) {
return null;
}
LocalDate chargeDueDate = loanCharge.getEffectiveDueDate();
LoanRepaymentScheduleInstallment latestInstallment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
if (!DateUtils.isAfter(chargeDueDate, latestInstallment.getDueDate())) {
return null;
}
if (latestInstallment.isAdditional()) {
latestInstallment.updateDueDate(chargeDueDate);
return null;
} else {
Loan loan = loanCharge.getLoan();
final LoanRepaymentScheduleInstallment additionalInstallment = new LoanRepaymentScheduleInstallment(loan,
(loan.getLoanRepaymentScheduleInstallmentsSize() + 1), latestInstallment.getDueDate(), chargeDueDate, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, false, null);
additionalInstallment.markAsAdditional();
loan.addLoanRepaymentScheduleInstallment(additionalInstallment);
return additionalInstallment;
}
}
}
@@ -46,6 +46,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymentScheduleProcessingWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
@@ -62,6 +63,8 @@ import org.springframework.util.CollectionUtils;
*/
public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implements LoanRepaymentScheduleTransactionProcessor {
public final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
@Override
public boolean accept(String s) {
return getCode().equalsIgnoreCase(s) || getName().equalsIgnoreCase(s);
@@ -510,7 +513,6 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
newLoanTransaction.getLoanTransactionRelations().add(
LoanTransactionRelation.linkToTransaction(newLoanTransaction, loanTransaction, LoanTransactionRelationTypeEnum.REPLAYED));
changedTransactionDetail.getNewTransactionMappings().put(loanTransaction.getId(), newLoanTransaction);
}
protected void processCreditTransaction(LoanTransaction loanTransaction, MoneyHolder overpaymentHolder, MonetaryCurrency currency,
@@ -887,33 +889,17 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
protected void addChargeOnlyRepaymentInstallmentIfRequired(Set<LoanCharge> charges,
List<LoanRepaymentScheduleInstallment> installments) {
if (!CollectionUtils.isEmpty(charges) && !CollectionUtils.isEmpty(installments)) {
LoanRepaymentScheduleInstallment latestRepaymentScheduleInstalment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
LocalDate installmentDueDate = null;
LoanCharge latestCharge = getLatestLoanChargeWithSpecificDueDate(charges);
if (latestCharge != null
&& DateUtils.isAfter(latestCharge.getEffectiveDueDate(), latestRepaymentScheduleInstalment.getDueDate())) {
installmentDueDate = latestCharge.getEffectiveDueDate();
}
if (installmentDueDate != null) {
if (latestRepaymentScheduleInstalment.isAdditional()) {
latestRepaymentScheduleInstalment.updateDueDate(installmentDueDate);
} else {
Loan loan = latestCharge.getLoan();
final LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan,
(installments.size() + 1), latestRepaymentScheduleInstalment.getDueDate(), installmentDueDate, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, false, null);
installment.markAsAdditional();
loan.addLoanRepaymentScheduleInstallment(installment);
}
}
LoanCharge latestCharge = getLatestLoanChargeWithSpecificDueDate(charges);
if (latestCharge == null) {
return;
}
loanChargeProcessor.addChargeOnlyRepaymentInstallmentIfRequired(latestCharge, installments);
}
private LoanCharge getLatestLoanChargeWithSpecificDueDate(Set<LoanCharge> charges) {
protected LoanCharge getLatestLoanChargeWithSpecificDueDate(Set<LoanCharge> charges) {
if (charges == null) {
return null;
}
LoanCharge latestCharge = null;
List<LoanCharge> chargesWithSpecificDueDate = new ArrayList<>();
chargesWithSpecificDueDate.addAll(charges.stream().filter(charge -> charge.isSpecifiedDueDate()).toList());
@@ -132,7 +132,9 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapperTest {
when(charge.getName()).thenReturn("charge a");
when(charge.getCurrencyCode()).thenReturn("UDS");
when(charge.isPenalty()).thenReturn(penalty);
LoanCharge loanCharge = new LoanCharge(null, charge, new BigDecimal(1000), new BigDecimal(10), ChargeTimeType.SPECIFIED_DUE_DATE,
Loan loan = mock(Loan.class);
when(loan.isInterestBearing()).thenReturn(false);
LoanCharge loanCharge = new LoanCharge(loan, charge, new BigDecimal(1000), new BigDecimal(10), ChargeTimeType.SPECIFIED_DUE_DATE,
ChargeCalculationType.FLAT, LocalDate.of(2023, 01, 15), ChargePaymentMode.REGULAR, 1, null, null);
return loanCharge;
}
@@ -150,5 +152,4 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapperTest {
Mockito.when(period.getInterestCharged(eq(currency))).thenReturn(interest);
return period;
}
}