FINERACT-2059: Re-aging repayment schedule handling

This commit is contained in:
Adam Saghy
2024-03-19 10:30:18 +01:00
parent 314758abb5
commit d955bc26cb
21 changed files with 413 additions and 114 deletions
@@ -24,7 +24,8 @@ public interface LoanReAgingApiConstants {
String dateFormatParameterName = "dateFormat"; String dateFormatParameterName = "dateFormat";
String externalIdParameterName = "externalId"; String externalIdParameterName = "externalId";
String frequency = "frequency"; String frequencyType = "frequencyType";
String frequencyNumber = "frequencyNumber";
String startDate = "startDate"; String startDate = "startDate";
String numberOfInstallments = "numberOfInstallments"; String numberOfInstallments = "numberOfInstallments";
} }
@@ -2166,8 +2166,9 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
} }
private LocalDate determineExpectedMaturityDate() { private LocalDate determineExpectedMaturityDate() {
final int numberOfInstallments = this.repaymentScheduleInstallments.size(); List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments().stream()
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments(); .filter(i -> !i.isDownPayment() && !i.isAdditional()).toList();
final int numberOfInstallments = installments.size();
LocalDate maturityDate = installments.get(numberOfInstallments - 1).getDueDate(); LocalDate maturityDate = installments.get(numberOfInstallments - 1).getDueDate();
ListIterator<LoanRepaymentScheduleInstallment> iterator = installments.listIterator(numberOfInstallments); ListIterator<LoanRepaymentScheduleInstallment> iterator = installments.listIterator(numberOfInstallments);
while (iterator.hasPrevious()) { while (iterator.hasPrevious()) {
@@ -3432,7 +3433,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
final List<LoanTransaction> repaymentsOrWaivers = new ArrayList<>(); final List<LoanTransaction> repaymentsOrWaivers = new ArrayList<>();
List<LoanTransaction> trans = getLoanTransactions(); List<LoanTransaction> trans = getLoanTransactions();
for (final LoanTransaction transaction : trans) { for (final LoanTransaction transaction : trans) {
if (transaction.isNotReversed() && (transaction.isChargeOff() || !transaction.isNonMonetaryTransaction())) { if (transaction.isNotReversed() && (transaction.isChargeOff() || transaction.isReAge() || transaction.isReAmortize()
|| !transaction.isNonMonetaryTransaction())) {
repaymentsOrWaivers.add(transaction); repaymentsOrWaivers.add(transaction);
} }
} }
@@ -3670,10 +3672,10 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
} }
private LocalDate getNextUnpaidInstallmentDueDate() { private LocalDate getNextUnpaidInstallmentDueDate() {
LocalDate nextUnpaidInstallmentDate = null;
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments(); List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
LocalDate currentBusinessDate = DateUtils.getBusinessLocalDate(); LocalDate currentBusinessDate = DateUtils.getBusinessLocalDate();
LocalDate expectedMaturityDate = determineExpectedMaturityDate(); LocalDate expectedMaturityDate = determineExpectedMaturityDate();
LocalDate nextUnpaidInstallmentDate = expectedMaturityDate;
for (final LoanRepaymentScheduleInstallment installment : installments) { for (final LoanRepaymentScheduleInstallment installment : installments) {
boolean isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isBefore(currentBusinessDate, installment.getDueDate()) boolean isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isBefore(currentBusinessDate, installment.getDueDate())
@@ -5664,7 +5666,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
lastCompoundingDate = compoundingDetail.getEffectiveDate(); lastCompoundingDate = compoundingDetail.getEffectiveDate();
} }
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments(); List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
LoanRepaymentScheduleInstallment lastInstallment = installments.get(installments.size() - 1); LoanRepaymentScheduleInstallment lastInstallment = LoanRepaymentScheduleInstallment
.getLastNonDownPaymentInstallment(installments);
reverseTransactionsPostEffectiveDate(incomeTransactions, lastInstallment.getDueDate()); reverseTransactionsPostEffectiveDate(incomeTransactions, lastInstallment.getDueDate());
reverseTransactionsPostEffectiveDate(accrualTransactions, lastInstallment.getDueDate()); reverseTransactionsPostEffectiveDate(accrualTransactions, lastInstallment.getDueDate());
} }
@@ -29,6 +29,7 @@ import jakarta.persistence.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -145,6 +146,9 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
@Column(name = "is_down_payment", nullable = false) @Column(name = "is_down_payment", nullable = false)
private boolean isDownPayment; private boolean isDownPayment;
@Column(name = "is_re_aged", nullable = false)
private boolean isReAged;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, mappedBy = "loanRepaymentScheduleInstallment") @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, mappedBy = "loanRepaymentScheduleInstallment")
private Set<LoanInterestRecalcualtionAdditionalDetails> loanCompoundingDetails = new HashSet<>(); private Set<LoanInterestRecalcualtionAdditionalDetails> loanCompoundingDetails = new HashSet<>();
@@ -223,6 +227,36 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
this.obligationsMet = false; this.obligationsMet = false;
} }
public LoanRepaymentScheduleInstallment(Loan loan, Integer installmentNumber, LocalDate fromDate, LocalDate dueDate,
BigDecimal principal, BigDecimal interestCharged, BigDecimal feeChargesCharged, BigDecimal penaltyCharges,
BigDecimal creditedPrincipal, BigDecimal creditedFee, BigDecimal creditedPenalty, boolean additional, boolean isDownPayment,
boolean isReAged) {
this.loan = loan;
this.installmentNumber = installmentNumber;
this.fromDate = fromDate;
this.dueDate = dueDate;
this.principal = principal;
this.interestCharged = interestCharged;
this.feeChargesCharged = feeChargesCharged;
this.penaltyCharges = penaltyCharges;
this.creditedPrincipal = creditedPrincipal;
this.creditedFee = creditedFee;
this.creditedPenalty = creditedPenalty;
this.additional = additional;
this.isDownPayment = isDownPayment;
this.isReAged = isReAged;
}
public static LoanRepaymentScheduleInstallment newReAgedInstallment(final Loan loan, final Integer installmentNumber,
final LocalDate fromDate, final LocalDate dueDate, final BigDecimal principal) {
return new LoanRepaymentScheduleInstallment(loan, installmentNumber, fromDate, dueDate, principal, null, null, null, null, null,
null, false, false, true);
}
public static LoanRepaymentScheduleInstallment getLastNonDownPaymentInstallment(List<LoanRepaymentScheduleInstallment> installments) {
return installments.stream().filter(i -> !i.isDownPayment()).reduce((first, second) -> second).orElseThrow();
}
private BigDecimal defaultToNullIfZero(final BigDecimal value) { private BigDecimal defaultToNullIfZero(final BigDecimal value) {
BigDecimal result = value; BigDecimal result = value;
if (BigDecimal.ZERO.compareTo(value) == 0) { if (BigDecimal.ZERO.compareTo(value) == 0) {
@@ -400,6 +434,10 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
return this.installmentNumber.compareTo(o.installmentNumber); return this.installmentNumber.compareTo(o.installmentNumber);
} }
public int compareToByDueDate(LoanRepaymentScheduleInstallment o) {
return this.dueDate.compareTo(o.dueDate);
}
public boolean isPrincipalNotCompleted(final MonetaryCurrency currency) { public boolean isPrincipalNotCompleted(final MonetaryCurrency currency) {
return !isPrincipalCompleted(currency); return !isPrincipalCompleted(currency);
} }
@@ -1022,4 +1060,8 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
public enum PaymentAction { public enum PaymentAction {
PAY, UNPAY PAY, UNPAY
} }
public boolean isReAged() {
return isReAged;
}
} }
@@ -865,7 +865,8 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
|| LoanTransactionType.MARKED_FOR_RESCHEDULING.equals(getTypeOf()) || LoanTransactionType.MARKED_FOR_RESCHEDULING.equals(getTypeOf())
|| LoanTransactionType.APPROVE_TRANSFER.equals(getTypeOf()) || LoanTransactionType.INITIATE_TRANSFER.equals(getTypeOf()) || LoanTransactionType.APPROVE_TRANSFER.equals(getTypeOf()) || LoanTransactionType.INITIATE_TRANSFER.equals(getTypeOf())
|| LoanTransactionType.REJECT_TRANSFER.equals(getTypeOf()) || LoanTransactionType.WITHDRAW_TRANSFER.equals(getTypeOf()) || LoanTransactionType.REJECT_TRANSFER.equals(getTypeOf()) || LoanTransactionType.WITHDRAW_TRANSFER.equals(getTypeOf())
|| LoanTransactionType.CHARGE_OFF.equals(getTypeOf())); || LoanTransactionType.CHARGE_OFF.equals(getTypeOf()) || LoanTransactionType.REAMORTIZE.equals(getTypeOf())
|| LoanTransactionType.REAGE.equals(getTypeOf()));
} }
public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) { public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) {
@@ -61,7 +61,9 @@ public enum LoanTransactionType {
CHARGE_ADJUSTMENT(26, "loanTransactionType.chargeAdjustment"), // CHARGE_ADJUSTMENT(26, "loanTransactionType.chargeAdjustment"), //
CHARGE_OFF(27, "loanTransactionType.chargeOff"), // CHARGE_OFF(27, "loanTransactionType.chargeOff"), //
DOWN_PAYMENT(28, "loanTransactionType.downPayment"), // DOWN_PAYMENT(28, "loanTransactionType.downPayment"), //
REAGE(29, "loanTransactionType.reAge"), REAMORTIZE(30, "loanTransactionType.reAmortize"); REAGE(29, "loanTransactionType.reAge"), //
REAMORTIZE(30, "loanTransactionType.reAmortize"), //
;
private final Integer value; private final Integer value;
private final String code; private final String code;
@@ -40,8 +40,11 @@ public class LoanReAgeParameter extends AbstractAuditableWithUTCDateTimeCustom {
private Long loanTransactionId; private Long loanTransactionId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "frequency", nullable = false) @Column(name = "frequency_type", nullable = false)
private PeriodFrequencyType frequency; private PeriodFrequencyType frequencyType;
@Column(name = "frequency_number", nullable = false)
private Integer frequencyNumber;
@Column(name = "start_date", nullable = false) @Column(name = "start_date", nullable = false)
private LocalDate startDate; private LocalDate startDate;
@@ -479,7 +479,8 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
loanTransaction.resetDerivedComponents(); loanTransaction.resetDerivedComponents();
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>(); List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
final Comparator<LoanRepaymentScheduleInstallment> byDate = Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate); final Comparator<LoanRepaymentScheduleInstallment> byDate = Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate);
installments.sort(byDate); List<LoanRepaymentScheduleInstallment> installmentToBeProcessed = installments.stream().filter(i -> !i.isDownPayment())
.sorted(byDate).toList();
final Money zeroMoney = Money.zero(currency); final Money zeroMoney = Money.zero(currency);
Money transactionAmount = loanTransaction.getAmount(currency); Money transactionAmount = loanTransaction.getAmount(currency);
Money principalPortion = MathUtil.negativeToZero(loanTransaction.getAmount(currency).minus(overpaymentHolder.getMoneyObject())); Money principalPortion = MathUtil.negativeToZero(loanTransaction.getAmount(currency).minus(overpaymentHolder.getMoneyObject()));
@@ -492,7 +493,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
final LocalDate transactionDate = loanTransaction.getTransactionDate(); final LocalDate transactionDate = loanTransaction.getTransactionDate();
boolean loanTransactionMapped = false; boolean loanTransactionMapped = false;
LocalDate pastDueDate = null; LocalDate pastDueDate = null;
for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { for (final LoanRepaymentScheduleInstallment currentInstallment : installmentToBeProcessed) {
pastDueDate = currentInstallment.getDueDate(); pastDueDate = currentInstallment.getDueDate();
if (!currentInstallment.isAdditional() && DateUtils.isAfter(currentInstallment.getDueDate(), transactionDate)) { if (!currentInstallment.isAdditional() && DateUtils.isAfter(currentInstallment.getDueDate(), transactionDate)) {
currentInstallment.addToCreditedPrincipal(transactionAmount.getAmount()); currentInstallment.addToCreditedPrincipal(transactionAmount.getAmount());
@@ -526,7 +527,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
// New installment will be added (N+1 scenario) // New installment will be added (N+1 scenario)
if (!loanTransactionMapped) { if (!loanTransactionMapped) {
if (loanTransaction.getTransactionDate().equals(pastDueDate)) { if (loanTransaction.getTransactionDate().equals(pastDueDate)) {
LoanRepaymentScheduleInstallment currentInstallment = installments.get(installments.size() - 1); LoanRepaymentScheduleInstallment currentInstallment = installmentToBeProcessed.get(installmentToBeProcessed.size() - 1);
currentInstallment.addToCreditedPrincipal(transactionAmount.getAmount()); currentInstallment.addToCreditedPrincipal(transactionAmount.getAmount());
currentInstallment.addToPrincipal(transactionDate, transactionAmount); currentInstallment.addToPrincipal(transactionDate, transactionAmount);
if (repaidAmount.isGreaterThanZero()) { if (repaidAmount.isGreaterThanZero()) {
@@ -848,7 +849,8 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
protected void addChargeOnlyRepaymentInstallmentIfRequired(Set<LoanCharge> charges, protected void addChargeOnlyRepaymentInstallmentIfRequired(Set<LoanCharge> charges,
List<LoanRepaymentScheduleInstallment> installments) { List<LoanRepaymentScheduleInstallment> installments) {
if (!CollectionUtils.isEmpty(charges) && !CollectionUtils.isEmpty(installments)) { if (!CollectionUtils.isEmpty(charges) && !CollectionUtils.isEmpty(installments)) {
LoanRepaymentScheduleInstallment latestRepaymentScheduleInstalment = installments.get(installments.size() - 1); LoanRepaymentScheduleInstallment latestRepaymentScheduleInstalment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
LocalDate installmentDueDate = null; LocalDate installmentDueDate = null;
LoanCharge latestCharge = getLatestLoanChargeWithSpecificDueDate(charges); LoanCharge latestCharge = getLatestLoanChargeWithSpecificDueDate(charges);
@@ -867,7 +869,6 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, false, null); BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, false, null);
installment.markAsAdditional(); installment.markAsAdditional();
loan.addLoanRepaymentScheduleInstallment(installment); loan.addLoanRepaymentScheduleInstallment(installment);
} }
} }
} }
@@ -22,7 +22,6 @@ import static java.math.BigDecimal.ZERO;
import static java.util.stream.Collectors.mapping; import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.CHARGEBACK; import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.CHARGEBACK;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REAMORTIZE;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.FEE; import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.FEE;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.INTEREST; import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.INTEREST;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY; import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY;
@@ -44,11 +43,14 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter; import lombok.Setter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.NotImplementedException;
@@ -71,6 +73,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymentScheduleProcessingWrapper; import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymentScheduleProcessingWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor; 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.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
@@ -84,12 +88,15 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
@Slf4j @Slf4j
@RequiredArgsConstructor
public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor { public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy"; public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy";
public final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper(); public final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
private final LoanReAgingParameterRepository reAgingParameterRepository;
@Override @Override
public String getCode() { public String getCode() {
return ADVANCED_PAYMENT_ALLOCATION_STRATEGY; return ADVANCED_PAYMENT_ALLOCATION_STRATEGY;
@@ -144,6 +151,10 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
} }
} }
} }
// Remove re-aged and additional (N+1) installments (if applicable), those will be recreated during the
// reprocessing
installments.removeIf(LoanRepaymentScheduleInstallment::isReAged);
installments.removeIf(LoanRepaymentScheduleInstallment::isAdditional);
addChargeOnlyRepaymentInstallmentIfRequired(charges, installments); addChargeOnlyRepaymentInstallmentIfRequired(charges, installments);
@@ -185,6 +196,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
ctx.getOverpaymentHolder()); ctx.getOverpaymentHolder());
case WAIVE_CHARGES -> log.debug("WAIVE_CHARGES transaction will not be processed."); case WAIVE_CHARGES -> log.debug("WAIVE_CHARGES transaction will not be processed.");
case REAMORTIZE -> handleReAmortization(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); case REAMORTIZE -> handleReAmortization(loanTransaction, ctx.getCurrency(), ctx.getInstallments());
case REAGE -> handleReAge(loanTransaction, ctx);
// TODO: Cover rest of the transaction types // TODO: Cover rest of the transaction types
default -> { default -> {
log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf()); log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf());
@@ -201,7 +213,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
.toList(); .toList();
List<LoanRepaymentScheduleInstallment> futureInstallments = installments.stream() // List<LoanRepaymentScheduleInstallment> futureInstallments = installments.stream() //
.filter(installment -> installment.getDueDate().isAfter(transactionDate)) // .filter(installment -> installment.getDueDate().isAfter(transactionDate)) //
.filter(installment -> !installment.isAdditional() && !installment.isDownPayment()) // .filter(installment -> !installment.isAdditional() && !installment.isDownPayment() && !installment.isReAged()) //
.toList(); .toList();
BigDecimal overallOverDuePrincipal = ZERO; BigDecimal overallOverDuePrincipal = ZERO;
@@ -1279,4 +1291,62 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private Money aggregatedInterestPortion; private Money aggregatedInterestPortion;
private Money aggregatedPenaltyChargesPortion; private Money aggregatedPenaltyChargesPortion;
} }
private void handleReAge(LoanTransaction loanTransaction, TransactionCtx ctx) {
MonetaryCurrency currency = ctx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
// Either we have the transaction id or we need to fetch it from context
Long loanTransactionId = loanTransaction.getId() != null ? loanTransaction.getId()
: ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction);
LoanReAgeParameter reAgeParameter = reAgingParameterRepository.findByLoanTransactionId(loanTransactionId).orElseThrow();
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());
}
});
Money calculatedPrincipal = outstandingPrincipalBalance.get().dividedBy(reAgeParameter.getNumberOfInstallments(),
MoneyHelper.getRoundingMode());
Integer installmentAmountInMultiplesOf = loanTransaction.getLoan().getLoanProduct().getInstallmentAmountInMultiplesOf();
if (installmentAmountInMultiplesOf != null) {
calculatedPrincipal = Money.roundToMultiplesOf(calculatedPrincipal, installmentAmountInMultiplesOf);
}
Money adjustCalculatedPrincipal = outstandingPrincipalBalance.get()
.minus(calculatedPrincipal.multipliedBy(reAgeParameter.getNumberOfInstallments()));
LoanRepaymentScheduleInstallment lastNormalInstallment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
LoanRepaymentScheduleInstallment reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(
lastNormalInstallment.getLoan(), lastNormalInstallment.getInstallmentNumber() + 1, lastNormalInstallment.getDueDate(),
reAgeParameter.getStartDate(), calculatedPrincipal.getAmount());
installments.add(reAgedInstallment);
for (int i = 1; i < reAgeParameter.getNumberOfInstallments(); i++) {
LocalDate calculatedDueDate = calculateReAgedInstallmentDueDate(reAgeParameter, reAgedInstallment.getDueDate());
reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(reAgedInstallment.getLoan(),
reAgedInstallment.getInstallmentNumber() + 1, reAgedInstallment.getDueDate(), calculatedDueDate,
calculatedPrincipal.getAmount());
installments.add(reAgedInstallment);
}
reAgedInstallment.addToPrincipal(loanTransaction.getTransactionDate(), adjustCalculatedPrincipal);
reprocessInstallmentsOrder(installments);
}
private void reprocessInstallmentsOrder(List<LoanRepaymentScheduleInstallment> installments) {
AtomicInteger counter = new AtomicInteger(0);
installments.stream().sorted(LoanRepaymentScheduleInstallment::compareToByDueDate)
.forEachOrdered(i -> i.updateInstallmentNumber(counter.getAndIncrement()));
}
private LocalDate calculateReAgedInstallmentDueDate(LoanReAgeParameter reAgeParameter, LocalDate dueDate) {
return switch (reAgeParameter.getFrequencyType()) {
case DAYS -> dueDate.plusDays(reAgeParameter.getFrequencyNumber());
case WEEKS -> dueDate.plusWeeks(reAgeParameter.getFrequencyNumber());
case MONTHS -> dueDate.plusMonths(reAgeParameter.getFrequencyNumber());
case YEARS -> dueDate.plusYears(reAgeParameter.getFrequencyNumber());
default -> throw new UnsupportedOperationException(reAgeParameter.getFrequencyType().getCode());
};
}
} }
@@ -42,4 +42,5 @@
<include relativeToChangelogFile="true" file="parts/1017_add_fee_and_penalty_adjustments_to_loan.xml"/> <include relativeToChangelogFile="true" file="parts/1017_add_fee_and_penalty_adjustments_to_loan.xml"/>
<include relativeToChangelogFile="true" file="parts/1018_rename_credited_principal_back_to_credits_amount.xml"/> <include relativeToChangelogFile="true" file="parts/1018_rename_credited_principal_back_to_credits_amount.xml"/>
<include relativeToChangelogFile="true" file="parts/1019_add_fixed_length.xml"/> <include relativeToChangelogFile="true" file="parts/1019_add_fixed_length.xml"/>
<include relativeToChangelogFile="true" file="parts/1020_add_re_aged_flag_to_loan_installment.xml"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_loan_repayment_schedule">
<column name="is_re_aged" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -285,8 +285,10 @@ final class LoanTransactionsApiResourceSwagger {
public Long writeoffReasonId; public Long writeoffReasonId;
// command=reAge START // command=reAge START
@Schema(example = "frequency") @Schema(example = "frequencyType")
public String frequency; public String frequencyType;
@Schema(example = "frequencyNumber")
public Integer frequencyNumber;
@Schema(example = "startDate") @Schema(example = "startDate")
public String startDate; public String startDate;
@Schema(example = "numberOfInstallments") @Schema(example = "numberOfInstallments")
@@ -1854,7 +1854,8 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
holidayDetailDTO); holidayDetailDTO);
updateMapWithAmount(principalPortionMap, unprocessed, applicableDate); updateMapWithAmount(principalPortionMap, unprocessed, applicableDate);
installment.addPrincipalAmount(unprocessed); installment.addPrincipalAmount(unprocessed);
LoanRepaymentScheduleInstallment lastInstallment = installments.get(installments.size() - 1); LoanRepaymentScheduleInstallment lastInstallment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
lastInstallment.updatePrincipal(lastInstallment.getPrincipal(unprocessed.getCurrency()).plus(unprocessed).getAmount()); lastInstallment.updatePrincipal(lastInstallment.getPrincipal(unprocessed.getCurrency()).plus(unprocessed).getAmount());
lastInstallment.payPrincipalComponent(detail.getTransactionDate(), unprocessed); lastInstallment.payPrincipalComponent(detail.getTransactionDate(), unprocessed);
} }
@@ -181,6 +181,12 @@ public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanSc
// } // }
} }
// If the disbursement happened after maturity date
if (loanApplicationTerms.isMultiDisburseLoan()) {
processDisbursements(loanApplicationTerms, chargesDueAtTimeOfDisbursement, scheduleParams, periods,
DateUtils.getBusinessLocalDate().plusDays(1));
}
// determine fees and penalties for charges which depends on total // determine fees and penalties for charges which depends on total
// loan interest // loan interest
updatePeriodsWithCharges(currency, scheduleParams, periods, nonCompoundingCharges, mc); updatePeriodsWithCharges(currency, scheduleParams, periods, nonCompoundingCharges, mc);
@@ -498,7 +498,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
} }
} }
if (!changes.isEmpty()) { if (!changes.isEmpty()) {
loan.updateLoanScheduleDependentDerivedFields();
loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan); loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan);
final String noteText = command.stringValueOfParameterNamed("note"); final String noteText = command.stringValueOfParameterNamed("note");
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -41,12 +42,18 @@ import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants; import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionComparator;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter; import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -61,6 +68,8 @@ public class LoanReAgingServiceImpl {
private final BusinessEventNotifierService businessEventNotifierService; private final BusinessEventNotifierService businessEventNotifierService;
private final LoanTransactionRepository loanTransactionRepository; private final LoanTransactionRepository loanTransactionRepository;
private final LoanReAgingParameterRepository reAgingParameterRepository; private final LoanReAgingParameterRepository reAgingParameterRepository;
private final LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory;
private final NoteRepository noteRepository;
public CommandProcessingResult reAge(Long loanId, JsonCommand command) { public CommandProcessingResult reAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId); Loan loan = loanAssembler.assembleFrom(loanId);
@@ -77,6 +86,15 @@ public class LoanReAgingServiceImpl {
LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command); LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command);
reAgingParameterRepository.saveAndFlush(reAgeParameter); reAgingParameterRepository.saveAndFlush(reAgeParameter);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction,
new LoanRepaymentScheduleTransactionProcessor.TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(),
loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney())));
loan.updateLoanScheduleDependentDerivedFields();
persistNote(loan, command, changes);
// delinquency recalculation will be triggered by the event in a decoupled way via a listener // delinquency recalculation will be triggered by the event in a decoupled way via a listener
businessEventNotifierService.notifyPostBusinessEvent(new LoanReAgeBusinessEvent(loan)); businessEventNotifierService.notifyPostBusinessEvent(new LoanReAgeBusinessEvent(loan));
businessEventNotifierService.notifyPostBusinessEvent(new LoanReAgeTransactionBusinessEvent(reAgeTransaction)); businessEventNotifierService.notifyPostBusinessEvent(new LoanReAgeTransactionBusinessEvent(reAgeTransaction));
@@ -91,15 +109,6 @@ public class LoanReAgingServiceImpl {
.with(changes).build(); .with(changes).build();
} }
private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) {
// TODO: these parameters should be checked when the validations are implemented
PeriodFrequencyType periodFrequencyType = command.enumValueOfParameterNamed(LoanReAgingApiConstants.frequency,
PeriodFrequencyType.class);
LocalDate startDate = command.dateValueOfParameterNamed(LoanReAgingApiConstants.startDate);
Integer numberOfInstallments = command.integerValueOfParameterNamed(LoanReAgingApiConstants.numberOfInstallments);
return new LoanReAgeParameter(reAgeTransaction.getId(), periodFrequencyType, startDate, numberOfInstallments);
}
public CommandProcessingResult undoReAge(Long loanId, JsonCommand command) { public CommandProcessingResult undoReAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId); Loan loan = loanAssembler.assembleFrom(loanId);
reAgingValidator.validateUndoReAge(loan, command); reAgingValidator.validateUndoReAge(loan, command);
@@ -115,6 +124,10 @@ public class LoanReAgingServiceImpl {
reverseReAgeTransaction(reAgeTransaction, command); reverseReAgeTransaction(reAgeTransaction, command);
loanTransactionRepository.saveAndFlush(reAgeTransaction); loanTransactionRepository.saveAndFlush(reAgeTransaction);
reProcessLoanTransactions(reAgeTransaction.getLoan());
loan.updateLoanScheduleDependentDerivedFields();
persistNote(loan, command, changes);
// delinquency recalculation will be triggered by the event in a decoupled way via a listener // delinquency recalculation will be triggered by the event in a decoupled way via a listener
businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoReAgeBusinessEvent(loan)); businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoReAgeBusinessEvent(loan));
businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoReAgeTransactionBusinessEvent(reAgeTransaction)); businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoReAgeTransactionBusinessEvent(reAgeTransaction));
@@ -156,4 +169,35 @@ public class LoanReAgingServiceImpl {
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE.getValue(), transactionDate, txPrincipalAmount, return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE.getValue(), transactionDate, txPrincipalAmount,
txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, txExternalId); txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, txExternalId);
} }
private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) {
// TODO: these parameters should be checked when the validations are implemented
PeriodFrequencyType periodFrequencyType = command.enumValueOfParameterNamed(LoanReAgingApiConstants.frequencyType,
PeriodFrequencyType.class);
LocalDate startDate = command.dateValueOfParameterNamed(LoanReAgingApiConstants.startDate);
Integer numberOfInstallments = command.integerValueOfParameterNamed(LoanReAgingApiConstants.numberOfInstallments);
Integer periodFrequencyNumber = command.integerValueOfParameterNamed(LoanReAgingApiConstants.frequencyNumber);
return new LoanReAgeParameter(reAgeTransaction.getId(), periodFrequencyType, periodFrequencyNumber, startDate,
numberOfInstallments);
}
private void reProcessLoanTransactions(Loan loan) {
final List<LoanTransaction> filteredTransactions = loan.getLoanTransactions().stream().filter(LoanTransaction::isNotReversed)
.filter(t -> t.isChargeOff() || !t.isNonMonetaryTransaction()).sorted(LoanTransactionComparator.INSTANCE).toList();
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(loan.getDisbursementDate(), filteredTransactions,
loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
}
private void persistNote(Loan loan, JsonCommand command, Map<String, Object> changes) {
if (command.hasParameter("note")) {
final String note = command.stringValueOfParameterNamed("note");
final Note newNote = Note.loanNote(loan, note);
changes.put("note", note);
this.noteRepository.saveAndFlush(newNote);
}
}
} }
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.loanaccount.starter;
import java.util.List; import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; 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.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor;
@@ -103,8 +104,9 @@ public class LoanAccountAutoStarter {
@Bean @Bean
@Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class) @Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class)
public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor() { public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor(
return new AdvancedPaymentScheduleTransactionProcessor(); LoanReAgingParameterRepository reAgingParameterRepository) {
return new AdvancedPaymentScheduleTransactionProcessor(reAgingParameterRepository);
} }
} }
@@ -71,4 +71,12 @@
</column> </column>
</addColumn> </addColumn>
</changeSet> </changeSet>
<changeSet id="4" author="fineract">
<addColumn tableName="m_loan_reage_parameter">
<column name="frequency_number" type="SMALLINT" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
</addColumn>
<renameColumn tableName="m_loan_reage_parameter" oldColumnName="frequency" newColumnName="frequency_type" columnDataType="VARCHAR(100)"/>
</changeSet>
</databaseChangeLog> </databaseChangeLog>
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
@@ -89,6 +90,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
private static final MonetaryCurrency MONETARY_CURRENCY = new MonetaryCurrency("USD", 2, 1); private static final MonetaryCurrency MONETARY_CURRENCY = new MonetaryCurrency("USD", 2, 1);
private static final MockedStatic<MoneyHelper> MONEY_HELPER = mockStatic(MoneyHelper.class); private static final MockedStatic<MoneyHelper> MONEY_HELPER = mockStatic(MoneyHelper.class);
private AdvancedPaymentScheduleTransactionProcessor underTest; private AdvancedPaymentScheduleTransactionProcessor underTest;
private LoanReAgingParameterRepository reAgingParameterRepository = Mockito.mock(LoanReAgingParameterRepository.class);
@BeforeAll @BeforeAll
public static void init() { public static void init() {
@@ -102,7 +104,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
@BeforeEach @BeforeEach
public void setUp() { public void setUp() {
underTest = new AdvancedPaymentScheduleTransactionProcessor(); underTest = new AdvancedPaymentScheduleTransactionProcessor(reAgingParameterRepository);
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
@@ -378,11 +378,12 @@ public abstract class BaseLoanIntegrationTest {
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
} }
protected void reAgeLoan(Long loanId, String frequency, String startDate, Integer numberOfInstallments) { protected void reAgeLoan(Long loanId, String frequencyType, int frequencyNumber, String startDate, Integer numberOfInstallments) {
PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest(); PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
request.setDateFormat(DATETIME_PATTERN); request.setDateFormat(DATETIME_PATTERN);
request.setLocale("en"); request.setLocale("en");
request.setFrequency(frequency); request.setFrequencyType(frequencyType);
request.setFrequencyNumber(frequencyNumber);
request.setStartDate(startDate); request.setStartDate(startDate);
request.setNumberOfInstallments(numberOfInstallments); request.setNumberOfInstallments(numberOfInstallments);
loanTransactionHelper.reAge(loanId, request); loanTransactionHelper.reAge(loanId, request);
@@ -798,6 +799,13 @@ public abstract class BaseLoanIntegrationTest {
assertEquals(paidLate, period.getTotalPaidLateForPeriod()); assertEquals(paidLate, period.getTotalPaidLateForPeriod());
} }
protected void checkMaturityDates(long loanId, LocalDate expectedMaturityDate, LocalDate actualMaturityDate) {
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertEquals(expectedMaturityDate, loanDetails.getTimeline().getExpectedMaturityDate());
assertEquals(actualMaturityDate, loanDetails.getTimeline().getActualMaturityDate());
}
@RequiredArgsConstructor @RequiredArgsConstructor
public static class BatchRequestBuilder { public static class BatchRequestBuilder {
@@ -937,6 +945,7 @@ public abstract class BaseLoanIntegrationTest {
public static final Integer MONTHS = 2; public static final Integer MONTHS = 2;
public static final String MONTHS_STRING = "MONTHS"; public static final String MONTHS_STRING = "MONTHS";
public static final String DAYS_STRING = "DAYS";
} }
public static class InterestCalculationPeriodType { public static class InterestCalculationPeriodType {
@@ -120,9 +120,6 @@ public class LoanDelinquencyDetailsNextPaymentDateConfigurationTest extends Base
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("25 December 2023") businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("25 December 2023")
.dateFormat(DATETIME_PATTERN).locale("en")); .dateFormat(DATETIME_PATTERN).locale("en"));
// delinquency null next payment date for date after maturity date
verifyLoanDelinquencyNextPaymentDate(loanId, "", true);
} finally { } finally {
// reset global config // reset global config
GlobalConfigurationHelper.updateLoanNextPaymentDateConfiguration(this.requestSpec, this.responseSpec, GlobalConfigurationHelper.updateLoanNextPaymentDateConfiguration(this.requestSpec, this.responseSpec,
@@ -238,10 +235,6 @@ public class LoanDelinquencyDetailsNextPaymentDateConfigurationTest extends Base
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("25 December 2023") businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("25 December 2023")
.dateFormat(DATETIME_PATTERN).locale("en")); .dateFormat(DATETIME_PATTERN).locale("en"));
// delinquency null next payment date for date after maturity date
verifyLoanDelinquencyNextPaymentDate(loanId, "", true);
} finally { } finally {
// reset global config // reset global config
GlobalConfigurationHelper.updateLoanNextPaymentDateConfiguration(this.requestSpec, this.responseSpec, GlobalConfigurationHelper.updateLoanNextPaymentDateConfiguration(this.requestSpec, this.responseSpec,
@@ -19,10 +19,15 @@
package org.apache.fineract.integrationtests.loan.reaging; package org.apache.fineract.integrationtests.loan.reaging;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import org.apache.fineract.client.models.PostChargesResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse; import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse;
import org.apache.fineract.client.models.PostLoansLoanIdResponse; import org.apache.fineract.client.models.PostLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansRequest; import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.integrationtests.BaseLoanIntegrationTest; import org.apache.fineract.integrationtests.BaseLoanIntegrationTest;
@@ -40,13 +45,17 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest {
// Create Client // Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 1; int numberOfRepayments = 3;
int repaymentEvery = 1; int repaymentEvery = 1;
// Create Loan Product // Create Loan Product
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() // PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() //
.numberOfRepayments(numberOfRepayments) // .numberOfRepayments(numberOfRepayments) //
.repaymentEvery(repaymentEvery) // .repaymentEvery(repaymentEvery) //
.installmentAmountInMultiplesOf(null) //
.enableDownPayment(true) //
.disbursedAmountPercentageForDownPayment(BigDecimal.valueOf(25)) //
.enableAutoRepaymentForDownPayment(true) //
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()); // .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()); //
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
@@ -74,102 +83,75 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest {
// verify transactions // verify transactions
verifyTransactions(loanId, // verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023") // transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023") //
); );
// verify schedule // verify schedule
verifyRepaymentSchedule(loanId, // verifyRepaymentSchedule(loanId, //
installment(0, null, "01 January 2023"), // installment(1250, null, "01 January 2023"), //
installment(1250.0, false, "01 February 2023") // installment(312.5, true, "01 January 2023"), //
installment(312.5, false, "01 February 2023"), //
installment(312.5, false, "01 March 2023"), //
installment(312.5, false, "01 April 2023") //
); );
checkMaturityDates(loanId, LocalDate.of(2023, 4, 1), LocalDate.of(2023, 4, 1));
createdLoanId.set(loanId); createdLoanId.set(loanId);
}); });
runAt("02 February 2023", () -> { runAt("11 April 2023", () -> {
long loanId = createdLoanId.get(); long loanId = createdLoanId.get();
// create re-age transaction // create charge
reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, "02 February 2023", 6); double chargeAmount = 10.0;
PostChargesResponse chargeResult = createCharge(chargeAmount);
Long chargeId = chargeResult.getResourceId();
// verify transactions // add charge after maturity
verifyTransactions(loanId, // PostLoansLoanIdChargesResponse loanChargeResult = addLoanCharge(loanId, chargeId, "11 April 2023", chargeAmount);
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(1250.0, "Re-age", "02 February 2023") //
);
// TODO: verify installments when schedule generation is implemented
});
}
@Test
public void test_LoanUndoReAgeTransaction_Works() {
AtomicLong createdLoanId = new AtomicLong();
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 1;
int repaymentEvery = 1;
// Create Loan Product
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() //
.numberOfRepayments(numberOfRepayments) //
.repaymentEvery(repaymentEvery) //
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()); //
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
// Apply and Approve Loan
double amount = 1250.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)//
.transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)//
.repaymentEvery(repaymentEvery)//
.loanTermFrequency(numberOfRepayments)//
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS)//
.loanTermFrequencyType(RepaymentFrequencyType.MONTHS);
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 January 2023"));
Long loanId = approvedLoanResult.getLoanId();
// disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023");
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023") //
);
// verify schedule // verify schedule
verifyRepaymentSchedule(loanId, // verifyRepaymentSchedule(loanId, //
installment(0, null, "01 January 2023"), // installment(1250, null, "01 January 2023"), //
installment(1250.0, false, "01 February 2023") // installment(312.5, true, "01 January 2023"), //
installment(312.5, false, "01 February 2023"), //
installment(312.5, false, "01 March 2023"), //
installment(312.5, false, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023") //
); );
checkMaturityDates(loanId, LocalDate.of(2023, 4, 1), LocalDate.of(2023, 4, 1));
createdLoanId.set(loanId);
}); });
runAt("02 February 2023", () -> { runAt("12 April 2023", () -> {
long loanId = createdLoanId.get(); long loanId = createdLoanId.get();
// create re-age transaction // create re-age transaction
reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, "02 February 2023", 6); reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "12 April 2023", 4);
// verify transactions // verify transactions
verifyTransactions(loanId, // verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), // transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(1250.0, "Re-age", "02 February 2023") // transaction(312.5, "Down Payment", "01 January 2023"), //
transaction(937.5, "Re-age", "12 April 2023") //
); );
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, true, "01 January 2023"), //
installment(0, true, "01 February 2023"), //
installment(0, true, "01 March 2023"), //
installment(0, true, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023"), //
installment(234.38, false, "12 April 2023"), //
installment(234.38, false, "12 May 2023"), //
installment(234.38, false, "12 June 2023"), //
installment(234.36, false, "12 July 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 7, 12), LocalDate.of(2023, 7, 12));
}); });
runAt("03 February 2023", () -> { runAt("13 April 2023", () -> {
long loanId = createdLoanId.get(); long loanId = createdLoanId.get();
// create re-age transaction // create re-age transaction
@@ -178,10 +160,106 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest {
// verify transactions // verify transactions
verifyTransactions(loanId, // verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), // transaction(1250.0, "Disbursement", "01 January 2023"), //
reversedTransaction(1250.0, "Re-age", "02 February 2023") // transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023") //
); );
// TODO: verify installments when schedule generation is implemented // verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, true, "01 January 2023"), //
installment(312.5, false, "01 February 2023"), //
installment(312.5, false, "01 March 2023"), //
installment(312.5, false, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 4, 1), LocalDate.of(2023, 4, 1));
});
String repaymentExternalId = UUID.randomUUID().toString();
runAt("13 April 2023", () -> {
long loanId = createdLoanId.get();
loanTransactionHelper.makeLoanRepayment(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN)
.transactionDate("13 April 2023").locale("en").transactionAmount(100.0).externalId(repaymentExternalId));
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023"), //
transaction(100.0, "Repayment", "13 April 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, 0, 0, 0, 0.0, true, "01 January 2023"), //
installment(312.5, 0, 0, 0, 212.5, false, "01 February 2023"), //
installment(312.5, 0, 0, 0, 312.5, false, "01 March 2023"), //
installment(312.5, 0, 0, 0, 312.5, false, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023") //
);
// create re-age transaction
reAgeLoan(loanId, RepaymentFrequencyType.DAYS_STRING, 30, "13 April 2023", 3);
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023"), //
transaction(100.0, "Repayment", "13 April 2023"), //
transaction(837.5, "Re-age", "13 April 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, 0, 0, 0, 0.0, true, "01 January 2023"), //
installment(100.0, 0, 0, 0, 0.0, true, "01 February 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 March 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023"), //
installment(279.17, 0, 0, 0, 279.17, false, "13 April 2023"), //
installment(279.17, 0, 0, 0, 279.17, false, "13 May 2023"), //
installment(279.16, 0, 0, 0, 279.16, false, "12 June 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 6, 12), LocalDate.of(2023, 6, 12));
});
runAt("14 April 2023", () -> {
long loanId = createdLoanId.get();
// disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(100.0), "14 April 2023");
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023"), //
transaction(100.0, "Repayment", "13 April 2023"), //
transaction(837.5, "Re-age", "13 April 2023"), //
transaction(100.0, "Disbursement", "14 April 2023"), //
transaction(25.0, "Down Payment", "14 April 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, 0, 0, 0, 0.0, true, "01 January 2023"), //
installment(100.0, 0, 0, 0, 0.0, true, "01 February 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 March 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 0.0, true, "11 April 2023"), //
installment(279.17, 0, 0, 0, 264.17, false, "13 April 2023"), //
installment(100, null, "14 April 2023"), //
installment(25.0, 0, 0, 0, 25.0, false, "14 April 2023"), //
installment(316.67, 0, 0, 0, 316.67, false, "13 May 2023"), //
installment(316.66, 0, 0, 0, 316.66, false, "12 June 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 6, 12), LocalDate.of(2023, 6, 12));
}); });
} }
} }