FINERACT-2308: fix duplicates for multiple tranches

This commit is contained in:
Attila Budai
2025-09-09 15:32:13 +02:00
committed by Adam Saghy
parent 467d82846d
commit 91c9c4e61f
4 changed files with 172 additions and 35 deletions
@@ -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)
@@ -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;
}
}
@@ -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<>();
@@ -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));