FINERACT-2211: Second disbursement error fix
This commit is contained in:
+53
@@ -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);
|
||||
|
||||
|
||||
+66
@@ -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);
|
||||
|
||||
+149
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user