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 91c9c4e61fe66149f97f33af48af5eb92e49dbaa
Author: Attila Budai <[email protected]>
AuthorDate: Thu Aug 28 19:27:27 2025 +0200

    FINERACT-2308: fix duplicates for multiple tranches
---
 .../serialization/LoanApplicationValidator.java    |   7 --
 .../service/LoanDisbursementService.java           | 111 ++++++++++++++++++++-
 .../service/LoanReadPlatformServiceImpl.java       |  73 ++++++++++++--
 .../LoanApplicationApprovalTest.java               |  16 +--
 4 files changed, 172 insertions(+), 35 deletions(-)

diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java
 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java
index 3c7fd4c9d5..6ab9f9516f 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java
@@ -1696,7 +1696,6 @@ public final class LoanApplicationValidator {
             BigDecimal tatalDisbursement = BigDecimal.ZERO;
             final JsonArray variationArray = 
this.fromApiJsonHelper.extractJsonArrayNamed(LoanApiConstants.disbursementDataParameterName,
                     element);
-            List<LocalDate> expectedDisbursementDates = new ArrayList<>();
             if (variationArray != null && !variationArray.isEmpty()) {
                 if 
(this.fromApiJsonHelper.parameterExists(LoanApiConstants.isEqualAmortizationParam,
 element)) {
                     boolean isEqualAmortization = 
this.fromApiJsonHelper.extractBooleanNamed(LoanApiConstants.isEqualAmortizationParam,
@@ -1722,12 +1721,6 @@ public final class LoanApplicationValidator {
                                 
.failWithCode(LoanApiConstants.DISBURSEMENT_DATE_BEFORE_ERROR);
                     }
 
-                    if (expectedDisbursementDate != null && 
expectedDisbursementDates.contains(expectedDisbursementDate)) {
-                        
baseDataValidator.reset().parameter(LoanApiConstants.expectedDisbursementDateParameterName)
-                                
.failWithCode(LoanApiConstants.DISBURSEMENT_DATE_UNIQUE_ERROR);
-                    }
-                    expectedDisbursementDates.add(expectedDisbursementDate);
-
                     BigDecimal principal = this.fromApiJsonHelper
                             
.extractBigDecimalNamed(LoanApiConstants.disbursementPrincipalParameterName, 
jsonObject, locale);
                     
baseDataValidator.reset().parameter(LoanApiConstants.disbursementDataParameterName)
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java
 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java
index 3ada0cf9d1..2b19b13520 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java
@@ -28,12 +28,15 @@ import com.google.gson.JsonPrimitive;
 import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import 
org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer;
 import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -57,6 +60,7 @@ import 
org.apache.fineract.portfolio.loanaccount.serialization.LoanDisbursementV
 import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
 import org.springframework.lang.NonNull;
 
+@Slf4j
 @RequiredArgsConstructor
 public class LoanDisbursementService {
 
@@ -125,10 +129,13 @@ public class LoanDisbursementService {
             loan.setActualDisbursementDate(actualDisbursementDate);
         }
         BigDecimal diff = BigDecimal.ZERO;
-        final Collection<LoanDisbursementDetails> details = 
loan.fetchUndisbursedDetail();
+        final Collection<LoanDisbursementDetails> rawDetails = 
loan.fetchUndisbursedDetail();
+        final Collection<LoanDisbursementDetails> details = 
hasMultipleTranchesOnSameDateWithSameExpectedDate(rawDetails,
+                actualDisbursementDate) ? 
sortDisbursementDetailsByBusinessRules(rawDetails) : rawDetails;
         if (principalDisbursed == null) {
             disburseAmount = 
loan.getLoanRepaymentScheduleDetail().getPrincipal();
             if (!details.isEmpty()) {
+                // When no specific amount provided, disburse ALL undisbursed 
tranches for the date
                 disburseAmount = disburseAmount.zero();
                 for (LoanDisbursementDetails disbursementDetails : details) {
                     
disbursementDetails.updateActualDisbursementDate(actualDisbursementDate);
@@ -145,9 +152,45 @@ public class LoanDisbursementService {
             if (details.isEmpty()) {
                 diff = 
loan.getLoanRepaymentScheduleDetail().getPrincipal().minus(principalDisbursed).getAmount();
             } else {
-                for (LoanDisbursementDetails disbursementDetails : details) {
-                    
disbursementDetails.updateActualDisbursementDate(actualDisbursementDate);
-                    disbursementDetails.updatePrincipal(principalDisbursed);
+                // Check if this is a tranche-based loan (has multiple 
predefined disbursement details)
+                // versus a non-tranche multi-disbursal loan (creates 
disbursement details on-the-fly)
+                boolean isTrancheBasedLoan = 
hasMultipleOrPreDefinedDisbursementDetails(loan, details);
+
+                if (isTrancheBasedLoan && details.size() >= 1) {
+                    // For tranche-based loans, find the matching tranche by 
amount first, then by order
+                    LoanDisbursementDetails selectedTranche = null;
+
+                    // First try to find a tranche that exactly matches the 
requested disbursement amount
+                    for (LoanDisbursementDetails disbursementDetails : 
details) {
+                        if (disbursementDetails.actualDisbursementDate() == 
null
+                                && 
disbursementDetails.principal().compareTo(principalDisbursed) == 0) {
+                            selectedTranche = disbursementDetails;
+                            break;
+                        }
+                    }
+
+                    // If no exact match found, take the first available 
tranche (next in line)
+                    if (selectedTranche == null) {
+                        for (LoanDisbursementDetails disbursementDetails : 
details) {
+                            if (disbursementDetails.actualDisbursementDate() 
== null) {
+                                selectedTranche = disbursementDetails;
+                                break;
+                            }
+                        }
+                    }
+
+                    if (selectedTranche != null) {
+                        // Update the selected tranche with the actual 
disbursement
+                        
selectedTranche.updateActualDisbursementDate(actualDisbursementDate);
+                        selectedTranche.updatePrincipal(principalDisbursed);
+                    }
+                } else {
+                    // For non-tranche multi-disbursal loans: preserve 
original behavior
+                    // Update all available disbursement details with the 
actual disbursement date and amount
+                    for (LoanDisbursementDetails disbursementDetails : 
details) {
+                        
disbursementDetails.updateActualDisbursementDate(actualDisbursementDate);
+                        
disbursementDetails.updatePrincipal(principalDisbursed);
+                    }
                 }
             }
             BigDecimal totalAmount = BigDecimal.ZERO;
@@ -377,4 +420,64 @@ public class LoanDisbursementService {
         return returnObject;
     }
 
+    private boolean hasMultipleOrPreDefinedDisbursementDetails(final Loan loan,
+            final Collection<LoanDisbursementDetails> undisbursedDetails) {
+        Collection<LoanDisbursementDetails> allDisbursementDetails = 
loan.getDisbursementDetails();
+
+        if (undisbursedDetails.size() > 1) {
+            return true;
+        }
+
+        if (allDisbursementDetails.size() > 1 && 
!undisbursedDetails.isEmpty()) {
+            return true;
+        }
+
+        if (undisbursedDetails.size() == 1) {
+            LoanDisbursementDetails singleDetail = 
undisbursedDetails.iterator().next();
+            BigDecimal loanPrincipal = 
loan.getLoanRepaymentScheduleDetail().getPrincipal().getAmount();
+
+            if (singleDetail.principal().compareTo(loanPrincipal) == 0) {
+                return false;
+            }
+        }
+
+        // Default to tranche behavior for safety in ambiguous cases
+        return true;
+    }
+
+    public static List<LoanDisbursementDetails> 
sortDisbursementDetailsByBusinessRules(
+            Collection<LoanDisbursementDetails> disbursementDetails) {
+        if (disbursementDetails == null || disbursementDetails.isEmpty()) {
+            return List.of();
+        }
+
+        return disbursementDetails.stream()
+                
.sorted(Comparator.comparing(LoanDisbursementDetails::expectedDisbursementDate)
+                        .thenComparing((LoanDisbursementDetails d1, 
LoanDisbursementDetails d2) -> d2.principal().compareTo(d1.principal()))
+                        .thenComparing(LoanDisbursementDetails::getId))
+                .collect(Collectors.toList());
+    }
+
+    public static boolean 
hasMultipleTranchesOnSameDate(Collection<LoanDisbursementDetails> 
disbursementDetails) {
+        if (disbursementDetails == null || disbursementDetails.size() <= 1) {
+            return false;
+        }
+
+        return disbursementDetails.stream()
+                
.collect(Collectors.groupingBy(LoanDisbursementDetails::expectedDisbursementDate,
 Collectors.counting())).values().stream()
+                .anyMatch(count -> count > 1);
+    }
+
+    public static boolean 
hasMultipleTranchesOnSameDateWithSameExpectedDate(Collection<LoanDisbursementDetails>
 disbursementDetails,
+            LocalDate actualDisbursementDate) {
+        if (disbursementDetails == null || disbursementDetails.size() <= 1 || 
actualDisbursementDate == null) {
+            return false;
+        }
+
+        long tranchesForActualDate = disbursementDetails.stream()
+                .filter(detail -> 
actualDisbursementDate.equals(detail.expectedDisbursementDate())).count();
+
+        return tranchesForActualDate > 1;
+    }
+
 }
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java
 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java
index 4f4d1f1e37..5c31a4d997 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java
@@ -31,6 +31,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
@@ -1550,22 +1551,74 @@ public class LoanReadPlatformServiceImpl implements 
LoanReadPlatformService, Loa
         private List<LoanSchedulePeriodDataWrapper> 
collectEligibleDisbursementData(LoanScheduleType loanScheduleType,
                 Collection<DisbursementData> disbursementData, LocalDate 
fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds) {
             List<LoanSchedulePeriodDataWrapper> disbursementDataList = new 
ArrayList<>();
-            // Collect eligible disbursement data
-            for (final DisbursementData data : disbursementData) {
-                boolean isDueForDisbursement = 
data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
-                boolean isEligible = 
((fromDate.equals(this.disbursement.disbursementDate()) && 
data.disbursementDate().equals(fromDate))
-                        || (fromDate.equals(dueDate) && 
data.disbursementDate().equals(fromDate))
-                        || canAddDisbursementData(data, isDueForDisbursement, 
excludePastUnDisbursed))
-                        && !disbursementPeriodIds.contains(data.getId());
 
-                if (isEligible) {
-                    disbursementDataList.add(new 
LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
-                    disbursementPeriodIds.add(data.getId());
+            boolean hasMultipleTranchesOnSameDate = 
hasMultipleTranchesOnSameDate(disbursementData);
+
+            if (hasMultipleTranchesOnSameDate) {
+                Map<LocalDate, List<DisbursementData>> disbursementsByDate = 
new HashMap<>();
+
+                for (final DisbursementData data : disbursementData) {
+                    boolean isDueForDisbursement = 
data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
+                    boolean isEligible = 
((fromDate.equals(this.disbursement.disbursementDate())
+                            && data.disbursementDate().equals(fromDate))
+                            || (fromDate.equals(dueDate) && 
data.disbursementDate().equals(fromDate))
+                            || canAddDisbursementData(data, 
isDueForDisbursement, excludePastUnDisbursed))
+                            && !disbursementPeriodIds.contains(data.getId());
+
+                    if (isEligible) {
+                        
disbursementsByDate.computeIfAbsent(data.disbursementDate(), k -> new 
ArrayList<>()).add(data);
+                        disbursementPeriodIds.add(data.getId());
+                    }
+                }
+
+                for (Map.Entry<LocalDate, List<DisbursementData>> entry : 
disbursementsByDate.entrySet()) {
+                    List<DisbursementData> sameDateDisbursements = 
entry.getValue();
+
+                    if (sameDateDisbursements.size() > 1) {
+                        List<DisbursementData> disbursedTranches = 
sameDateDisbursements.stream().filter(DisbursementData::isDisbursed)
+                                .collect(Collectors.toList());
+
+                        if (!disbursedTranches.isEmpty()) {
+                            for (DisbursementData data : disbursedTranches) {
+                                disbursementDataList.add(new 
LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
+                            }
+                        } else {
+                            for (DisbursementData data : 
sameDateDisbursements) {
+                                disbursementDataList.add(new 
LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
+                            }
+                        }
+                    } else {
+                        DisbursementData data = sameDateDisbursements.get(0);
+                        disbursementDataList.add(new 
LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
+                    }
+                }
+            } else {
+                for (final DisbursementData data : disbursementData) {
+                    boolean isDueForDisbursement = 
data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
+                    boolean isEligible = 
((fromDate.equals(this.disbursement.disbursementDate())
+                            && data.disbursementDate().equals(fromDate))
+                            || (fromDate.equals(dueDate) && 
data.disbursementDate().equals(fromDate))
+                            || canAddDisbursementData(data, 
isDueForDisbursement, excludePastUnDisbursed))
+                            && !disbursementPeriodIds.contains(data.getId());
+
+                    if (isEligible) {
+                        disbursementDataList.add(new 
LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
+                        disbursementPeriodIds.add(data.getId());
+                    }
                 }
             }
+
             return disbursementDataList;
         }
 
+        private boolean 
hasMultipleTranchesOnSameDate(Collection<DisbursementData> disbursementData) {
+            if (disbursementData == null || disbursementData.size() <= 1) {
+                return false;
+            }
+            return 
disbursementData.stream().collect(Collectors.groupingBy(DisbursementData::disbursementDate,
 Collectors.counting()))
+                    .values().stream().anyMatch(count -> count > 1);
+        }
+
         private List<LoanSchedulePeriodDataWrapper> 
collectEligibleCapitalizedIncomeData(LocalDate fromDate, LocalDate dueDate,
                 Set<Long> disbursementPeriodIds) {
             List<LoanSchedulePeriodDataWrapper> capitalizedIncomeDataList = 
new ArrayList<>();
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanApplicationApprovalTest.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanApplicationApprovalTest.java
index b8035abeb4..190b241d2a 100644
--- 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanApplicationApprovalTest.java
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanApplicationApprovalTest.java
@@ -202,7 +202,6 @@ public class LoanApplicationApprovalTest {
     private void trancheLoansApprovalValidation(Integer clientID, Integer 
loanProductID, List<HashMap> createTranches) {
         final String proposedAmount = "5000";
         final String approvalAmount1 = "10000";
-        final String approvalAmount2 = "3000";
         final String approvalAmount3 = "400";
         final String approvalAmount4 = "200";
 
@@ -213,11 +212,6 @@ public class LoanApplicationApprovalTest {
         approveTranche1.add(createTrancheDetail("01 March 2014", "5000"));
         approveTranche1.add(createTrancheDetail("23 March 2014", "5000"));
 
-        List<HashMap> approveTranche2 = new ArrayList<>();
-        approveTranche2.add(createTrancheDetail("01 March 2014", "1000"));
-        approveTranche2.add(createTrancheDetail("23 March 2014", "1000"));
-        approveTranche2.add(createTrancheDetail("23 March 2014", "1000"));
-
         List<HashMap> approveTranche3 = new ArrayList<>();
         approveTranche3.add(createTrancheDetail("01 March 2014", "100"));
         approveTranche3.add(createTrancheDetail("23 March 2014", "100"));
@@ -238,15 +232,9 @@ public class LoanApplicationApprovalTest {
         log.info("-----------------------------------APPROVE 
LOAN-----------------------------------------------------------");
         this.loanTransactionHelper = new 
LoanTransactionHelper(this.requestSpec, this.responseSpecForStatusCode400);
 
-        /* Tranches with same expected disbursement date */
-        List<HashMap<String, Object>> error = 
this.loanTransactionHelper.approveLoanForTranches(approveDate, 
expectedDisbursementDate,
-                approvalAmount2, loanID, approveTranche2, 
CommonConstants.RESPONSE_ERROR);
-        
assertEquals("validation.msg.loan.expectedDisbursementDate.disbursement.date.must.be.unique.for.tranches",
-                error.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE));
-
         /* Sum of tranches is greater than approved amount */
-        error = this.loanTransactionHelper.approveLoanForTranches(approveDate, 
expectedDisbursementDate, approvalAmount4, loanID,
-                approveTranche4, CommonConstants.RESPONSE_ERROR);
+        List<HashMap<String, Object>> error = 
this.loanTransactionHelper.approveLoanForTranches(approveDate, 
expectedDisbursementDate,
+                approvalAmount4, loanID, approveTranche4, 
CommonConstants.RESPONSE_ERROR);
         
assertEquals("validation.msg.loan.principal.sum.of.multi.disburse.amounts.must.be.equal.to.or.lesser.than.approved.principal",
                 error.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE));
 

Reply via email to