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);
@@ -0,0 +1,149 @@
/**
* 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.
*/
package org.apache.fineract.integrationtests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@Slf4j
public class ProgressiveLoanDisbursementAfterMaturityTest extends BaseLoanIntegrationTest {
@Test
public void testSecondDisbursementAfterOriginalMaturityDate() {
final PostClientsResponse client = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final AtomicReference<Long> loanIdRef = new AtomicReference<>();
// Create loan product with specific configurations for this test
final PostLoanProductsResponse loanProductResponse = loanProductHelper
.createLoanProduct(create4IProgressive().multiDisburseLoan(true).maxTrancheCount(10).disallowExpectedDisbursements(true)
.allowApprovedDisbursedAmountsOverApplied(true).overAppliedCalculationType("percentage").overAppliedNumber(100)
.enableDownPayment(true).disbursedAmountPercentageForDownPayment(BigDecimal.valueOf(25.0))
.enableAutoRepaymentForDownPayment(true)
.addSupportedInterestRefundTypesItem(SupportedInterestRefundTypesItem.MERCHANT_ISSUED_REFUND)
.addSupportedInterestRefundTypesItem(SupportedInterestRefundTypesItem.PAYOUT_REFUND)
.paymentAllocation(List.of(createPaymentAllocation("DEFAULT", FuturePaymentAllocationRule.NEXT_INSTALLMENT),
createPaymentAllocation("DOWN_PAYMENT", FuturePaymentAllocationRule.NEXT_INSTALLMENT),
createPaymentAllocation("MERCHANT_ISSUED_REFUND", FuturePaymentAllocationRule.LAST_INSTALLMENT),
createPaymentAllocation("PAYOUT_REFUND", FuturePaymentAllocationRule.LAST_INSTALLMENT))));
runAt("14 March 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductResponse.getResourceId(), "14 March 2024", 1000.0,
0.0, 3, null);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(487.58), "14 March 2024");
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
verifyLoanStatus(loanDetails, LoanStatus.ACTIVE);
verifyTransactions(loanId, transaction(487.58, "Disbursement", "14 March 2024"),
transaction(121.90, "Down Payment", "14 March 2024"));
assertEquals(0, BigDecimal.valueOf(365.68).compareTo(loanDetails.getSummary().getPrincipalOutstanding()));
});
// Step 4: Create first merchant issued refund on 24 March 2024 for €201.39
runAt("24 March 2024", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse mirResponse = loanTransactionHelper.makeLoanRepayment(loanId, "MerchantIssuedRefund",
"24 March 2024", 201.39);
Assertions.assertNotNull(mirResponse);
Assertions.assertNotNull(mirResponse.getResourceId());
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
verifyLoanStatus(loanDetails, LoanStatus.ACTIVE);
// Verify remaining balance
assertEquals(0, BigDecimal.valueOf(164.29).compareTo(loanDetails.getSummary().getPrincipalOutstanding()));
log.info("First MIR applied. Outstanding: €{}", loanDetails.getSummary().getPrincipalOutstanding());
});
// Step 5: Create second merchant issued refund on 24 March 2024 for €286.19 to overpay
runAt("24 March 2024", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse mirResponse = loanTransactionHelper.makeLoanRepayment(loanId, "MerchantIssuedRefund",
"24 March 2024", 286.19);
Assertions.assertNotNull(mirResponse);
Assertions.assertNotNull(mirResponse.getResourceId());
// After second MIR, the loan should be overpaid
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
verifyLoanStatus(loanDetails, LoanStatus.OVERPAID);
// Verify overpaid amount
assertEquals(0, BigDecimal.valueOf(121.90).compareTo(loanDetails.getTotalOverpaid()));
});
// Step 6: Create credit balance refund on 25 March 2024 to close the loan
runAt("25 March 2024", () -> {
Long loanId = loanIdRef.get();
loanTransactionHelper.makeLoanRepayment(loanId, "CreditBalanceRefund", "25 March 2024", 121.90);
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
verifyLoanStatus(loanDetails, LoanStatus.CLOSED_OBLIGATIONS_MET);
assertEquals(0, BigDecimal.ZERO.compareTo(loanDetails.getSummary().getPrincipalOutstanding()));
});
runAt("1 April 2025", () -> {
Long loanId = loanIdRef.get();
try {
// Attempt second disbursement after original maturity date
disburseLoan(loanId, BigDecimal.valueOf(312.69), "1 April 2025");
// If disbursement succeeds, verify the loan is active again
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
verifyLoanStatus(loanDetails, LoanStatus.ACTIVE);
// Verify second disbursement and automatic downpayment
verifyTransactions(loanId, transaction(487.58, "Disbursement", "14 March 2024"),
transaction(121.90, "Down Payment", "14 March 2024"),
transaction(201.39, "Merchant Issued Refund", "24 March 2024"),
transaction(286.19, "Merchant Issued Refund", "24 March 2024"),
transaction(121.90, "Credit Balance Refund", "25 March 2024"), transaction(312.69, "Disbursement", "01 April 2025"),
transaction(78.17, "Down Payment", "01 April 2025")); // 25% of 312.69
// Verify outstanding balance after second disbursement
BigDecimal expectedOutstanding = BigDecimal.valueOf(312.69).subtract(BigDecimal.valueOf(78.17));
assertEquals(0, expectedOutstanding.compareTo(loanDetails.getSummary().getPrincipalOutstanding()));
} catch (Exception e) {
log.error("Second disbursement failed after maturity date: {}", e.getMessage());
Assertions.fail("Second disbursement should be allowed after original maturity date: " + e.getMessage());
}
});
}
}