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

This commit is contained in:
Attila Budai
2025-10-22 13:17:32 +02:00
committed by Adam Saghy
parent 967dbed021
commit 13170dc551
10 changed files with 1061 additions and 74 deletions
@@ -610,6 +610,7 @@ Feature: LoanDelinquency
# --- Grace period applied only on Loan level, not on installment level ---
Then Loan has the following INSTALLMENT level delinquency data:
| rangeId | Range | Amount |
| 1 | RANGE_1 | 250.00 |
| 2 | RANGE_3 | 250.00 |
@TestRailId:C3000
@@ -728,8 +729,7 @@ Feature: LoanDelinquency
| RANGE_3 | 750.0 | 04 October 2023 | 30 | 43 |
Then Loan has the following INSTALLMENT level delinquency data:
| rangeId | Range | Amount |
| 1 | RANGE_1 | 250.00 |
| 2 | RANGE_3 | 250.00 |
| 2 | RANGE_3 | 500.00 |
| 3 | RANGE_30 | 250.00 |
# --- Second delinquency pause ---
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 |
Then Loan has the following INSTALLMENT level delinquency data:
| rangeId | Range | Amount |
| 1 | RANGE_1 | 250.00 |
| 2 | RANGE_3 | 250.00 |
| 2 | RANGE_3 | 500.00 |
| 3 | RANGE_30 | 250.00 |
Then Installment level delinquency event has correct data
# --- Second delinquency ends ---
@@ -770,8 +769,7 @@ Feature: LoanDelinquency
| RANGE_3 | 1000.0 | 04 October 2023 | 31 | 60 |
Then Loan has the following INSTALLMENT level delinquency data:
| rangeId | Range | Amount |
| 1 | RANGE_1 | 250.00 |
| 2 | RANGE_3 | 250.00 |
| 2 | RANGE_3 | 500.00 |
| 3 | RANGE_30 | 250.00 |
# --- Delinquency runs again ---
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 |
Then Loan has the following INSTALLMENT level delinquency data:
| rangeId | Range | Amount |
| 1 | RANGE_1 | 250.00 |
| 2 | RANGE_3 | 500.00 |
| 3 | RANGE_30 | 250.00 |
Then Installment level delinquency event has correct data
@@ -995,11 +994,11 @@ Feature: LoanDelinquency
| RESUME | 25 October 2023 | |
Then Loan has the following LOAN level delinquency data:
| 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 ---
Then Loan has the following INSTALLMENT level delinquency data:
| rangeId | Range | Amount |
| 2 | RANGE_3 | 250.00 |
| 2 | RANGE_3 | 500.00 |
Then Installment level delinquency event has correct data
@TestRailId:C3013
@@ -28,4 +28,7 @@ public interface DelinquencyEffectivePauseHelper {
List<LoanDelinquencyActionData> calculateEffectiveDelinquencyList(List<LoanDelinquencyAction> savedDelinquencyActions);
Long getPausedDaysBeforeDate(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate date);
Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
LocalDate endExclusive);
}
@@ -66,6 +66,30 @@ public class DelinquencyEffectivePauseHelperImpl implements DelinquencyEffective
return Long.sum(pausedDaysClosedPausePeriods, pausedDaysRunningPausePeriods);
}
@Override
public Long getPausedDaysWithinRange(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate startInclusive,
LocalDate endExclusive) {
if (startInclusive == null || endExclusive == null || !startInclusive.isBefore(endExclusive)) {
return 0L;
}
return effectiveDelinquencyList.stream().map(pausePeriod -> {
LocalDate pauseStart = pausePeriod.getStartDate();
LocalDate pauseEnd = Optional.ofNullable(pausePeriod.getEndDate()).orElse(endExclusive);
if (pauseStart == null || !pauseStart.isBefore(endExclusive)) {
return 0L;
}
if (!pauseEnd.isAfter(startInclusive)) {
return 0L;
}
LocalDate overlapStart = pauseStart.isAfter(startInclusive) ? pauseStart : startInclusive;
LocalDate overlapEnd = pauseEnd.isBefore(endExclusive) ? pauseEnd : endExclusive;
if (!overlapStart.isBefore(overlapEnd)) {
return 0L;
}
return DateUtils.getDifferenceInDays(overlapStart, overlapEnd);
}).reduce(0L, Long::sum);
}
private Optional<LoanDelinquencyAction> findMatchingResume(LoanDelinquencyAction pause, List<LoanDelinquencyAction> resumes) {
if (resumes != null && resumes.size() > 0) {
for (LoanDelinquencyAction resume : resumes) {
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.delinquency.helper;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData;
import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency;
/**
* Static utility class for aggregating installment-level delinquency data.
*
* @see InstallmentLevelDelinquency
* @see LoanInstallmentDelinquencyTagData
*/
public final class InstallmentDelinquencyAggregator {
private InstallmentDelinquencyAggregator() {}
/**
* Aggregates installment-level delinquency data by rangeId and sorts by minimumAgeDays.
*
* This method performs two key operations: 1. Groups installments by delinquency rangeId and sums delinquentAmount
* for installments with the same rangeId 2. Sorts the aggregated results by minimumAgeDays in ascending order
*
* @param installmentData
* Collection of installment delinquency data to aggregate
* @return Sorted list of aggregated delinquency data, empty list if input is null or empty
*/
public static List<InstallmentLevelDelinquency> aggregateAndSort(Collection<LoanInstallmentDelinquencyTagData> installmentData) {
if (installmentData == null || installmentData.isEmpty()) {
return List.of();
}
Collection<InstallmentLevelDelinquency> aggregated = installmentData.stream().map(InstallmentLevelDelinquency::from)
.collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values()
.stream().map(opt -> opt.orElseThrow(() -> new IllegalStateException("Unexpected empty Optional in aggregation"))).toList();
return aggregated.stream().sorted(Comparator.comparing(ild -> Optional.ofNullable(ild.getMinimumAgeDays()).orElse(0))).toList();
}
/**
* Creates a custom collector that sums delinquent amounts while preserving range metadata.
*
* This collector uses the reducing operation to combine multiple InstallmentLevelDelinquency objects with the same
* rangeId. It preserves the range classification (rangeId, classification, minimumAgeDays, maximumAgeDays) while
* summing the delinquentAmount fields.
*
* Note: This uses the 1-argument reducing() variant which returns Optional<T> to avoid the identity value bug that
* would cause amounts to be incorrectly doubled when aggregating single installments.
*
* @return Collector that combines InstallmentLevelDelinquency objects by summing amounts
*/
private static Collector<InstallmentLevelDelinquency, ?, Optional<InstallmentLevelDelinquency>> delinquentAmountSummingCollector() {
return Collectors.reducing((item1, item2) -> {
final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency();
result.setRangeId(item1.getRangeId());
result.setClassification(item1.getClassification());
result.setMaximumAgeDays(item1.getMaximumAgeDays());
result.setMinimumAgeDays(item1.getMinimumAgeDays());
result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount()));
return result;
});
}
}
@@ -27,8 +27,6 @@ import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -49,6 +47,7 @@ import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistor
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
import org.apache.fineract.portfolio.delinquency.helper.InstallmentDelinquencyAggregator;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
@@ -260,36 +259,12 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag(
loanId);
if (loanInstallmentDelinquencyTagData != null && !loanInstallmentDelinquencyTagData.isEmpty()) {
// installment level delinquency grouped by rangeId, and summed up the delinquent amount
Collection<InstallmentLevelDelinquency> installmentLevelDelinquencies = loanInstallmentDelinquencyTagData.stream()
.map(InstallmentLevelDelinquency::from)
.collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values();
// sort this based on minimum days, so ranges will be delivered in ascending order
List<InstallmentLevelDelinquency> sorted = installmentLevelDelinquencies.stream().sorted((o1, o2) -> {
Integer first = Optional.ofNullable(o1.getMinimumAgeDays()).orElse(0);
Integer second = Optional.ofNullable(o2.getMinimumAgeDays()).orElse(0);
return first.compareTo(second);
}).toList();
collectionData.setInstallmentLevelDelinquency(sorted);
List<InstallmentLevelDelinquency> aggregated = InstallmentDelinquencyAggregator
.aggregateAndSort(loanInstallmentDelinquencyTagData);
collectionData.setInstallmentLevelDelinquency(aggregated);
}
}
@NonNull
private static Collector<InstallmentLevelDelinquency, ?, InstallmentLevelDelinquency> delinquentAmountSummingCollector() {
return Collectors.reducing(new InstallmentLevelDelinquency(), (item1, item2) -> {
final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency();
result.setRangeId(Optional.ofNullable(item1.getRangeId()).orElse(item2.getRangeId()));
result.setClassification(Optional.ofNullable(item1.getClassification()).orElse(item2.getClassification()));
result.setMaximumAgeDays(Optional.ofNullable(item1.getMaximumAgeDays()).orElse(item2.getMaximumAgeDays()));
result.setMinimumAgeDays(Optional.ofNullable(item1.getMinimumAgeDays()).orElse(item2.getMinimumAgeDays()));
result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount()));
return result;
});
}
void enrichWithDelinquencyPausePeriodInfo(CollectionData collectionData, Collection<LoanDelinquencyActionData> effectiveDelinquencyList,
LocalDate businessDate) {
List<DelinquencyPausePeriod> result = effectiveDelinquencyList.stream() //
@@ -119,14 +119,15 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
long overdueDays = 0L;
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
if (overdueSinceDate != null) {
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
if (overdueDays < 0) {
overdueDays = 0L;
}
collectionData.setPastDueDays(overdueDays);
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(overdueSinceDate);
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(delinquentStartDate);
}
collectionData.setDelinquentAmount(outstandingAmount);
collectionData.setDelinquentPrincipal(delinquentPrincipal);
@@ -134,11 +135,8 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
collectionData.setDelinquentFee(delinquentFee);
collectionData.setDelinquentPenalty(delinquentPenalty);
collectionData.setDelinquentDays(0L);
final long delinquentDays = overdueDays - graceDays;
if (delinquentDays > 0) {
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
}
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
overdueSinceDateForCalculation);
log.debug("Result: {}", collectionData);
return collectionData;
@@ -200,31 +198,22 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
long overdueDays = 0L;
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
if (overdueSinceDate != null) {
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
if (overdueDays < 0) {
overdueDays = 0L;
}
collectionData.setPastDueDays(overdueDays);
overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(overdueSinceDate);
LocalDate delinquentStartDate = overdueSinceDate.plusDays(graceDays.longValue());
collectionData.setDelinquentDate(delinquentStartDate);
}
collectionData.setDelinquentAmount(outstandingAmount);
collectionData.setDelinquentDays(0L);
final long delinquentDays = overdueDays - graceDays;
if (delinquentDays > 0) {
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
}
calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate,
overdueSinceDateForCalculation);
return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData);
}
private void calculateDelinquentDays(List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate businessDate,
CollectionData collectionData, Long delinquentDays) {
Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate);
Long calculatedDelinquentDays = delinquentDays - pausedDays;
collectionData.setDelinquentDays(calculatedDelinquentDays > 0 ? calculatedDelinquentDays : 0L);
}
private CollectionData getInstallmentOverdueCollectionData(final Loan loan, final LoanRepaymentScheduleInstallment installment,
final List<LoanDelinquencyActionData> effectiveDelinquencyList, final List<LoanTransaction> chargebackTransactions) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
@@ -248,6 +237,7 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
// Grace days are not considered for installment level delinquency calculation currently.
long overdueDays = 0L;
LocalDate overdueSinceDateForCalculation = overdueSinceDate;
if (overdueSinceDate != null) {
overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate);
if (overdueDays < 0) {
@@ -257,11 +247,8 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
collectionData.setDelinquentDate(overdueSinceDate);
}
collectionData.setDelinquentAmount(outstandingAmount);
collectionData.setDelinquentDays(0L);
final long delinquentDays = overdueDays;
if (delinquentDays > 0) {
calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays);
}
calculateAndSetDelinquentDays(collectionData, overdueDays, 0, effectiveDelinquencyList, businessDate,
overdueSinceDateForCalculation);
return collectionData;
}
@@ -356,4 +343,18 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
return collectionData;
}
private void calculateAndSetDelinquentDays(CollectionData collectionData, long overdueDays, Integer graceDays,
List<LoanDelinquencyActionData> effectiveDelinquencyList, LocalDate businessDate, LocalDate overdueSinceDate) {
collectionData.setDelinquentDays(0L);
if (overdueDays > 0) {
Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysWithinRange(effectiveDelinquencyList, overdueSinceDate,
businessDate);
if (pausedDays == null) {
pausedDays = 0L;
}
final long delinquentDays = overdueDays - pausedDays - graceDays;
collectionData.setDelinquentDays(delinquentDays > 0 ? delinquentDays : 0L);
}
}
}
@@ -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;
}
}
}
@@ -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.Money;
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.DelinquencyEffectivePauseHelperImpl;
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl;
import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData;
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
@@ -159,7 +162,8 @@ public class LoanDelinquencyDomainServiceTest {
when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(repaymentScheduleInstallments.get(0));
when(loan.getCurrency()).thenReturn(currency);
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);
@@ -229,7 +233,8 @@ public class LoanDelinquencyDomainServiceTest {
when(loan.getCurrency()).thenReturn(currency);
when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true);
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);
@@ -281,7 +286,8 @@ public class LoanDelinquencyDomainServiceTest {
when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
when(loanTransactionReadService.fetchLoanTransactionsByType(loan.getId(), null, LoanTransactionType.CHARGEBACK))
.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);
@@ -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());
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.integrationtests;
import static java.lang.Boolean.FALSE;
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.RESUME;
@@ -27,9 +28,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.AllArgsConstructor;
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.GetLoansLoanIdDelinquencyPausePeriod;
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.PostLoanProductsRequest;
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.products.DelinquencyBucketsHelper;
import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
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
public static class InstallmentDelinquencyData {
@@ -470,4 +850,120 @@ public class DelinquencyActionIntegrationTests extends BaseLoanIntegrationTest {
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";
}
}
@@ -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.products.DelinquencyBucketsHelper;
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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -1324,14 +1325,27 @@ public class DelinquencyBucketsIntegrationTest extends BaseLoanIntegrationTest {
getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
loanTransactionHelper.printDelinquencyData(getLoansLoanIdResponse);
GetLoansLoanIdDelinquencySummary delinquent = getLoansLoanIdResponse.getDelinquent();
assertEquals(2049.99, Utils.getDoubleValue(delinquent.getDelinquentAmount()));
assertEquals(LocalDate.of(2012, 2, 1), delinquent.getDelinquentDate());
assertEquals(31, delinquent.getDelinquentDays());
assertEquals(2, delinquent.getInstallmentLevelDelinquency().size());
GetLoansLoanIdLoanInstallmentLevelDelinquency firstInstallmentDelinquent = delinquent.getInstallmentLevelDelinquency().get(0);
assertEquals(BigDecimal.valueOf(1016.66), firstInstallmentDelinquent.getDelinquentAmount().stripTrailingZeros());
GetLoansLoanIdLoanInstallmentLevelDelinquency secondInstallmentDelinquent = delinquent.getInstallmentLevelDelinquency().get(1);
assertEquals(BigDecimal.valueOf(1033.33), secondInstallmentDelinquent.getDelinquentAmount().stripTrailingZeros());
SoftAssertions softly = new SoftAssertions();
softly.assertThat(Utils.getDoubleValue(delinquent.getDelinquentAmount())).as("Total delinquent amount").isEqualTo(2049.99);
softly.assertThat(delinquent.getDelinquentDate()).as("Delinquent date").isEqualTo(LocalDate.of(2012, 2, 1));
softly.assertThat(delinquent.getDelinquentDays()).as("Delinquent days").isEqualTo(31);
// Installment-level delinquency is aggregated by range
// 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();
});
}