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
@@ -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());
}
}