FINERACT-2211: Second disbursement error fix

This commit is contained in:
budaidev
2025-06-06 10:55:17 +02:00
committed by Adam Saghy
parent 39a8d5c63e
commit 56e89ba544
3 changed files with 268 additions and 0 deletions
@@ -1246,6 +1246,22 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Money amortizableAmount = disbursementTransaction.getAmount(currency).minus(downPaymentAmount);
emiCalculator.addDisbursement(model, transactionDate, amortizableAmount);
boolean needsNPlusOneInstallment = installments.stream()
.filter(i -> i.getDueDate().isAfter(transactionDate) || i.getDueDate().isEqual(transactionDate))
.filter(i -> !i.isDownPayment() && !i.isAdditional()).findAny().isEmpty();
if (needsNPlusOneInstallment) {
// CREATE N+1 installment like the non-EMI version does
LoanRepaymentScheduleInstallment newInstallment = new LoanRepaymentScheduleInstallment(disbursementTransaction.getLoan(),
installments.size() + 1, disbursementTransaction.getTransactionDate(), disbursementTransaction.getTransactionDate(),
Money.zero(currency).getAmount(), Money.zero(currency).getAmount(), Money.zero(currency).getAmount(),
Money.zero(currency).getAmount(), true, null);
newInstallment.updatePrincipal(amortizableAmount.getAmount());
newInstallment.markAsAdditional();
disbursementTransaction.getLoan().addLoanRepaymentScheduleInstallment(newInstallment);
installments.add(newInstallment);
}
disbursementTransaction.resetDerivedComponents();
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, transactionDate, currency);
allocateOverpayment(disbursementTransaction, transactionCtx);
@@ -1259,6 +1275,23 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanRepaymentScheduleInstallment> candidateRepaymentInstallments = installments.stream().filter(
i -> i.getDueDate().isAfter(disbursementTransaction.getTransactionDate()) && !i.isDownPayment() && !i.isAdditional())
.toList();
if (candidateRepaymentInstallments.isEmpty()) {
LoanRepaymentScheduleInstallment newInstallment;
if (installments.stream().filter(LoanRepaymentScheduleInstallment::isAdditional).findAny().isEmpty()) {
newInstallment = new LoanRepaymentScheduleInstallment(disbursementTransaction.getLoan(), installments.size() + 1,
disbursementTransaction.getTransactionDate(), disbursementTransaction.getTransactionDate(),
Money.zero(currency).getAmount(), Money.zero(currency).getAmount(), Money.zero(currency).getAmount(),
Money.zero(currency).getAmount(), false, null);
newInstallment.markAsAdditional();
disbursementTransaction.getLoan().addLoanRepaymentScheduleInstallment(newInstallment);
installments.add(newInstallment);
} else {
newInstallment = installments.stream().filter(LoanRepaymentScheduleInstallment::isAdditional).findFirst().orElseThrow();
newInstallment.updateDueDate(disbursementTransaction.getTransactionDate());
}
candidateRepaymentInstallments = Collections.singletonList(newInstallment);
}
LoanProductRelatedDetail loanProductRelatedDetail = disbursementTransaction.getLoan().getLoanRepaymentScheduleDetail();
Integer installmentAmountInMultiplesOf = disbursementTransaction.getLoan().getLoanProductRelatedDetail()
.getInstallmentAmountInMultiplesOf();
@@ -1325,7 +1358,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private void recalculateRepaymentPeriodsWithEMICalculation(Money amortizableAmount, ProgressiveLoanInterestScheduleModel model,
List<LoanRepaymentScheduleInstallment> installments, LocalDate transactionDate, MonetaryCurrency currency) {
boolean isPostMaturityDisbursement = installments.stream().filter(i -> !i.isDownPayment() && !i.isAdditional())
.allMatch(i -> i.getDueDate().isBefore(transactionDate));
if (amortizableAmount.isGreaterThanZero()) {
if (isPostMaturityDisbursement) {
LoanRepaymentScheduleInstallment additionalInstallment = installments.stream()
.filter(LoanRepaymentScheduleInstallment::isAdditional).findFirst().orElse(null);
if (additionalInstallment != null && additionalInstallment.getPrincipal(currency).isZero()) {
additionalInstallment.updatePrincipal(amortizableAmount.getAmount());
}
}
model.repaymentPeriods().forEach(rm -> {
LoanRepaymentScheduleInstallment installment = installments.stream().filter(
ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment() && !ri.getDueDate().isBefore(transactionDate))
@@ -1344,6 +1388,15 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Integer installmentAmountInMultiplesOf) {
if (amortizableAmount.isGreaterThanZero()) {
int noCandidateRepaymentInstallments = candidateRepaymentInstallments.size();
// Handle the case where no future installments exist (e.g., second disbursement after loan closure)
if (noCandidateRepaymentInstallments == 0) {
log.debug("No candidate repayment installments found for disbursement on {}. Creating new installments.",
loanTransaction.getTransactionDate());
return;
}
// Original logic for when candidate installments exist
Money increasePrincipalBy = amortizableAmount.dividedBy(noCandidateRepaymentInstallments, MoneyHelper.getMathContext());
MoneyHolder moneyHolder = new MoneyHolder(amortizableAmount);
@@ -25,6 +25,7 @@ import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PE
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PRINCIPAL;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.refEq;
@@ -515,6 +516,71 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
assertEquals(transactionAmountMoney.toString(), paidPortion.toString());
}
@Test
public void testDisbursementAfterMaturityDateWithEMICalculator() {
LocalDate disbursementDate = LocalDate.of(2023, 1, 1);
LocalDate maturityDate = LocalDate.of(2023, 6, 1);
LocalDate postMaturityDisbursementDate = LocalDate.of(2023, 7, 15); // After maturity date
MonetaryCurrency currency = MONETARY_CURRENCY;
BigDecimal postMaturityDisbursementAmount = BigDecimal.valueOf(500.0);
Money disbursementMoney = Money.of(currency, postMaturityDisbursementAmount);
LoanProductRelatedDetail loanProductRelatedDetail = mock(LoanProductRelatedDetail.class);
org.apache.fineract.portfolio.loanproduct.domain.LoanProduct loanProduct = mock(
org.apache.fineract.portfolio.loanproduct.domain.LoanProduct.class);
when(loanProduct.getInstallmentAmountInMultiplesOf()).thenReturn(null);
when(loanProductRelatedDetail.isEnableDownPayment()).thenReturn(false);
Loan loan = mock(Loan.class);
when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductRelatedDetail);
when(loan.getLoanProduct()).thenReturn(loanProduct);
when(loan.isInterestBearing()).thenReturn(true);
LoanRepaymentScheduleInstallment installment1 = spy(
new LoanRepaymentScheduleInstallment(loan, 1, disbursementDate, disbursementDate.plusMonths(1), BigDecimal.valueOf(200.0),
BigDecimal.valueOf(10.0), BigDecimal.valueOf(0.0), BigDecimal.valueOf(0.0), false, null, BigDecimal.ZERO));
LoanRepaymentScheduleInstallment installment2 = spy(new LoanRepaymentScheduleInstallment(loan, 2, disbursementDate.plusMonths(1),
disbursementDate.plusMonths(2), BigDecimal.valueOf(200.0), BigDecimal.valueOf(10.0), BigDecimal.valueOf(0.0),
BigDecimal.valueOf(0.0), false, null, BigDecimal.ZERO));
LoanRepaymentScheduleInstallment installment3 = spy(
new LoanRepaymentScheduleInstallment(loan, 3, disbursementDate.plusMonths(2), maturityDate, BigDecimal.valueOf(600.0),
BigDecimal.valueOf(10.0), BigDecimal.valueOf(0.0), BigDecimal.valueOf(0.0), false, null, BigDecimal.ZERO));
List<LoanRepaymentScheduleInstallment> installments = new ArrayList<>(Arrays.asList(installment1, installment2, installment3));
List<LoanRepaymentScheduleInstallment> spyInstallments = spy(installments);
LoanTransaction disbursementTransaction = mock(LoanTransaction.class);
when(disbursementTransaction.getTypeOf()).thenReturn(LoanTransactionType.DISBURSEMENT);
when(disbursementTransaction.getTransactionDate()).thenReturn(postMaturityDisbursementDate);
when(disbursementTransaction.getAmount(currency)).thenReturn(disbursementMoney);
when(disbursementTransaction.getLoan()).thenReturn(loan);
ArgumentCaptor<LoanRepaymentScheduleInstallment> installmentCaptor = ArgumentCaptor
.forClass(LoanRepaymentScheduleInstallment.class);
Mockito.doNothing().when(loan).addLoanRepaymentScheduleInstallment(installmentCaptor.capture());
ProgressiveLoanInterestScheduleModel model = mock(ProgressiveLoanInterestScheduleModel.class);
TransactionCtx ctx = new ProgressiveTransactionCtx(currency, spyInstallments, Set.of(), new MoneyHolder(Money.zero(currency)),
mock(ChangedTransactionDetail.class), model, Money.zero(currency));
underTest.processLatestTransaction(disbursementTransaction, ctx);
Mockito.verify(emiCalculator).addDisbursement(eq(model), eq(postMaturityDisbursementDate), eq(disbursementMoney));
Mockito.verify(loan).addLoanRepaymentScheduleInstallment(any(LoanRepaymentScheduleInstallment.class));
LoanRepaymentScheduleInstallment newInstallment = installmentCaptor.getValue();
assertNotNull(newInstallment);
assertTrue(newInstallment.isAdditional());
assertEquals(postMaturityDisbursementDate, newInstallment.getDueDate());
assertEquals(0, newInstallment.getPrincipal(currency).getAmount().compareTo(postMaturityDisbursementAmount));
}
private LoanRepaymentScheduleInstallment createMockInstallment(LocalDate localDate, boolean isAdditional) {
LoanRepaymentScheduleInstallment installment = mock(LoanRepaymentScheduleInstallment.class);
lenient().when(installment.isAdditional()).thenReturn(isAdditional);