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());
+            }
+        });
+    }
+}

Reply via email to