FINERACT-2326: fix delinquent days & delinquency date after delinquency pause calculations
This commit is contained in:
@@ -610,6 +610,7 @@ Feature: LoanDelinquency
|
|||||||
# --- Grace period applied only on Loan level, not on installment level ---
|
# --- Grace period applied only on Loan level, not on installment level ---
|
||||||
Then Loan has the following INSTALLMENT level delinquency data:
|
Then Loan has the following INSTALLMENT level delinquency data:
|
||||||
| rangeId | Range | Amount |
|
| rangeId | Range | Amount |
|
||||||
|
| 1 | RANGE_1 | 250.00 |
|
||||||
| 2 | RANGE_3 | 250.00 |
|
| 2 | RANGE_3 | 250.00 |
|
||||||
|
|
||||||
@TestRailId:C3000
|
@TestRailId:C3000
|
||||||
@@ -728,8 +729,7 @@ Feature: LoanDelinquency
|
|||||||
| RANGE_3 | 750.0 | 04 October 2023 | 30 | 43 |
|
| RANGE_3 | 750.0 | 04 October 2023 | 30 | 43 |
|
||||||
Then Loan has the following INSTALLMENT level delinquency data:
|
Then Loan has the following INSTALLMENT level delinquency data:
|
||||||
| rangeId | Range | Amount |
|
| rangeId | Range | Amount |
|
||||||
| 1 | RANGE_1 | 250.00 |
|
| 2 | RANGE_3 | 500.00 |
|
||||||
| 2 | RANGE_3 | 250.00 |
|
|
||||||
| 3 | RANGE_30 | 250.00 |
|
| 3 | RANGE_30 | 250.00 |
|
||||||
# --- Second delinquency pause ---
|
# --- Second delinquency pause ---
|
||||||
When Admin sets the business date to "14 November 2023"
|
When Admin sets the business date to "14 November 2023"
|
||||||
@@ -749,8 +749,7 @@ Feature: LoanDelinquency
|
|||||||
| RANGE_3 | 750.0 | 04 October 2023 | 31 | 44 |
|
| RANGE_3 | 750.0 | 04 October 2023 | 31 | 44 |
|
||||||
Then Loan has the following INSTALLMENT level delinquency data:
|
Then Loan has the following INSTALLMENT level delinquency data:
|
||||||
| rangeId | Range | Amount |
|
| rangeId | Range | Amount |
|
||||||
| 1 | RANGE_1 | 250.00 |
|
| 2 | RANGE_3 | 500.00 |
|
||||||
| 2 | RANGE_3 | 250.00 |
|
|
||||||
| 3 | RANGE_30 | 250.00 |
|
| 3 | RANGE_30 | 250.00 |
|
||||||
Then Installment level delinquency event has correct data
|
Then Installment level delinquency event has correct data
|
||||||
# --- Second delinquency ends ---
|
# --- Second delinquency ends ---
|
||||||
@@ -770,8 +769,7 @@ Feature: LoanDelinquency
|
|||||||
| RANGE_3 | 1000.0 | 04 October 2023 | 31 | 60 |
|
| RANGE_3 | 1000.0 | 04 October 2023 | 31 | 60 |
|
||||||
Then Loan has the following INSTALLMENT level delinquency data:
|
Then Loan has the following INSTALLMENT level delinquency data:
|
||||||
| rangeId | Range | Amount |
|
| rangeId | Range | Amount |
|
||||||
| 1 | RANGE_1 | 250.00 |
|
| 2 | RANGE_3 | 500.00 |
|
||||||
| 2 | RANGE_3 | 250.00 |
|
|
||||||
| 3 | RANGE_30 | 250.00 |
|
| 3 | RANGE_30 | 250.00 |
|
||||||
# --- Delinquency runs again ---
|
# --- Delinquency runs again ---
|
||||||
When Admin sets the business date to "01 December 2023"
|
When Admin sets the business date to "01 December 2023"
|
||||||
@@ -790,6 +788,7 @@ Feature: LoanDelinquency
|
|||||||
| RANGE_30 | 1000.0 | 04 October 2023 | 32 | 61 |
|
| RANGE_30 | 1000.0 | 04 October 2023 | 32 | 61 |
|
||||||
Then Loan has the following INSTALLMENT level delinquency data:
|
Then Loan has the following INSTALLMENT level delinquency data:
|
||||||
| rangeId | Range | Amount |
|
| rangeId | Range | Amount |
|
||||||
|
| 1 | RANGE_1 | 250.00 |
|
||||||
| 2 | RANGE_3 | 500.00 |
|
| 2 | RANGE_3 | 500.00 |
|
||||||
| 3 | RANGE_30 | 250.00 |
|
| 3 | RANGE_30 | 250.00 |
|
||||||
Then Installment level delinquency event has correct data
|
Then Installment level delinquency event has correct data
|
||||||
@@ -995,11 +994,11 @@ Feature: LoanDelinquency
|
|||||||
| RESUME | 25 October 2023 | |
|
| RESUME | 25 October 2023 | |
|
||||||
Then Loan has the following LOAN level delinquency data:
|
Then Loan has the following LOAN level delinquency data:
|
||||||
| classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays |
|
| classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays |
|
||||||
| RANGE_3 | 500.0 | 19 October 2023 | 8 | 30 |
|
| RANGE_3 | 500.0 | 19 October 2023 | 18 | 30 |
|
||||||
# --- Grace period applied only on Loan level, not on installment level ---
|
# --- Grace period applied only on Loan level, not on installment level ---
|
||||||
Then Loan has the following INSTALLMENT level delinquency data:
|
Then Loan has the following INSTALLMENT level delinquency data:
|
||||||
| rangeId | Range | Amount |
|
| rangeId | Range | Amount |
|
||||||
| 2 | RANGE_3 | 250.00 |
|
| 2 | RANGE_3 | 500.00 |
|
||||||
Then Installment level delinquency event has correct data
|
Then Installment level delinquency event has correct data
|
||||||
|
|
||||||
@TestRailId:C3013
|
@TestRailId:C3013
|
||||||
|
|||||||
+3
@@ -28,4 +28,7 @@ public interface DelinquencyEffectivePauseHelper {
|
|||||||
List<LoanDelinquencyActionData> calculateEffectiveDelinquencyList(List<LoanDelinquencyAction> savedDelinquencyActions);
|
List<LoanDelinquencyActionData> calculateEffectiveDelinquencyList(List<LoanDelinquencyAction> savedDelinquencyActions);
|
||||||
|
|
||||||
Long getPausedDaysBeforeDate(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate date);
|
Long getPausedDaysBeforeDate(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate date);
|
||||||
|
|
||||||
|
Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
|
||||||
|
LocalDate endExclusive);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -66,6 +66,30 @@ public class DelinquencyEffectivePauseHelperImpl implements DelinquencyEffective
|
|||||||
return Long.sum(pausedDaysClosedPausePeriods, pausedDaysRunningPausePeriods);
|
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) {
|
private Optional<LoanDelinquencyAction> findMatchingResume(LoanDelinquencyAction pause, List<LoanDelinquencyAction> resumes) {
|
||||||
if (resumes != null && resumes.size() > 0) {
|
if (resumes != null && resumes.size() > 0) {
|
||||||
for (LoanDelinquencyAction resume : resumes) {
|
for (LoanDelinquencyAction resume : resumes) {
|
||||||
|
|||||||
+87
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-29
@@ -27,8 +27,6 @@ import java.util.Collection;
|
|||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collector;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
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.LoanDelinquencyTagHistoryRepository;
|
||||||
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
|
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
|
||||||
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
|
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.DelinquencyBucketMapper;
|
||||||
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
|
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
|
||||||
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
|
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
|
||||||
@@ -260,36 +259,12 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
|
|||||||
Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag(
|
Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag(
|
||||||
loanId);
|
loanId);
|
||||||
if (loanInstallmentDelinquencyTagData != null && !loanInstallmentDelinquencyTagData.isEmpty()) {
|
if (loanInstallmentDelinquencyTagData != null && !loanInstallmentDelinquencyTagData.isEmpty()) {
|
||||||
|
List<InstallmentLevelDelinquency> aggregated = InstallmentDelinquencyAggregator
|
||||||
// installment level delinquency grouped by rangeId, and summed up the delinquent amount
|
.aggregateAndSort(loanInstallmentDelinquencyTagData);
|
||||||
Collection<InstallmentLevelDelinquency> installmentLevelDelinquencies = loanInstallmentDelinquencyTagData.stream()
|
collectionData.setInstallmentLevelDelinquency(aggregated);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,
|
void enrichWithDelinquencyPausePeriodInfo(CollectionData collectionData, Collection<LoanDelinquencyActionData> effectiveDelinquencyList,
|
||||||
LocalDate businessDate) {
|
LocalDate businessDate) {
|
||||||
List<DelinquencyPausePeriod> result = effectiveDelinquencyList.stream() //
|
List<DelinquencyPausePeriod> result = effectiveDelinquencyList.stream() //
|
||||||
|
|||||||
+27
-26
@@ -119,14 +119,15 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
|
|||||||
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
|
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
|
||||||
|
|
||||||
long overdueDays = 0L;
|
long overdueDays = 0L;
|
||||||
|
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
|
||||||
if (overdueSinceDate != null) {
|
if (overdueSinceDate != null) {
|
||||||
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
|
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
|
||||||
if (overdueDays < 0) {
|
if (overdueDays < 0) {
|
||||||
overdueDays = 0L;
|
overdueDays = 0L;
|
||||||
}
|
}
|
||||||
collectionData.setPastDueDays(overdueDays);
|
collectionData.setPastDueDays(overdueDays);
|
||||||
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
|
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
|
||||||
collectionData.setDelinquentDate(overdueSinceDate);
|
collectionData.setDelinquentDate(delinquentStartDate);
|
||||||
}
|
}
|
||||||
collectionData.setDelinquentAmount(outstandingAmount);
|
collectionData.setDelinquentAmount(outstandingAmount);
|
||||||
collectionData.setDelinquentPrincipal(delinquentPrincipal);
|
collectionData.setDelinquentPrincipal(delinquentPrincipal);
|
||||||
@@ -134,11 +135,8 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
|
|||||||
collectionData.setDelinquentFee(delinquentFee);
|
collectionData.setDelinquentFee(delinquentFee);
|
||||||
collectionData.setDelinquentPenalty(delinquentPenalty);
|
collectionData.setDelinquentPenalty(delinquentPenalty);
|
||||||
|
|
||||||
collectionData.setDelinquentDays(0L);
|
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
|
||||||
final long delinquentDays = overdueDays - graceDays;
|
overdueSinceDateForCalculation);
|
||||||
if (delinquentDays > 0) {
|
|
||||||
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("Result: {}", collectionData);
|
log.debug("Result: {}", collectionData);
|
||||||
return 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);
|
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
|
||||||
|
|
||||||
long overdueDays = 0L;
|
long overdueDays = 0L;
|
||||||
|
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
|
||||||
if (overdueSinceDate != null) {
|
if (overdueSinceDate != null) {
|
||||||
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
|
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
|
||||||
if (overdueDays < 0) {
|
if (overdueDays < 0) {
|
||||||
overdueDays = 0L;
|
overdueDays = 0L;
|
||||||
}
|
}
|
||||||
collectionData.setPastDueDays(overdueDays);
|
collectionData.setPastDueDays(overdueDays);
|
||||||
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
|
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
|
||||||
collectionData.setDelinquentDate(overdueSinceDate);
|
collectionData.setDelinquentDate(delinquentStartDate);
|
||||||
}
|
}
|
||||||
collectionData.setDelinquentAmount(outstandingAmount);
|
collectionData.setDelinquentAmount(outstandingAmount);
|
||||||
collectionData.setDelinquentDays(0L);
|
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
|
||||||
final long delinquentDays = overdueDays - graceDays;
|
overdueSinceDateForCalculation);
|
||||||
if (delinquentDays > 0) {
|
|
||||||
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
|
|
||||||
}
|
|
||||||
return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData);
|
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,
|
private CollectionData getInstallmentOverdueCollectionData(final Loan loan, final LoanRepaymentScheduleInstallment installment,
|
||||||
final List<LoanDelinquencyActionData> effectiveDelinquencyList, final List<LoanTransaction> chargebackTransactions) {
|
final List<LoanDelinquencyActionData> effectiveDelinquencyList, final List<LoanTransaction> chargebackTransactions) {
|
||||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
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.
|
// Grace days are not considered for installment level delinquency calculation currently.
|
||||||
long overdueDays = 0L;
|
long overdueDays = 0L;
|
||||||
|
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
|
||||||
if (overdueSinceDate != null) {
|
if (overdueSinceDate != null) {
|
||||||
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
|
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
|
||||||
if (overdueDays < 0) {
|
if (overdueDays < 0) {
|
||||||
@@ -257,11 +247,8 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
|
|||||||
collectionData.setDelinquentDate(overdueSinceDate);
|
collectionData.setDelinquentDate(overdueSinceDate);
|
||||||
}
|
}
|
||||||
collectionData.setDelinquentAmount(outstandingAmount);
|
collectionData.setDelinquentAmount(outstandingAmount);
|
||||||
collectionData.setDelinquentDays(0L);
|
calculateAndSetDelinquentDays(collectionData, overdueDays, 0, effectiveDelinquencyList, businessDate,
|
||||||
final long delinquentDays = overdueDays;
|
overdueSinceDateForCalculation);
|
||||||
if (delinquentDays > 0) {
|
|
||||||
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
|
|
||||||
}
|
|
||||||
return collectionData;
|
return collectionData;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -356,4 +343,18 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
|
|||||||
return collectionData;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* 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 static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
||||||
|
import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData;
|
||||||
|
import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for InstallmentDelinquencyAggregator.
|
||||||
|
*
|
||||||
|
* These tests cover the critical aggregation logic that groups installment-level delinquency data by range and sums
|
||||||
|
* amounts. This logic is essential for financial reporting and has zero test coverage before this test class was
|
||||||
|
* created.
|
||||||
|
*
|
||||||
|
* Test scenarios cover: - Same range aggregation (summing amounts) - Different range separation - Multiple installments
|
||||||
|
* with mixed ranges - Sorting by minimumAgeDays - Empty input handling
|
||||||
|
*/
|
||||||
|
class InstallmentDelinquencyAggregatorTest {
|
||||||
|
|
||||||
|
private FineractPlatformTenant testTenant;
|
||||||
|
private FineractPlatformTenant originalTenant;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
originalTenant = ThreadLocalContextUtil.getTenant();
|
||||||
|
testTenant = new FineractPlatformTenant(1L, "test", "Test Tenant", "Asia/Kolkata", null);
|
||||||
|
ThreadLocalContextUtil.setTenant(testTenant);
|
||||||
|
MoneyHelper.initializeTenantRoundingMode("test", 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
ThreadLocalContextUtil.setTenant(originalTenant);
|
||||||
|
MoneyHelper.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_emptyInput_returnsEmptyList() {
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_singleInstallment_returnsSameInstallment() {
|
||||||
|
LoanInstallmentDelinquencyTagData data = createTagData(1L, 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_twoInstallmentsSameRange_sumsAmounts() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 3L, "RANGE_3", 4, 60, "250.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 3L, "RANGE_3", 4, 60, "500.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 3L, "RANGE_3", 4, 60, "750.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_threeInstallmentsSameRange_sumsAllAmounts() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 2L, "RANGE_2", 2, 3, "100.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 2L, "RANGE_2", 2, 3, "150.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 2L, "RANGE_2", 2, 3, "200.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2, data3));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 2L, "RANGE_2", 2, 3, "450.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_twoInstallmentsDifferentRanges_remainsSeparate() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 3L, "RANGE_3", 4, 60, "250.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
assertInstallmentDelinquency(result.get(1), 3L, "RANGE_3", 4, 60, "250.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_multipleInstallmentsMixedRanges_aggregatesAndSeparatesCorrectly() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "100.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "150.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 3L, "RANGE_3", 4, 60, "200.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data4 = createTagData(4L, 3L, "RANGE_3", 4, 60, "300.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2, data3, data4));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
assertInstallmentDelinquency(result.get(1), 3L, "RANGE_3", 4, 60, "500.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_sortsByMinimumAgeDaysAscending() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 3L, "RANGE_3", 4, 60, "250.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 2L, "RANGE_2", 2, 3, "250.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2, data3));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
assertEquals(1L, result.get(0).getRangeId());
|
||||||
|
assertEquals(Integer.valueOf(1), result.get(0).getMinimumAgeDays());
|
||||||
|
assertEquals(2L, result.get(1).getRangeId());
|
||||||
|
assertEquals(Integer.valueOf(2), result.get(1).getMinimumAgeDays());
|
||||||
|
assertEquals(3L, result.get(2).getRangeId());
|
||||||
|
assertEquals(Integer.valueOf(4), result.get(2).getMinimumAgeDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_complexScenario_aggregatesSortsCorrectly() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 3L, "RANGE_3", 4, 60, "500.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "250.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 3L, "RANGE_3", 4, 60, "250.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data4 = createTagData(4L, 2L, "RANGE_2", 2, 3, "100.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data5 = createTagData(5L, 1L, "RANGE_1", 1, 3, "150.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator
|
||||||
|
.aggregateAndSort(List.of(data1, data2, data3, data4, data5));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "400.00");
|
||||||
|
assertInstallmentDelinquency(result.get(1), 2L, "RANGE_2", 2, 3, "100.00");
|
||||||
|
assertInstallmentDelinquency(result.get(2), 3L, "RANGE_3", 4, 60, "750.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_nullMinimumAgeDays_treatsAsZero() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "NO_DELINQUENCY", null, null, "100.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 2L, "RANGE_1", 1, 3, "200.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertEquals(1L, result.get(0).getRangeId());
|
||||||
|
assertEquals(2L, result.get(1).getRangeId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_decimalPrecision_maintainsPrecision() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "100.12");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "200.34");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getDelinquentAmount()).isEqualByComparingTo("300.46");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAggregateAndSort_zeroAmounts_includesInResult() {
|
||||||
|
LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "0.00");
|
||||||
|
LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 2L, "RANGE_2", 2, 3, "100.00");
|
||||||
|
|
||||||
|
List<InstallmentLevelDelinquency> result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "0.00");
|
||||||
|
assertInstallmentDelinquency(result.get(1), 2L, "RANGE_2", 2, 3, "100.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoanInstallmentDelinquencyTagData createTagData(Long installmentId, Long rangeId, String classification, Integer minDays,
|
||||||
|
Integer maxDays, String amount) {
|
||||||
|
return new TestLoanInstallmentDelinquencyTagData(installmentId,
|
||||||
|
new TestInstallmentDelinquencyRange(rangeId, classification, minDays, maxDays), new BigDecimal(amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertInstallmentDelinquency(InstallmentLevelDelinquency actual, Long expectedRangeId, String expectedClassification,
|
||||||
|
Integer expectedMinDays, Integer expectedMaxDays, String expectedAmount) {
|
||||||
|
assertNotNull(actual);
|
||||||
|
assertEquals(expectedRangeId, actual.getRangeId());
|
||||||
|
assertEquals(expectedClassification, actual.getClassification());
|
||||||
|
assertEquals(expectedMinDays, actual.getMinimumAgeDays());
|
||||||
|
assertEquals(expectedMaxDays, actual.getMaximumAgeDays());
|
||||||
|
assertThat(actual.getDelinquentAmount()).isEqualByComparingTo(expectedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestLoanInstallmentDelinquencyTagData implements LoanInstallmentDelinquencyTagData {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final InstallmentDelinquencyRange range;
|
||||||
|
private final BigDecimal amount;
|
||||||
|
|
||||||
|
TestLoanInstallmentDelinquencyTagData(Long id, InstallmentDelinquencyRange range, BigDecimal amount) {
|
||||||
|
this.id = id;
|
||||||
|
this.range = range;
|
||||||
|
this.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InstallmentDelinquencyRange getDelinquencyRange() {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getOutstandingAmount() {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestInstallmentDelinquencyRange implements LoanInstallmentDelinquencyTagData.InstallmentDelinquencyRange {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String classification;
|
||||||
|
private final Integer minDays;
|
||||||
|
private final Integer maxDays;
|
||||||
|
|
||||||
|
TestInstallmentDelinquencyRange(Long id, String classification, Integer minDays, Integer maxDays) {
|
||||||
|
this.id = id;
|
||||||
|
this.classification = classification;
|
||||||
|
this.minDays = minDays;
|
||||||
|
this.maxDays = maxDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getClassification() {
|
||||||
|
return classification;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getMinimumAgeDays() {
|
||||||
|
return minDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getMaximumAgeDays() {
|
||||||
|
return maxDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-3
@@ -42,7 +42,10 @@ import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
|||||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
||||||
|
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction;
|
||||||
|
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
|
||||||
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
|
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
|
||||||
|
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelperImpl;
|
||||||
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl;
|
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl;
|
||||||
import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData;
|
import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData;
|
||||||
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
|
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
|
||||||
@@ -159,7 +162,8 @@ public class LoanDelinquencyDomainServiceTest {
|
|||||||
when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(repaymentScheduleInstallments.get(0));
|
when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(repaymentScheduleInstallments.get(0));
|
||||||
when(loan.getCurrency()).thenReturn(currency);
|
when(loan.getCurrency()).thenReturn(currency);
|
||||||
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
||||||
when(delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate)).thenReturn(0L);
|
when(delinquencyEffectivePauseHelper.getPausedDaysWithinRange(Mockito.eq(effectiveDelinquencyList), Mockito.any(),
|
||||||
|
Mockito.eq(businessDate))).thenReturn(0L);
|
||||||
|
|
||||||
CollectionData collectionData = underTest.getOverdueCollectionData(loan, effectiveDelinquencyList);
|
CollectionData collectionData = underTest.getOverdueCollectionData(loan, effectiveDelinquencyList);
|
||||||
|
|
||||||
@@ -229,7 +233,8 @@ public class LoanDelinquencyDomainServiceTest {
|
|||||||
when(loan.getCurrency()).thenReturn(currency);
|
when(loan.getCurrency()).thenReturn(currency);
|
||||||
when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true);
|
when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true);
|
||||||
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
||||||
when(delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate)).thenReturn(0L);
|
when(delinquencyEffectivePauseHelper.getPausedDaysWithinRange(Mockito.eq(effectiveDelinquencyList), Mockito.any(),
|
||||||
|
Mockito.eq(businessDate))).thenReturn(0L);
|
||||||
|
|
||||||
LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan, effectiveDelinquencyList);
|
LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan, effectiveDelinquencyList);
|
||||||
|
|
||||||
@@ -281,7 +286,8 @@ public class LoanDelinquencyDomainServiceTest {
|
|||||||
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
||||||
when(loanTransactionReadService.fetchLoanTransactionsByType(loan.getId(), null, LoanTransactionType.CHARGEBACK))
|
when(loanTransactionReadService.fetchLoanTransactionsByType(loan.getId(), null, LoanTransactionType.CHARGEBACK))
|
||||||
.thenReturn(Arrays.asList(loanTransaction));
|
.thenReturn(Arrays.asList(loanTransaction));
|
||||||
when(delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate)).thenReturn(0L);
|
when(delinquencyEffectivePauseHelper.getPausedDaysWithinRange(Mockito.eq(effectiveDelinquencyList), Mockito.any(),
|
||||||
|
Mockito.eq(businessDate))).thenReturn(0L);
|
||||||
|
|
||||||
LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan, effectiveDelinquencyList);
|
LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan, effectiveDelinquencyList);
|
||||||
|
|
||||||
@@ -305,4 +311,108 @@ public class LoanDelinquencyDomainServiceTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void givenPausePeriodThenInstallmentDelinquentDaysOnlyIncludeOverlap() {
|
||||||
|
LocalDate overriddenBusinessDate = LocalDate.of(2022, 3, 2);
|
||||||
|
ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, overriddenBusinessDate)));
|
||||||
|
|
||||||
|
LoanRepaymentScheduleInstallment installmentOne = new LoanRepaymentScheduleInstallment(loan, 1, LocalDate.of(2021, 12, 1),
|
||||||
|
LocalDate.of(2022, 1, 16), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
|
||||||
|
installmentOne.setId(1L);
|
||||||
|
LoanRepaymentScheduleInstallment installmentTwo = new LoanRepaymentScheduleInstallment(loan, 2, LocalDate.of(2022, 1, 16),
|
||||||
|
LocalDate.of(2022, 1, 31), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
|
||||||
|
installmentTwo.setId(2L);
|
||||||
|
LoanRepaymentScheduleInstallment installmentThree = new LoanRepaymentScheduleInstallment(loan, 3, LocalDate.of(2022, 1, 31),
|
||||||
|
LocalDate.of(2022, 2, 15), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
|
||||||
|
installmentThree.setId(3L);
|
||||||
|
|
||||||
|
List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments = Arrays.asList(installmentOne, installmentTwo,
|
||||||
|
installmentThree);
|
||||||
|
|
||||||
|
when(loanProductRelatedDetail.getGraceOnArrearsAgeing()).thenReturn(0);
|
||||||
|
when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductRelatedDetail);
|
||||||
|
when(loan.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments);
|
||||||
|
when(loanTransactionReadService.fetchLoanTransactionsByType(loan.getId(), null, LoanTransactionType.CHARGEBACK))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(installmentThree);
|
||||||
|
when(loan.getCurrency()).thenReturn(currency);
|
||||||
|
when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true);
|
||||||
|
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
||||||
|
|
||||||
|
LoanDelinquencyAction pauseAction = new LoanDelinquencyAction(null, DelinquencyAction.PAUSE, LocalDate.of(2022, 1, 20),
|
||||||
|
LocalDate.of(2022, 1, 30));
|
||||||
|
pauseAction.setId(1L);
|
||||||
|
LoanDelinquencyActionData pauseData = new LoanDelinquencyActionData(pauseAction);
|
||||||
|
List<LoanDelinquencyActionData> effectiveDelinquencyList = List.of(pauseData);
|
||||||
|
|
||||||
|
LoanDelinquencyDomainServiceImpl service = new LoanDelinquencyDomainServiceImpl(new DelinquencyEffectivePauseHelperImpl(),
|
||||||
|
loanTransactionReadService);
|
||||||
|
|
||||||
|
LoanDelinquencyData collectionData = service.getLoanDelinquencyData(loan, effectiveDelinquencyList);
|
||||||
|
|
||||||
|
CollectionData loanCollectionData = collectionData.getLoanCollectionData();
|
||||||
|
assertEquals(35L, loanCollectionData.getDelinquentDays());
|
||||||
|
assertEquals(LocalDate.of(2022, 1, 16), loanCollectionData.getDelinquentDate());
|
||||||
|
|
||||||
|
Map<Long, CollectionData> installments = collectionData.getLoanInstallmentsCollectionData();
|
||||||
|
assertNotNull(installments);
|
||||||
|
assertEquals(3, installments.size());
|
||||||
|
assertEquals(35L, installments.get(1L).getDelinquentDays());
|
||||||
|
assertEquals(30L, installments.get(2L).getDelinquentDays());
|
||||||
|
assertEquals(15L, installments.get(3L).getDelinquentDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void givenMultipleInstallmentsAndPauseThenDelinquencyDaysDistributePerInstallment() {
|
||||||
|
LocalDate businessDate = LocalDate.of(2022, 2, 5);
|
||||||
|
ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, businessDate)));
|
||||||
|
|
||||||
|
Loan localLoan = Mockito.mock(Loan.class);
|
||||||
|
LoanProductRelatedDetail localDetails = Mockito.mock(LoanProductRelatedDetail.class);
|
||||||
|
MonetaryCurrency currency = new MonetaryCurrency("USD", 2, null);
|
||||||
|
|
||||||
|
LoanRepaymentScheduleInstallment installmentOne = new LoanRepaymentScheduleInstallment(localLoan, 1, LocalDate.of(2021, 12, 26),
|
||||||
|
LocalDate.of(2022, 1, 10), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
|
||||||
|
installmentOne.setId(1L);
|
||||||
|
LoanRepaymentScheduleInstallment installmentTwo = new LoanRepaymentScheduleInstallment(localLoan, 2, LocalDate.of(2022, 1, 10),
|
||||||
|
LocalDate.of(2022, 1, 20), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
|
||||||
|
installmentTwo.setId(2L);
|
||||||
|
LoanRepaymentScheduleInstallment installmentThree = new LoanRepaymentScheduleInstallment(localLoan, 3, LocalDate.of(2022, 1, 20),
|
||||||
|
LocalDate.of(2022, 1, 30), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
|
||||||
|
installmentThree.setId(3L);
|
||||||
|
|
||||||
|
List<LoanRepaymentScheduleInstallment> installments = Arrays.asList(installmentOne, installmentTwo, installmentThree);
|
||||||
|
|
||||||
|
when(localLoan.getId()).thenReturn(42L);
|
||||||
|
when(localLoan.getLoanProductRelatedDetail()).thenReturn(localDetails);
|
||||||
|
when(localDetails.getGraceOnArrearsAgeing()).thenReturn(0);
|
||||||
|
when(localLoan.getRepaymentScheduleInstallments()).thenReturn(installments);
|
||||||
|
when(localLoan.getLastLoanRepaymentScheduleInstallment()).thenReturn(installmentThree);
|
||||||
|
when(localLoan.getCurrency()).thenReturn(currency);
|
||||||
|
when(localLoan.isEnableInstallmentLevelDelinquency()).thenReturn(true);
|
||||||
|
when(localLoan.getStatus()).thenReturn(LoanStatus.ACTIVE);
|
||||||
|
|
||||||
|
when(loanTransactionReadService.fetchLoanTransactionsByType(localLoan.getId(), null, LoanTransactionType.CHARGEBACK))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
LoanDelinquencyAction pauseAction = new LoanDelinquencyAction(localLoan, DelinquencyAction.PAUSE, LocalDate.of(2022, 1, 15),
|
||||||
|
LocalDate.of(2022, 1, 25));
|
||||||
|
List<LoanDelinquencyActionData> effectiveDelinquencyList = new DelinquencyEffectivePauseHelperImpl()
|
||||||
|
.calculateEffectiveDelinquencyList(List.of(pauseAction));
|
||||||
|
|
||||||
|
LoanDelinquencyDomainServiceImpl service = new LoanDelinquencyDomainServiceImpl(new DelinquencyEffectivePauseHelperImpl(),
|
||||||
|
loanTransactionReadService);
|
||||||
|
|
||||||
|
LoanDelinquencyData delinquencyData = service.getLoanDelinquencyData(localLoan, effectiveDelinquencyList);
|
||||||
|
|
||||||
|
CollectionData loanCollectionData = delinquencyData.getLoanCollectionData();
|
||||||
|
assertEquals(16L, loanCollectionData.getDelinquentDays());
|
||||||
|
assertEquals(LocalDate.of(2022, 1, 10), loanCollectionData.getDelinquentDate());
|
||||||
|
|
||||||
|
Map<Long, CollectionData> installmentData = delinquencyData.getLoanInstallmentsCollectionData();
|
||||||
|
assertEquals(3, installmentData.size());
|
||||||
|
assertEquals(16L, installmentData.get(1L).getDelinquentDays());
|
||||||
|
assertEquals(11L, installmentData.get(2L).getDelinquentDays());
|
||||||
|
assertEquals(6L, installmentData.get(3L).getDelinquentDays());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+496
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.integrationtests;
|
package org.apache.fineract.integrationtests;
|
||||||
|
|
||||||
|
import static java.lang.Boolean.FALSE;
|
||||||
import static java.lang.Boolean.TRUE;
|
import static java.lang.Boolean.TRUE;
|
||||||
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE;
|
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE;
|
||||||
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
|
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
|
||||||
@@ -27,9 +28,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -39,6 +44,7 @@ import org.apache.fineract.client.models.GetDelinquencyActionsResponse;
|
|||||||
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
|
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
|
||||||
import org.apache.fineract.client.models.GetLoansLoanIdDelinquencyPausePeriod;
|
import org.apache.fineract.client.models.GetLoansLoanIdDelinquencyPausePeriod;
|
||||||
import org.apache.fineract.client.models.GetLoansLoanIdLoanInstallmentLevelDelinquency;
|
import org.apache.fineract.client.models.GetLoansLoanIdLoanInstallmentLevelDelinquency;
|
||||||
|
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
|
||||||
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
|
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
|
||||||
import org.apache.fineract.client.models.PostLoanProductsRequest;
|
import org.apache.fineract.client.models.PostLoanProductsRequest;
|
||||||
import org.apache.fineract.client.models.PostLoanProductsResponse;
|
import org.apache.fineract.client.models.PostLoanProductsResponse;
|
||||||
@@ -48,6 +54,7 @@ import org.apache.fineract.integrationtests.common.ClientHelper;
|
|||||||
import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension;
|
import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension;
|
||||||
import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper;
|
import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper;
|
||||||
import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper;
|
import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper;
|
||||||
|
import org.assertj.core.api.SoftAssertions;
|
||||||
import org.junit.jupiter.api.Assertions;
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -462,6 +469,379 @@ public class DelinquencyActionIntegrationTests extends BaseLoanIntegrationTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long createLoanProductWithDelinquencyBucketNoDownPayment(boolean multiDisburseEnabled,
|
||||||
|
boolean installmentLevelDelinquencyEnabled, Integer graceOnArrearsAging) {
|
||||||
|
Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(//
|
||||||
|
Pair.of(1, 3), //
|
||||||
|
Pair.of(4, 10), //
|
||||||
|
Pair.of(11, 60), //
|
||||||
|
Pair.of(61, null)//
|
||||||
|
));
|
||||||
|
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct();
|
||||||
|
product.setDelinquencyBucketId(delinquencyBucketId.longValue());
|
||||||
|
product.setMultiDisburseLoan(multiDisburseEnabled);
|
||||||
|
product.setEnableDownPayment(false);
|
||||||
|
product.setGraceOnArrearsAgeing(graceOnArrearsAging);
|
||||||
|
product.setEnableInstallmentLevelDelinquency(installmentLevelDelinquencyEnabled);
|
||||||
|
|
||||||
|
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
|
||||||
|
return loanProductResponse.getResourceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDelinquentDaysAndDateAfterPastDelinquencyPause() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("01 January 2022", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, false, 0);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 2, req -> {
|
||||||
|
req.setLoanTermFrequency(30);
|
||||||
|
req.setRepaymentEvery(15);
|
||||||
|
req.setGraceOnArrearsAgeing(0);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2022");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "20 January 2022", "30 January 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("02 February 2022", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Delinquent data should not be null");
|
||||||
|
|
||||||
|
Integer pastDueDays = loanDetails.getDelinquent().getPastDueDays();
|
||||||
|
assertNotNull(pastDueDays, "Past due days should not be null");
|
||||||
|
assertEquals(17, pastDueDays, "Past due days should be 17 (16 Jan due date to 02 Feb business date)");
|
||||||
|
|
||||||
|
Integer delinquentDays = loanDetails.getDelinquent().getDelinquentDays();
|
||||||
|
assertNotNull(delinquentDays, "Delinquent days should not be null");
|
||||||
|
assertEquals(7, delinquentDays, "Delinquent days should be 7 (17 past due days - 10 paused days = 7)");
|
||||||
|
|
||||||
|
LocalDate delinquentDate = loanDetails.getDelinquent().getDelinquentDate();
|
||||||
|
assertNotNull(delinquentDate, "Delinquent date should not be null");
|
||||||
|
assertEquals(LocalDate.parse("16 January 2022", dateTimeFormatter), delinquentDate,
|
||||||
|
"Delinquent date should be 16 Jan 2022 (first installment due date, NOT adjusted for pause)");
|
||||||
|
|
||||||
|
List<GetLoansLoanIdDelinquencyPausePeriod> pausePeriods = loanDetails.getDelinquent().getDelinquencyPausePeriods();
|
||||||
|
assertNotNull(pausePeriods);
|
||||||
|
assertEquals(1, pausePeriods.size());
|
||||||
|
assertEquals(LocalDate.parse("20 January 2022", dateTimeFormatter), pausePeriods.get(0).getPausePeriodStart());
|
||||||
|
assertEquals(LocalDate.parse("30 January 2022", dateTimeFormatter), pausePeriods.get(0).getPausePeriodEnd());
|
||||||
|
assertEquals(FALSE, pausePeriods.get(0).getActive());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInstallmentLevelDelinquencyWithMultipleOverdueInstallments() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("01 January 2022", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 3, req -> {
|
||||||
|
req.setLoanTermFrequency(45);
|
||||||
|
req.setRepaymentEvery(15);
|
||||||
|
req.setGraceOnArrearsAgeing(0);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(100.00), "01 January 2022");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
|
||||||
|
businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
|
||||||
|
.date("05 January 2022").dateFormat(DATETIME_PATTERN).locale("en"));
|
||||||
|
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "20 January 2022", "30 January 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("02 March 2022", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null");
|
||||||
|
|
||||||
|
Integer loanLevelPastDueDays = loanDetails.getDelinquent().getPastDueDays();
|
||||||
|
assertEquals(45, loanLevelPastDueDays, "Loan level past due days should be 45 (16 Jan to 02 Mar)");
|
||||||
|
|
||||||
|
Integer loanLevelDelinquentDays = loanDetails.getDelinquent().getDelinquentDays();
|
||||||
|
assertEquals(35, loanLevelDelinquentDays, "Loan level delinquent days should be 35 (45 past due days - 10 paused days = 35)");
|
||||||
|
|
||||||
|
LocalDate loanLevelDelinquentDate = loanDetails.getDelinquent().getDelinquentDate();
|
||||||
|
assertEquals(LocalDate.parse("16 January 2022", dateTimeFormatter), loanLevelDelinquentDate,
|
||||||
|
"Loan level delinquent date should be 16 Jan 2022 (first installment due date)");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> expectedTotals = calculateExpectedBucketTotals(loanDetails,
|
||||||
|
LocalDate.parse("02 March 2022", dateTimeFormatter));
|
||||||
|
assertTrue(expectedTotals.containsKey("11-60"), "Expected 11-60 bucket to contain delinquent installments");
|
||||||
|
assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("02 March 2022", dateTimeFormatter), expectedTotals);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInstallmentDelinquencyWithSinglePauseAffectingMultipleInstallments() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("10 January 2022", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "10 January 2022", 1000.0, 3, req -> {
|
||||||
|
req.setLoanTermFrequency(30);
|
||||||
|
req.setRepaymentEvery(10);
|
||||||
|
req.setGraceOnArrearsAgeing(0);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(100.00), "10 January 2022");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
|
||||||
|
businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
|
||||||
|
.date("14 January 2022").dateFormat(DATETIME_PATTERN).locale("en"));
|
||||||
|
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "15 January 2022", "25 January 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("05 February 2022", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null");
|
||||||
|
|
||||||
|
List<GetLoansLoanIdLoanInstallmentLevelDelinquency> delinquencies = loanDetails.getDelinquent()
|
||||||
|
.getInstallmentLevelDelinquency();
|
||||||
|
assertNotNull(delinquencies, "Installment level delinquency should not be null");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> actualTotals = new HashMap<>();
|
||||||
|
for (GetLoansLoanIdLoanInstallmentLevelDelinquency delinquency : delinquencies) {
|
||||||
|
String bucketKey = formatBucketKey(delinquency.getMinimumAgeDays(), delinquency.getMaximumAgeDays());
|
||||||
|
actualTotals.merge(bucketKey, delinquency.getDelinquentAmount(), BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(2, actualTotals.size(), "Should have 2 delinquency buckets");
|
||||||
|
assertTrue(actualTotals.containsKey("4-10"), "Should have 4-10 bucket");
|
||||||
|
assertTrue(actualTotals.containsKey("11-60"), "Should have 11-60 bucket");
|
||||||
|
assertEquals(0, BigDecimal.valueOf(25.0).compareTo(actualTotals.get("4-10")), "4-10 bucket should have 25.0");
|
||||||
|
assertEquals(0, BigDecimal.valueOf(25.0).compareTo(actualTotals.get("11-60")), "11-60 bucket should have 25.0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInstallmentDelinquencyWithMultiplePausesAffectingSameInstallment() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("01 January 2022", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 1, req -> {
|
||||||
|
req.setLoanTermFrequency(30);
|
||||||
|
req.setRepaymentEvery(30);
|
||||||
|
req.setGraceOnArrearsAgeing(0);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(100.00), "01 January 2022");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("04 February 2022", () -> {
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "04 February 2022", "09 February 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("15 February 2022", () -> {
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "15 February 2022", "20 February 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("01 March 2022", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null");
|
||||||
|
|
||||||
|
LocalDate businessDate = LocalDate.parse("01 March 2022", dateTimeFormatter);
|
||||||
|
LocalDate installmentDueDate = loanDetails.getDelinquent().getDelinquentDate();
|
||||||
|
|
||||||
|
Integer loanLevelPastDueDays = loanDetails.getDelinquent().getPastDueDays();
|
||||||
|
long expectedPastDueDays = ChronoUnit.DAYS.between(installmentDueDate, businessDate);
|
||||||
|
assertEquals((int) expectedPastDueDays, loanLevelPastDueDays,
|
||||||
|
"Loan level past due days should match the business date minus the first installment due date");
|
||||||
|
|
||||||
|
Integer loanLevelDelinquentDays = loanDetails.getDelinquent().getDelinquentDays();
|
||||||
|
long expectedDelinquentDays = Math.max(expectedPastDueDays - 10, 0);
|
||||||
|
assertEquals((int) expectedDelinquentDays, loanLevelDelinquentDays,
|
||||||
|
"Loan level delinquent days should subtract both five-day pause periods from the past due days");
|
||||||
|
|
||||||
|
LocalDate loanLevelDelinquentDate = loanDetails.getDelinquent().getDelinquentDate();
|
||||||
|
assertEquals(installmentDueDate, loanLevelDelinquentDate, "Loan level delinquent date should equal the installment due date");
|
||||||
|
|
||||||
|
List<GetLoansLoanIdLoanInstallmentLevelDelinquency> delinquencies = loanDetails.getDelinquent()
|
||||||
|
.getInstallmentLevelDelinquency();
|
||||||
|
assertNotNull(delinquencies, "Installment level delinquency should not be null");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> actualTotals = new HashMap<>();
|
||||||
|
for (GetLoansLoanIdLoanInstallmentLevelDelinquency delinquency : delinquencies) {
|
||||||
|
String bucketKey = formatBucketKey(delinquency.getMinimumAgeDays(), delinquency.getMaximumAgeDays());
|
||||||
|
actualTotals.merge(bucketKey, delinquency.getDelinquentAmount(), BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, actualTotals.size(), "Should have 1 delinquency bucket");
|
||||||
|
assertTrue(actualTotals.containsKey("11-60"), "Should have 11-60 bucket");
|
||||||
|
assertEquals(0, BigDecimal.valueOf(75.0).compareTo(actualTotals.get("11-60")), "11-60 bucket should have 75.0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInstallmentDelinquencyWithPauseBetweenSequentialInstallments() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("01 January 2022", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 2, req -> {
|
||||||
|
req.setLoanTermFrequency(20);
|
||||||
|
req.setRepaymentEvery(10);
|
||||||
|
req.setGraceOnArrearsAgeing(0);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(100.00), "01 January 2022");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
|
||||||
|
businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
|
||||||
|
.date("02 January 2022").dateFormat(DATETIME_PATTERN).locale("en"));
|
||||||
|
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "03 January 2022", "10 January 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("12 January 2022", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> expectedTotals = calculateExpectedBucketTotals(loanDetails,
|
||||||
|
LocalDate.parse("12 January 2022", dateTimeFormatter));
|
||||||
|
assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("12 January 2022", dateTimeFormatter), expectedTotals);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInstallmentDelinquencyWithFourInstallmentsAndPausePeriod() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("01 January 2022", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 4, req -> {
|
||||||
|
req.setLoanTermFrequency(60);
|
||||||
|
req.setRepaymentEvery(15);
|
||||||
|
req.setGraceOnArrearsAgeing(0);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2022");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
|
||||||
|
businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
|
||||||
|
.date("01 January 2022").dateFormat(DATETIME_PATTERN).locale("en"));
|
||||||
|
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "02 January 2022", "20 January 2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("01 March 2022", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> expectedTotals = calculateExpectedBucketTotals(loanDetails,
|
||||||
|
LocalDate.parse("01 March 2022", dateTimeFormatter));
|
||||||
|
assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("01 March 2022", dateTimeFormatter), expectedTotals);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPauseUsesBusinessDateNotCOBDate() {
|
||||||
|
final Long[] loanIdHolder = new Long[1];
|
||||||
|
|
||||||
|
runAt("28 May 2025", () -> {
|
||||||
|
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
|
||||||
|
Long loanProductId = createLoanProductWithDelinquencyBucketNoDownPayment(true, true, 3);
|
||||||
|
Long loanId = applyAndApproveLoan(clientId, loanProductId, "28 May 2025", 1000.0, 7, req -> {
|
||||||
|
req.setLoanTermFrequency(210);
|
||||||
|
req.setRepaymentEvery(30);
|
||||||
|
req.setGraceOnArrearsAgeing(3);
|
||||||
|
});
|
||||||
|
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "28 May 2025");
|
||||||
|
loanIdHolder[0] = loanId;
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("15 June 2025", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "17 June 2025", "19 August 2025");
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("01 July 2025", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("01 August 2025", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("01 September 2025", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("01 October 2025", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
});
|
||||||
|
|
||||||
|
runAt("31 October 2025", () -> {
|
||||||
|
final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
|
||||||
|
Long loanId = loanIdHolder[0];
|
||||||
|
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
|
||||||
|
|
||||||
|
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
|
||||||
|
assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null");
|
||||||
|
|
||||||
|
Integer loanLevelPastDueDays = loanDetails.getDelinquent().getPastDueDays();
|
||||||
|
assertEquals(126, loanLevelPastDueDays,
|
||||||
|
"Loan level past due days should be 126 (June 27 to Oct 31) - First installment due June 27 (30 days after May 28)");
|
||||||
|
|
||||||
|
Integer loanLevelDelinquentDays = loanDetails.getDelinquent().getDelinquentDays();
|
||||||
|
assertEquals(70, loanLevelDelinquentDays,
|
||||||
|
"Loan level delinquent days should be 70 (125 overdue days from June 28 to Oct 31, minus 52 paused days from June 28 to Aug 19, minus 3 grace)");
|
||||||
|
|
||||||
|
LocalDate loanLevelDelinquentDate = loanDetails.getDelinquent().getDelinquentDate();
|
||||||
|
assertEquals(LocalDate.parse("30 June 2025", dateTimeFormatter), loanLevelDelinquentDate,
|
||||||
|
"Loan level delinquent date should be June 30, 2025 (first installment due June 27 + 3 days grace)");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> expectedTotals = calculateExpectedBucketTotals(loanDetails,
|
||||||
|
LocalDate.parse("31 October 2025", dateTimeFormatter));
|
||||||
|
assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("31 October 2025", dateTimeFormatter), expectedTotals);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public static class InstallmentDelinquencyData {
|
public static class InstallmentDelinquencyData {
|
||||||
|
|
||||||
@@ -470,4 +850,120 @@ public class DelinquencyActionIntegrationTests extends BaseLoanIntegrationTest {
|
|||||||
BigDecimal delinquentAmount;
|
BigDecimal delinquentAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void assertInstallmentDelinquencyBuckets(GetLoansLoanIdResponse loanDetails, LocalDate businessDate,
|
||||||
|
Map<String, BigDecimal> expectedBucketTotals) {
|
||||||
|
SoftAssertions softly = new SoftAssertions();
|
||||||
|
|
||||||
|
List<GetLoansLoanIdLoanInstallmentLevelDelinquency> delinquencies = loanDetails.getDelinquent().getInstallmentLevelDelinquency();
|
||||||
|
softly.assertThat(delinquencies).as("Installment level delinquency should not be null").isNotNull();
|
||||||
|
|
||||||
|
Map<String, BigDecimal> calculatedTotals = calculateExpectedBucketTotals(loanDetails, businessDate);
|
||||||
|
Map<String, BigDecimal> actualTotals = new HashMap<>();
|
||||||
|
for (GetLoansLoanIdLoanInstallmentLevelDelinquency delinquency : delinquencies) {
|
||||||
|
String bucketKey = formatBucketKey(delinquency.getMinimumAgeDays(), delinquency.getMaximumAgeDays());
|
||||||
|
actualTotals.merge(bucketKey, delinquency.getDelinquentAmount(), BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
softly.assertThat(actualTotals.keySet()).as("Unexpected delinquency bucket set").isEqualTo(calculatedTotals.keySet());
|
||||||
|
|
||||||
|
calculatedTotals.forEach((bucket, expectedAmount) -> {
|
||||||
|
BigDecimal actualAmount = actualTotals.get(bucket);
|
||||||
|
softly.assertThat(actualAmount).as("Missing delinquency bucket " + bucket).isNotNull();
|
||||||
|
softly.assertThat(actualAmount.setScale(2, RoundingMode.HALF_DOWN)).as("Unexpected delinquent amount for bucket " + bucket)
|
||||||
|
.isEqualByComparingTo(expectedAmount.setScale(2, RoundingMode.HALF_DOWN));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expectedBucketTotals != null) {
|
||||||
|
expectedBucketTotals.forEach((bucket, amount) -> {
|
||||||
|
BigDecimal calculated = calculatedTotals.get(bucket);
|
||||||
|
softly.assertThat(calculated).as("Expected bucket " + bucket + " not present in calculated totals").isNotNull();
|
||||||
|
softly.assertThat(calculated.setScale(2, RoundingMode.HALF_DOWN))
|
||||||
|
.as("Calculated delinquent amount did not match expectation for bucket " + bucket)
|
||||||
|
.isEqualByComparingTo(amount.setScale(2, RoundingMode.HALF_DOWN));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal loanLevelAmount = loanDetails.getDelinquent().getDelinquentAmount();
|
||||||
|
if (loanLevelAmount != null) {
|
||||||
|
BigDecimal actualSum = actualTotals.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
softly.assertThat(actualSum.setScale(2, RoundingMode.HALF_DOWN))
|
||||||
|
.as("Installment bucket totals should sum to the loan level delinquent amount")
|
||||||
|
.isEqualByComparingTo(loanLevelAmount.setScale(2, RoundingMode.HALF_DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
softly.assertAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, BigDecimal> calculateExpectedBucketTotals(GetLoansLoanIdResponse loanDetails, LocalDate businessDate) {
|
||||||
|
Map<String, BigDecimal> totals = new HashMap<>();
|
||||||
|
List<GetLoansLoanIdDelinquencyPausePeriod> pauses = loanDetails.getDelinquent().getDelinquencyPausePeriods();
|
||||||
|
|
||||||
|
for (GetLoansLoanIdRepaymentPeriod period : loanDetails.getRepaymentSchedule().getPeriods()) {
|
||||||
|
if (Boolean.TRUE.equals(period.getDownPaymentPeriod())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LocalDate dueDate = period.getDueDate();
|
||||||
|
if (dueDate == null || !dueDate.isBefore(businessDate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BigDecimal outstanding = period.getTotalOutstandingForPeriod();
|
||||||
|
if (outstanding == null || outstanding.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long pastDueDays = ChronoUnit.DAYS.between(dueDate, businessDate);
|
||||||
|
if (pastDueDays <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long pausedDays = 0L;
|
||||||
|
if (pauses != null) {
|
||||||
|
for (GetLoansLoanIdDelinquencyPausePeriod pause : pauses) {
|
||||||
|
LocalDate pauseStart = pause.getPausePeriodStart();
|
||||||
|
LocalDate pauseEnd = pause.getPausePeriodEnd() != null ? pause.getPausePeriodEnd() : businessDate;
|
||||||
|
if (pauseStart == null || !pauseEnd.isAfter(pauseStart)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LocalDate overlapStart = pauseStart.isAfter(dueDate) ? pauseStart : dueDate;
|
||||||
|
LocalDate overlapEnd = pauseEnd.isBefore(businessDate) ? pauseEnd : businessDate;
|
||||||
|
if (overlapEnd.isAfter(overlapStart)) {
|
||||||
|
pausedDays += ChronoUnit.DAYS.between(overlapStart, overlapEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long delinquentDays = pastDueDays - pausedDays;
|
||||||
|
if (delinquentDays <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String bucket = formatBucketKeyForDays(delinquentDays);
|
||||||
|
totals.merge(bucket, outstanding, BigDecimal::add);
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatBucketKey(Integer minAgeDays, Integer maxAgeDays) {
|
||||||
|
if (minAgeDays == null) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
if (maxAgeDays == null) {
|
||||||
|
return minAgeDays + "+";
|
||||||
|
}
|
||||||
|
return minAgeDays + "-" + maxAgeDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatBucketKeyForDays(long delinquentDays) {
|
||||||
|
if (delinquentDays >= 1 && delinquentDays <= 3) {
|
||||||
|
return "1-3";
|
||||||
|
} else if (delinquentDays >= 4 && delinquentDays <= 10) {
|
||||||
|
return "4-10";
|
||||||
|
} else if (delinquentDays >= 11 && delinquentDays <= 60) {
|
||||||
|
return "11-60";
|
||||||
|
} else if (delinquentDays >= 61) {
|
||||||
|
return "61+";
|
||||||
|
}
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -82,6 +82,7 @@ import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtens
|
|||||||
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
|
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
|
||||||
import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper;
|
import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper;
|
||||||
import org.apache.fineract.integrationtests.common.products.DelinquencyRangesHelper;
|
import org.apache.fineract.integrationtests.common.products.DelinquencyRangesHelper;
|
||||||
|
import org.assertj.core.api.SoftAssertions;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -1324,14 +1325,27 @@ public class DelinquencyBucketsIntegrationTest extends BaseLoanIntegrationTest {
|
|||||||
getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
|
getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
|
||||||
loanTransactionHelper.printDelinquencyData(getLoansLoanIdResponse);
|
loanTransactionHelper.printDelinquencyData(getLoansLoanIdResponse);
|
||||||
GetLoansLoanIdDelinquencySummary delinquent = getLoansLoanIdResponse.getDelinquent();
|
GetLoansLoanIdDelinquencySummary delinquent = getLoansLoanIdResponse.getDelinquent();
|
||||||
assertEquals(2049.99, Utils.getDoubleValue(delinquent.getDelinquentAmount()));
|
|
||||||
assertEquals(LocalDate.of(2012, 2, 1), delinquent.getDelinquentDate());
|
SoftAssertions softly = new SoftAssertions();
|
||||||
assertEquals(31, delinquent.getDelinquentDays());
|
softly.assertThat(Utils.getDoubleValue(delinquent.getDelinquentAmount())).as("Total delinquent amount").isEqualTo(2049.99);
|
||||||
assertEquals(2, delinquent.getInstallmentLevelDelinquency().size());
|
softly.assertThat(delinquent.getDelinquentDate()).as("Delinquent date").isEqualTo(LocalDate.of(2012, 2, 1));
|
||||||
GetLoansLoanIdLoanInstallmentLevelDelinquency firstInstallmentDelinquent = delinquent.getInstallmentLevelDelinquency().get(0);
|
softly.assertThat(delinquent.getDelinquentDays()).as("Delinquent days").isEqualTo(31);
|
||||||
assertEquals(BigDecimal.valueOf(1016.66), firstInstallmentDelinquent.getDelinquentAmount().stripTrailingZeros());
|
|
||||||
GetLoansLoanIdLoanInstallmentLevelDelinquency secondInstallmentDelinquent = delinquent.getInstallmentLevelDelinquency().get(1);
|
// Installment-level delinquency is aggregated by range
|
||||||
assertEquals(BigDecimal.valueOf(1033.33), secondInstallmentDelinquent.getDelinquentAmount().stripTrailingZeros());
|
// Both installments (31 days and 13 days) fall into Range 2 (4-60 days)
|
||||||
|
// So we expect 1 aggregated entry with total amount 2049.99
|
||||||
|
softly.assertThat(delinquent.getInstallmentLevelDelinquency()).as("Installment level delinquency size").hasSize(1);
|
||||||
|
|
||||||
|
if (delinquent.getInstallmentLevelDelinquency().size() >= 1) {
|
||||||
|
GetLoansLoanIdLoanInstallmentLevelDelinquency rangeDelinquency = delinquent.getInstallmentLevelDelinquency().get(0);
|
||||||
|
// This is the aggregated amount for all installments in Range 2 (4-60 days)
|
||||||
|
softly.assertThat(rangeDelinquency.getDelinquentAmount().stripTrailingZeros())
|
||||||
|
.as("Range 2 (4-60 days) aggregated delinquent amount").isEqualByComparingTo(BigDecimal.valueOf(2049.99));
|
||||||
|
softly.assertThat(rangeDelinquency.getMinimumAgeDays()).as("Range minimum days").isEqualTo(4);
|
||||||
|
softly.assertThat(rangeDelinquency.getMaximumAgeDays()).as("Range maximum days").isEqualTo(60);
|
||||||
|
}
|
||||||
|
|
||||||
|
softly.assertAll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user