FINERACT-2412: full term tranche - N+1 installment handling

This commit is contained in:
Soma Sörös
2026-02-03 13:12:22 +01:00
committed by Adam Saghy
parent 7a7ed538e6
commit d2497a77fc
2 changed files with 64 additions and 9 deletions
@@ -44,6 +44,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
@@ -228,12 +229,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
final Integer installmentAmountInMultiplesOf = loan.getLoanProductRelatedDetail().getInstallmentAmountInMultiplesOf();
ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateInstallmentInterestScheduleModel(installments,
LoanConfigurationDetailsMapper.map(loan), installmentAmountInMultiplesOf, overpaymentHolder.getMoneyObject().getMc());
List<Long> loanChargeIdProcessed = new ArrayList<>();
ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder,
changedTransactionDetail, scheduleModel, loan.getActiveLoanTermVariations());
changedTransactionDetail, scheduleModel, Money.zero(currency), loan.getActiveLoanTermVariations(), loanChargeIdProcessed);
List<ChangeOperation> changeOperations = createSortedChangeList(loanTermVariations, loanTransactions, charges);
List<Long> loanChargeIdProcessed = new ArrayList<>();
List<LoanTransaction> overpaidTransactions = new ArrayList<>();
for (final ChangeOperation changeOperation : changeOperations) {
if (changeOperation.isLoanTermVariationsData()) {
@@ -1595,7 +1597,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
disbursementTransaction.resetDerivedComponents();
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, disbursementTransaction, currency);
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, disbursementTransaction, currency,
((ProgressiveTransactionCtx) transactionCtx).getProcessedLoanCharges());
allocateOverpayment(disbursementTransaction, transactionCtx);
}
@@ -1668,7 +1671,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Money amortizableAmount = capitalizedIncomeTransaction.getAmount(currency);
emiCalculator.addCapitalizedIncome(model, transactionDate, amortizableAmount);
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, capitalizedIncomeTransaction, currency);
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, capitalizedIncomeTransaction, currency,
((ProgressiveTransactionCtx) transactionCtx).getProcessedLoanCharges());
allocateOverpayment(capitalizedIncomeTransaction, transactionCtx);
}
@@ -1690,7 +1694,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private void recalculateRepaymentPeriodsWithEMICalculation(final Money amortizableAmount,
final ProgressiveLoanInterestScheduleModel model, final List<LoanRepaymentScheduleInstallment> installments,
final LoanTransaction loanTransaction, final MonetaryCurrency currency) {
final LoanTransaction loanTransaction, final MonetaryCurrency currency, final Set<LoanCharge> processedLoanCharges) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final boolean isPostMaturityDisbursement = installments.stream().filter(i -> !i.isDownPayment() && !i.isAdditional())
.allMatch(i -> i.getDueDate().isBefore(transactionDate));
@@ -1708,6 +1712,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
final AtomicInteger installmentCounter = new AtomicInteger();
final ILoanConfigurationDetails loanProductRelatedDetail = model.loanProductRelatedDetail();
List<LoanRepaymentScheduleInstallment> newInstallments = new LinkedList<>();
model.repaymentPeriods().forEach(rm -> {
LoanRepaymentScheduleInstallment installment = null;
while (iterator.hasNext() && (installment == null || installment.isAdditional() || installment.isDownPayment())) {
@@ -1723,7 +1729,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
} else {
if (loanProductRelatedDetail != null && loanProductRelatedDetail.isAllowFullTermForTranche()
&& loanProductRelatedDetail.getNumberOfRepayments() > 0 && !rm.getDueDate().isBefore(transactionDate)) {
installmentCounter.getAndIncrement();
if (installment == null || !installment.isAdditional()) {
installmentCounter.getAndIncrement();
}
final LoanRepaymentScheduleInstallment newInstallment = new LoanRepaymentScheduleInstallment(
loanTransaction.getLoan(), installmentCounter.get(), rm.getFromDate(), rm.getDueDate(),
rm.getDuePrincipal().getAmount(), rm.getDueInterest().getAmount(), null, null, null, null, null, null,
@@ -1731,9 +1739,27 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
newInstallment.updateObligationsMet(currency, transactionDate);
iterator.add(newInstallment);
newInstallments.add(newInstallment);
}
}
});
// fix additional installment
Optional<LoanRepaymentScheduleInstallment> additionalInstallmentOptional = installments.stream()
.filter(LoanRepaymentScheduleInstallment::isAdditional).findFirst();
if (additionalInstallmentOptional.isPresent() && !newInstallments.isEmpty()) {
LoanRepaymentScheduleInstallment additional = additionalInstallmentOptional.get();
// iterate trough new installments to fix charges
for (LoanRepaymentScheduleInstallment installment : newInstallments) {
moveRelatedChargesToInstallment(processedLoanCharges, installment, List.of(additional), currency);
additional.setFromDate(installment.getDueDate());
additional.setInstallmentNumber(installment.getInstallmentNumber() + 1);
}
installments.remove(additional);
if (additional.getDueDate().isAfter(model.getMaturityDate())) {
// step is needed to move the additional installment to the end of the list.
installments.add(additional);
}
}
}
}
@@ -3866,11 +3892,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
private void moveRelatedChargesToInstallment(Set<LoanCharge> charges, LoanRepaymentScheduleInstallment target,
List<LoanRepaymentScheduleInstallment> installments, MonetaryCurrency currency) {
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments);
List<LoanRepaymentScheduleInstallment> sources, MonetaryCurrency currency) {
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(sources);
Set<LoanCharge> chargesOfNewInstallment = getLoanChargesOfInstallment(charges, target, firstNormalInstallmentNumber);
Integer targetInstallmentNumber = target.getInstallmentNumber();
installments.stream().filter(i -> Objects.equals(i.getInstallmentNumber(), targetInstallmentNumber)).findFirst()
sources.stream().filter(source -> Objects.equals(source.getInstallmentNumber(), targetInstallmentNumber)).findFirst()
.filter(source -> source != target).ifPresent(source -> {
// move fees
chargesOfNewInstallment.stream().filter(LoanCharge::isNotFullyPaid).filter(LoanCharge::isFeeCharge)
@@ -19,10 +19,13 @@
package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
@@ -50,6 +53,7 @@ public class ProgressiveTransactionCtx extends TransactionCtx {
@Setter
private boolean isPrepayAttempt = false;
private final List<LoanRepaymentScheduleInstallment> skipRepaymentScheduleInstallments = new ArrayList<>();
private final List<Long> processedLoanChargeIds;
public ProgressiveTransactionCtx(MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
Set<LoanCharge> charges, MoneyHolder overpaymentHolder, ChangedTransactionDetail changedTransactionDetail,
@@ -62,8 +66,33 @@ public class ProgressiveTransactionCtx extends TransactionCtx {
Set<LoanCharge> charges, MoneyHolder overpaymentHolder, ChangedTransactionDetail changedTransactionDetail,
ProgressiveLoanInterestScheduleModel model, Money sumOfInterestRefundAmount,
List<LoanTermVariations> activeLoanTermVariations) {
this(currency, installments, charges, overpaymentHolder, changedTransactionDetail, model, sumOfInterestRefundAmount,
activeLoanTermVariations,
charges == null ? new ArrayList<>() : charges.stream().map(AbstractPersistableCustom::getId).toList());
}
public ProgressiveTransactionCtx(MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
Set<LoanCharge> charges, MoneyHolder overpaymentHolder, ChangedTransactionDetail changedTransactionDetail,
ProgressiveLoanInterestScheduleModel model, Money sumOfInterestRefundAmount, List<LoanTermVariations> activeLoanTermVariations,
List<Long> processedLoanChargeIds) {
super(currency, installments, charges, overpaymentHolder, changedTransactionDetail, activeLoanTermVariations);
this.sumOfInterestRefundAmount = sumOfInterestRefundAmount;
this.model = model;
this.processedLoanChargeIds = processedLoanChargeIds;
}
public Set<LoanCharge> getProcessedLoanCharges() {
if (getCharges() == null) {
return new HashSet<>();
}
if (getCharges().size() == getProcessedLoanChargeIds().size()) {
return getCharges();
}
return getCharges().stream().filter(this::isLoanChargeProcessed).collect(Collectors.toSet());
}
public boolean isLoanChargeProcessed(final LoanCharge loanCharge) {
return getProcessedLoanChargeIds().contains(loanCharge.getId());
}
}