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));
