Skip to content

Commit 13170dc

Browse files
budaidevadamsaghy
authored andcommitted
FINERACT-2326: fix delinquent days & delinquency date after delinquency pause calculations
1 parent 967dbed commit 13170dc

File tree

10 files changed

+1061
-74
lines changed

10 files changed

+1061
-74
lines changed

fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,7 @@ Feature: LoanDelinquency
610610
# --- Grace period applied only on Loan level, not on installment level ---
611611
Then Loan has the following INSTALLMENT level delinquency data:
612612
| rangeId | Range | Amount |
613+
| 1 | RANGE_1 | 250.00 |
613614
| 2 | RANGE_3 | 250.00 |
614615

615616
@TestRailId:C3000
@@ -728,8 +729,7 @@ Feature: LoanDelinquency
728729
| RANGE_3 | 750.0 | 04 October 2023 | 30 | 43 |
729730
Then Loan has the following INSTALLMENT level delinquency data:
730731
| rangeId | Range | Amount |
731-
| 1 | RANGE_1 | 250.00 |
732-
| 2 | RANGE_3 | 250.00 |
732+
| 2 | RANGE_3 | 500.00 |
733733
| 3 | RANGE_30 | 250.00 |
734734
# --- Second delinquency pause ---
735735
When Admin sets the business date to "14 November 2023"
@@ -749,8 +749,7 @@ Feature: LoanDelinquency
749749
| RANGE_3 | 750.0 | 04 October 2023 | 31 | 44 |
750750
Then Loan has the following INSTALLMENT level delinquency data:
751751
| rangeId | Range | Amount |
752-
| 1 | RANGE_1 | 250.00 |
753-
| 2 | RANGE_3 | 250.00 |
752+
| 2 | RANGE_3 | 500.00 |
754753
| 3 | RANGE_30 | 250.00 |
755754
Then Installment level delinquency event has correct data
756755
# --- Second delinquency ends ---
@@ -770,8 +769,7 @@ Feature: LoanDelinquency
770769
| RANGE_3 | 1000.0 | 04 October 2023 | 31 | 60 |
771770
Then Loan has the following INSTALLMENT level delinquency data:
772771
| rangeId | Range | Amount |
773-
| 1 | RANGE_1 | 250.00 |
774-
| 2 | RANGE_3 | 250.00 |
772+
| 2 | RANGE_3 | 500.00 |
775773
| 3 | RANGE_30 | 250.00 |
776774
# --- Delinquency runs again ---
777775
When Admin sets the business date to "01 December 2023"
@@ -790,6 +788,7 @@ Feature: LoanDelinquency
790788
| RANGE_30 | 1000.0 | 04 October 2023 | 32 | 61 |
791789
Then Loan has the following INSTALLMENT level delinquency data:
792790
| rangeId | Range | Amount |
791+
| 1 | RANGE_1 | 250.00 |
793792
| 2 | RANGE_3 | 500.00 |
794793
| 3 | RANGE_30 | 250.00 |
795794
Then Installment level delinquency event has correct data
@@ -995,11 +994,11 @@ Feature: LoanDelinquency
995994
| RESUME | 25 October 2023 | |
996995
Then Loan has the following LOAN level delinquency data:
997996
| classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays |
998-
| RANGE_3 | 500.0 | 19 October 2023 | 8 | 30 |
997+
| RANGE_3 | 500.0 | 19 October 2023 | 18 | 30 |
999998
# --- Grace period applied only on Loan level, not on installment level ---
1000999
Then Loan has the following INSTALLMENT level delinquency data:
10011000
| rangeId | Range | Amount |
1002-
| 2 | RANGE_3 | 250.00 |
1001+
| 2 | RANGE_3 | 500.00 |
10031002
Then Installment level delinquency event has correct data
10041003

10051004
@TestRailId:C3013

fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ public interface DelinquencyEffectivePauseHelper {
2828
List<LoanDelinquencyActionData> calculateEffectiveDelinquencyList(List<LoanDelinquencyAction> savedDelinquencyActions);
2929

3030
Long getPausedDaysBeforeDate(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate date);
31+
32+
Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
33+
LocalDate endExclusive);
3134
}

fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ public Long getPausedDaysBeforeDate(List<LoanDelinquencyActionData> effectiveDel
6666
return Long.sum(pausedDaysClosedPausePeriods, pausedDaysRunningPausePeriods);
6767
}
6868

69+
@Override
70+
public Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
71+
LocalDate endExclusive) {
72+
if (startInclusive == null || endExclusive == null || !startInclusive.isBefore(endExclusive)) {
73+
return 0L;
74+
}
75+
return effectiveDelinquencyList.stream().map(pausePeriod -> {
76+
LocalDate pauseStart = pausePeriod.getStartDate();
77+
LocalDate pauseEnd = Optional.ofNullable(pausePeriod.getEndDate()).orElse(endExclusive);
78+
if (pauseStart == null || !pauseStart.isBefore(endExclusive)) {
79+
return 0L;
80+
}
81+
if (!pauseEnd.isAfter(startInclusive)) {
82+
return 0L;
83+
}
84+
LocalDate overlapStart = pauseStart.isAfter(startInclusive) ? pauseStart : startInclusive;
85+
LocalDate overlapEnd = pauseEnd.isBefore(endExclusive) ? pauseEnd : endExclusive;
86+
if (!overlapStart.isBefore(overlapEnd)) {
87+
return 0L;
88+
}
89+
return DateUtils.getDifferenceInDays(overlapStart, overlapEnd);
90+
}).reduce(0L, Long::sum);
91+
}
92+
6993
private Optional<LoanDelinquencyAction> findMatchingResume(LoanDelinquencyAction pause, List<LoanDelinquencyAction> resumes) {
7094
if (resumes != null && resumes.size() > 0) {
7195
for (LoanDelinquencyAction resume : resumes) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.delinquency.helper;
20+
21+
import java.util.Collection;
22+
import java.util.Comparator;
23+
import java.util.List;
24+
import java.util.Optional;
25+
import java.util.stream.Collector;
26+
import java.util.stream.Collectors;
27+
import org.apache.fineract.infrastructure.core.service.MathUtil;
28+
import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData;
29+
import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency;
30+
31+
/**
32+
* Static utility class for aggregating installment-level delinquency data.
33+
*
34+
* @see InstallmentLevelDelinquency
35+
* @see LoanInstallmentDelinquencyTagData
36+
*/
37+
public final class InstallmentDelinquencyAggregator {
38+
39+
private InstallmentDelinquencyAggregator() {}
40+
41+
/**
42+
* Aggregates installment-level delinquency data by rangeId and sorts by minimumAgeDays.
43+
*
44+
* This method performs two key operations: 1. Groups installments by delinquency rangeId and sums delinquentAmount
45+
* for installments with the same rangeId 2. Sorts the aggregated results by minimumAgeDays in ascending order
46+
*
47+
* @param installmentData
48+
* Collection of installment delinquency data to aggregate
49+
* @return Sorted list of aggregated delinquency data, empty list if input is null or empty
50+
*/
51+
public static List<InstallmentLevelDelinquency> aggregateAndSort(Collection<LoanInstallmentDelinquencyTagData> installmentData) {
52+
53+
if (installmentData == null || installmentData.isEmpty()) {
54+
return List.of();
55+
}
56+
57+
Collection<InstallmentLevelDelinquency> aggregated = installmentData.stream().map(InstallmentLevelDelinquency::from)
58+
.collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values()
59+
.stream().map(opt -> opt.orElseThrow(() -> new IllegalStateException("Unexpected empty Optional in aggregation"))).toList();
60+
61+
return aggregated.stream().sorted(Comparator.comparing(ild -> Optional.ofNullable(ild.getMinimumAgeDays()).orElse(0))).toList();
62+
}
63+
64+
/**
65+
* Creates a custom collector that sums delinquent amounts while preserving range metadata.
66+
*
67+
* This collector uses the reducing operation to combine multiple InstallmentLevelDelinquency objects with the same
68+
* rangeId. It preserves the range classification (rangeId, classification, minimumAgeDays, maximumAgeDays) while
69+
* summing the delinquentAmount fields.
70+
*
71+
* Note: This uses the 1-argument reducing() variant which returns Optional<T> to avoid the identity value bug that
72+
* would cause amounts to be incorrectly doubled when aggregating single installments.
73+
*
74+
* @return Collector that combines InstallmentLevelDelinquency objects by summing amounts
75+
*/
76+
private static Collector<InstallmentLevelDelinquency, ?, Optional<InstallmentLevelDelinquency>> delinquentAmountSummingCollector() {
77+
return Collectors.reducing((item1, item2) -> {
78+
final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency();
79+
result.setRangeId(item1.getRangeId());
80+
result.setClassification(item1.getClassification());
81+
result.setMaximumAgeDays(item1.getMaximumAgeDays());
82+
result.setMinimumAgeDays(item1.getMinimumAgeDays());
83+
result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount()));
84+
return result;
85+
});
86+
}
87+
}

fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@
2727
import java.util.Comparator;
2828
import java.util.List;
2929
import java.util.Optional;
30-
import java.util.stream.Collector;
31-
import java.util.stream.Collectors;
3230
import lombok.RequiredArgsConstructor;
3331
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
3432
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -49,6 +47,7 @@
4947
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
5048
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
5149
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
50+
import org.apache.fineract.portfolio.delinquency.helper.InstallmentDelinquencyAggregator;
5251
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper;
5352
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
5453
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
@@ -260,36 +259,12 @@ private void addInstallmentLevelDelinquencyData(CollectionData collectionData, L
260259
Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag(
261260
loanId);
262261
if (loanInstallmentDelinquencyTagData != null && !loanInstallmentDelinquencyTagData.isEmpty()) {
263-
264-
// installment level delinquency grouped by rangeId, and summed up the delinquent amount
265-
Collection<InstallmentLevelDelinquency> installmentLevelDelinquencies = loanInstallmentDelinquencyTagData.stream()
266-
.map(InstallmentLevelDelinquency::from)
267-
.collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values();
268-
269-
// sort this based on minimum days, so ranges will be delivered in ascending order
270-
List<InstallmentLevelDelinquency> sorted = installmentLevelDelinquencies.stream().sorted((o1, o2) -> {
271-
Integer first = Optional.ofNullable(o1.getMinimumAgeDays()).orElse(0);
272-
Integer second = Optional.ofNullable(o2.getMinimumAgeDays()).orElse(0);
273-
return first.compareTo(second);
274-
}).toList();
275-
276-
collectionData.setInstallmentLevelDelinquency(sorted);
262+
List<InstallmentLevelDelinquency> aggregated = InstallmentDelinquencyAggregator
263+
.aggregateAndSort(loanInstallmentDelinquencyTagData);
264+
collectionData.setInstallmentLevelDelinquency(aggregated);
277265
}
278266
}
279267

280-
@NonNull
281-
private static Collector<InstallmentLevelDelinquency, ?, InstallmentLevelDelinquency> delinquentAmountSummingCollector() {
282-
return Collectors.reducing(new InstallmentLevelDelinquency(), (item1, item2) -> {
283-
final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency();
284-
result.setRangeId(Optional.ofNullable(item1.getRangeId()).orElse(item2.getRangeId()));
285-
result.setClassification(Optional.ofNullable(item1.getClassification()).orElse(item2.getClassification()));
286-
result.setMaximumAgeDays(Optional.ofNullable(item1.getMaximumAgeDays()).orElse(item2.getMaximumAgeDays()));
287-
result.setMinimumAgeDays(Optional.ofNullable(item1.getMinimumAgeDays()).orElse(item2.getMinimumAgeDays()));
288-
result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount()));
289-
return result;
290-
});
291-
}
292-
293268
void enrichWithDelinquencyPausePeriodInfo(CollectionData collectionData, Collection<LoanDelinquencyActionData> effectiveDelinquencyList,
294269
LocalDate businessDate) {
295270
List<DelinquencyPausePeriod> result = effectiveDelinquencyList.stream() //

fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -119,26 +119,24 @@ public CollectionData getOverdueCollectionData(final Loan loan, final List<LoanD
119119
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
120120

121121
long overdueDays = 0L;
122+
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
122123
if (overdueSinceDate != null) {
123124
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
124125
if (overdueDays < 0) {
125126
overdueDays = 0L;
126127
}
127128
collectionData.setPastDueDays(overdueDays);
128-
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
129-
collectionData.setDelinquentDate(overdueSinceDate);
129+
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
130+
collectionData.setDelinquentDate(delinquentStartDate);
130131
}
131132
collectionData.setDelinquentAmount(outstandingAmount);
132133
collectionData.setDelinquentPrincipal(delinquentPrincipal);
133134
collectionData.setDelinquentInterest(delinquentInterest);
134135
collectionData.setDelinquentFee(delinquentFee);
135136
collectionData.setDelinquentPenalty(delinquentPenalty);
136137

137-
collectionData.setDelinquentDays(0L);
138-
final long delinquentDays = overdueDays - graceDays;
139-
if (delinquentDays > 0) {
140-
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
141-
}
138+
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
139+
overdueSinceDateForCalculation);
142140

143141
log.debug("Result: {}", collectionData);
144142
return collectionData;
@@ -200,31 +198,22 @@ public LoanDelinquencyData getLoanDelinquencyData(final Loan loan, List<LoanDeli
200198
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
201199

202200
long overdueDays = 0L;
201+
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
203202
if (overdueSinceDate != null) {
204203
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
205204
if (overdueDays < 0) {
206205
overdueDays = 0L;
207206
}
208207
collectionData.setPastDueDays(overdueDays);
209-
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
210-
collectionData.setDelinquentDate(overdueSinceDate);
208+
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
209+
collectionData.setDelinquentDate(delinquentStartDate);
211210
}
212211
collectionData.setDelinquentAmount(outstandingAmount);
213-
collectionData.setDelinquentDays(0L);
214-
final long delinquentDays = overdueDays - graceDays;
215-
if (delinquentDays > 0) {
216-
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
217-
}
212+
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
213+
overdueSinceDateForCalculation);
218214
return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData);
219215
}
220216

221-
private void calculateDelinquentDays(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate businessDate,
222-
CollectionData collectionData, Long delinquentDays) {
223-
Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate);
224-
Long calculatedDelinquentDays = delinquentDays - pausedDays;
225-
collectionData.setDelinquentDays(calculatedDelinquentDays > 0 ? calculatedDelinquentDays : 0L);
226-
}
227-
228217
private CollectionData getInstallmentOverdueCollectionData(final Loan loan, final LoanRepaymentScheduleInstallment installment,
229218
final List<LoanDelinquencyActionData> effectiveDelinquencyList, final List<LoanTransaction> chargebackTransactions) {
230219
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
@@ -248,6 +237,7 @@ private CollectionData getInstallmentOverdueCollectionData(final Loan loan, fina
248237

249238
// Grace days are not considered for installment level delinquency calculation currently.
250239
long overdueDays = 0L;
240+
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
251241
if (overdueSinceDate != null) {
252242
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
253243
if (overdueDays < 0) {
@@ -257,11 +247,8 @@ private CollectionData getInstallmentOverdueCollectionData(final Loan loan, fina
257247
collectionData.setDelinquentDate(overdueSinceDate);
258248
}
259249
collectionData.setDelinquentAmount(outstandingAmount);
260-
collectionData.setDelinquentDays(0L);
261-
final long delinquentDays = overdueDays;
262-
if (delinquentDays > 0) {
263-
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
264-
}
250+
calculateAndSetDelinquentDays(collectionData, overdueDays, 0, effectiveDelinquencyList, businessDate,
251+
overdueSinceDateForCalculation);
265252
return collectionData;
266253

267254
}
@@ -356,4 +343,18 @@ private CollectionData calculateDelinquencyDataForNonOverdueInstallment(final Lo
356343
return collectionData;
357344
}
358345

346+
private void calculateAndSetDelinquentDays(CollectionData collectionData, long overdueDays, Integer graceDays,
347+
List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate businessDate, LocalDate overdueSinceDate) {
348+
collectionData.setDelinquentDays(0L);
349+
if (overdueDays > 0) {
350+
Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysWithinRange(effectiveDelinquencyList, overdueSinceDate,
351+
businessDate);
352+
if (pausedDays == null) {
353+
pausedDays = 0L;
354+
}
355+
final long delinquentDays = overdueDays - pausedDays - graceDays;
356+
collectionData.setDelinquentDays(delinquentDays > 0 ? delinquentDays : 0L);
357+
}
358+
}
359+
359360
}

0 commit comments

Comments
 (0)