FINERACT-2326: fix delinquent days & delinquency date after delinquency pause calculations

This commit is contained in:
Attila Budai
2025-10-22 13:17:32 +02:00
committed by Adam Saghy
parent 967dbed021
commit 13170dc551
10 changed files with 1061 additions and 74 deletions
@@ -28,4 +28,7 @@ public interface DelinquencyEffectivePauseHelper {
List<LoanDelinquencyActionData> calculateEffectiveDelinquencyList(List<LoanDelinquencyAction> savedDelinquencyActions);
Long getPausedDaysBeforeDate(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate date);
Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
LocalDate endExclusive);
}
@@ -66,6 +66,30 @@ public class DelinquencyEffectivePauseHelperImpl implements DelinquencyEffective
return Long.sum(pausedDaysClosedPausePeriods, pausedDaysRunningPausePeriods);
}
@Override
public Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
LocalDate endExclusive) {
if (startInclusive == null || endExclusive == null || !startInclusive.isBefore(endExclusive)) {
return 0L;
}
return effectiveDelinquencyList.stream().map(pausePeriod -> {
LocalDate pauseStart = pausePeriod.getStartDate();
LocalDate pauseEnd = Optional.ofNullable(pausePeriod.getEndDate()).orElse(endExclusive);
if (pauseStart == null || !pauseStart.isBefore(endExclusive)) {
return 0L;
}
if (!pauseEnd.isAfter(startInclusive)) {
return 0L;
}
LocalDate overlapStart = pauseStart.isAfter(startInclusive) ? pauseStart : startInclusive;
LocalDate overlapEnd = pauseEnd.isBefore(endExclusive) ? pauseEnd : endExclusive;
if (!overlapStart.isBefore(overlapEnd)) {
return 0L;
}
return DateUtils.getDifferenceInDays(overlapStart, overlapEnd);
}).reduce(0L, Long::sum);
}
private Optional<LoanDelinquencyAction> findMatchingResume(LoanDelinquencyAction pause, List<LoanDelinquencyAction> resumes) {
if (resumes != null && resumes.size() > 0) {
for (LoanDelinquencyAction resume : resumes) {
@@ -0,0 +1,87 @@
/**
* 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.portfolio.delinquency.helper;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData;
import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency;
/**
* Static utility class for aggregating installment-level delinquency data.
*
* @see InstallmentLevelDelinquency
* @see LoanInstallmentDelinquencyTagData
*/
public final class InstallmentDelinquencyAggregator {
private InstallmentDelinquencyAggregator() {}
/**
* Aggregates installment-level delinquency data by rangeId and sorts by minimumAgeDays.
*
* This method performs two key operations: 1. Groups installments by delinquency rangeId and sums delinquentAmount
* for installments with the same rangeId 2. Sorts the aggregated results by minimumAgeDays in ascending order
*
* @param installmentData
* Collection of installment delinquency data to aggregate
* @return Sorted list of aggregated delinquency data, empty list if input is null or empty
*/
public static List<InstallmentLevelDelinquency> aggregateAndSort(Collection<LoanInstallmentDelinquencyTagData> installmentData) {
if (installmentData == null || installmentData.isEmpty()) {
return List.of();
}
Collection<InstallmentLevelDelinquency> aggregated = installmentData.stream().map(InstallmentLevelDelinquency::from)
.collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values()
.stream().map(opt -> opt.orElseThrow(() -> new IllegalStateException("Unexpected empty Optional in aggregation"))).toList();
return aggregated.stream().sorted(Comparator.comparing(ild -> Optional.ofNullable(ild.getMinimumAgeDays()).orElse(0))).toList();
}
/**
* Creates a custom collector that sums delinquent amounts while preserving range metadata.
*
* This collector uses the reducing operation to combine multiple InstallmentLevelDelinquency objects with the same
* rangeId. It preserves the range classification (rangeId, classification, minimumAgeDays, maximumAgeDays) while
* summing the delinquentAmount fields.
*
* Note: This uses the 1-argument reducing() variant which returns Optional<T> to avoid the identity value bug that
* would cause amounts to be incorrectly doubled when aggregating single installments.
*
* @return Collector that combines InstallmentLevelDelinquency objects by summing amounts
*/
private static Collector<InstallmentLevelDelinquency, ?, Optional<InstallmentLevelDelinquency>> delinquentAmountSummingCollector() {
return Collectors.reducing((item1, item2) -> {
final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency();
result.setRangeId(item1.getRangeId());
result.setClassification(item1.getClassification());
result.setMaximumAgeDays(item1.getMaximumAgeDays());
result.setMinimumAgeDays(item1.getMinimumAgeDays());
result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount()));
return result;
});
}
}
@@ -27,8 +27,6 @@ import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -49,6 +47,7 @@ import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistor
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
import org.apache.fineract.portfolio.delinquency.helper.InstallmentDelinquencyAggregator;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
@@ -260,36 +259,12 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag(
loanId);
if (loanInstallmentDelinquencyTagData != null && !loanInstallmentDelinquencyTagData.isEmpty()) {
// installment level delinquency grouped by rangeId, and summed up the delinquent amount
Collection<InstallmentLevelDelinquency> installmentLevelDelinquencies = loanInstallmentDelinquencyTagData.stream()
.map(InstallmentLevelDelinquency::from)
.collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values();
// sort this based on minimum days, so ranges will be delivered in ascending order
List<InstallmentLevelDelinquency> sorted = installmentLevelDelinquencies.stream().sorted((o1, o2) -> {
Integer first = Optional.ofNullable(o1.getMinimumAgeDays()).orElse(0);
Integer second = Optional.ofNullable(o2.getMinimumAgeDays()).orElse(0);
return first.compareTo(second);
}).toList();
collectionData.setInstallmentLevelDelinquency(sorted);
List<InstallmentLevelDelinquency> aggregated = InstallmentDelinquencyAggregator
.aggregateAndSort(loanInstallmentDelinquencyTagData);
collectionData.setInstallmentLevelDelinquency(aggregated);
}
}
@NonNull
private static Collector<InstallmentLevelDelinquency, ?, InstallmentLevelDelinquency> delinquentAmountSummingCollector() {
return Collectors.reducing(new InstallmentLevelDelinquency(), (item1, item2) -> {
final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency();
result.setRangeId(Optional.ofNullable(item1.getRangeId()).orElse(item2.getRangeId()));
result.setClassification(Optional.ofNullable(item1.getClassification()).orElse(item2.getClassification()));
result.setMaximumAgeDays(Optional.ofNullable(item1.getMaximumAgeDays()).orElse(item2.getMaximumAgeDays()));
result.setMinimumAgeDays(Optional.ofNullable(item1.getMinimumAgeDays()).orElse(item2.getMinimumAgeDays()));
result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount()));
return result;
});
}
void enrichWithDelinquencyPausePeriodInfo(CollectionData collectionData, Collection<LoanDelinquencyActionData> effectiveDelinquencyList,
LocalDate businessDate) {
List<DelinquencyPausePeriod> result = effectiveDelinquencyList.stream() //
@@ -119,14 +119,15 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
long overdueDays = 0L;
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
if (overdueSinceDate != null) {
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
if (overdueDays < 0) {
overdueDays = 0L;
}
collectionData.setPastDueDays(overdueDays);
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(overdueSinceDate);
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(delinquentStartDate);
}
collectionData.setDelinquentAmount(outstandingAmount);
collectionData.setDelinquentPrincipal(delinquentPrincipal);
@@ -134,11 +135,8 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
collectionData.setDelinquentFee(delinquentFee);
collectionData.setDelinquentPenalty(delinquentPenalty);
collectionData.setDelinquentDays(0L);
final long delinquentDays = overdueDays - graceDays;
if (delinquentDays > 0) {
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
}
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
overdueSinceDateForCalculation);
log.debug("Result: {}", collectionData);
return collectionData;
@@ -200,31 +198,22 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
long overdueDays = 0L;
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
if (overdueSinceDate != null) {
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
if (overdueDays < 0) {
overdueDays = 0L;
}
collectionData.setPastDueDays(overdueDays);
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(overdueSinceDate);
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(delinquentStartDate);
}
collectionData.setDelinquentAmount(outstandingAmount);
collectionData.setDelinquentDays(0L);
final long delinquentDays = overdueDays - graceDays;
if (delinquentDays > 0) {
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
}
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
overdueSinceDateForCalculation);
return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData);
}
private void calculateDelinquentDays(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate businessDate,
CollectionData collectionData, Long delinquentDays) {
Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate);
Long calculatedDelinquentDays = delinquentDays - pausedDays;
collectionData.setDelinquentDays(calculatedDelinquentDays > 0 ? calculatedDelinquentDays : 0L);
}
private CollectionData getInstallmentOverdueCollectionData(final Loan loan, final LoanRepaymentScheduleInstallment installment,
final List<LoanDelinquencyActionData> effectiveDelinquencyList, final List<LoanTransaction> chargebackTransactions) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
@@ -248,6 +237,7 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
// Grace days are not considered for installment level delinquency calculation currently.
long overdueDays = 0L;
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
if (overdueSinceDate != null) {
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
if (overdueDays < 0) {
@@ -257,11 +247,8 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
collectionData.setDelinquentDate(overdueSinceDate);
}
collectionData.setDelinquentAmount(outstandingAmount);
collectionData.setDelinquentDays(0L);
final long delinquentDays = overdueDays;
if (delinquentDays > 0) {
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
}
calculateAndSetDelinquentDays(collectionData, overdueDays, 0, effectiveDelinquencyList, businessDate,
overdueSinceDateForCalculation);
return collectionData;
}
@@ -356,4 +343,18 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
return collectionData;
}
private void calculateAndSetDelinquentDays(CollectionData collectionData, long overdueDays, Integer graceDays,
List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate businessDate, LocalDate overdueSinceDate) {
collectionData.setDelinquentDays(0L);
if (overdueDays > 0) {
Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysWithinRange(effectiveDelinquencyList, overdueSinceDate,
businessDate);
if (pausedDays == null) {
pausedDays = 0L;
}
final long delinquentDays = overdueDays - pausedDays - graceDays;
collectionData.setDelinquentDays(delinquentDays > 0 ? delinquentDays : 0L);
}
}
}