This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit 56e89ba54449912ffae2c026f6d6e694cda52c2b Author: budaidev <[email protected]> AuthorDate: Mon Jun 2 21:42:45 2025 +0200 FINERACT-2211: Second disbursement error fix --- ...dvancedPaymentScheduleTransactionProcessor.java | 53 ++++++++ ...cedPaymentScheduleTransactionProcessorTest.java | 66 +++++++++ ...ogressiveLoanDisbursementAfterMaturityTest.java | 149 +++++++++++++++++++++ 3 files changed, 268 insertions(+) diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 9ffe5c0a4e..4f6818c5b4 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -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); diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java index 588cc76434..9cde84ea00 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java @@ -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); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanDisbursementAfterMaturityTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanDisbursementAfterMaturityTest.java new file mode 100644 index 0000000000..886cb8eb81 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanDisbursementAfterMaturityTest.java @@ -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()); + } + }); + } +}
