This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch release/1.13.1 in repository https://gitbox.apache.org/repos/asf/fineract.git
commit 40a044ecec413e8bd7a439048a08f856c8334e81 Author: Attila Budai <[email protected]> AuthorDate: Mon Oct 13 23:02:30 2025 +0200 FINERACT-2326: fix delinquent days & delinquency date after delinquency pause calculations --- .../resources/features/LoanDelinquency.feature | 15 +- .../helper/DelinquencyEffectivePauseHelper.java | 3 + .../DelinquencyEffectivePauseHelperImpl.java | 24 + .../helper/InstallmentDelinquencyAggregator.java | 87 ++++ .../DelinquencyReadPlatformServiceImpl.java | 33 +- .../service/LoanDelinquencyDomainServiceImpl.java | 53 +-- .../InstallmentDelinquencyAggregatorTest.java | 278 ++++++++++++ .../LoanDelinquencyDomainServiceTest.java | 116 ++++- .../DelinquencyActionIntegrationTests.java | 496 +++++++++++++++++++++ .../DelinquencyBucketsIntegrationTest.java | 30 +- 10 files changed, 1061 insertions(+), 74 deletions(-) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature index 5967ea805c..d12375f371 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature @@ -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 diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java index e236d84222..a5722ff75c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java @@ -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); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java index eeb64bff5d..f400a836fd 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java @@ -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) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java new file mode 100644 index 0000000000..3ddcb36348 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java @@ -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; + }); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java index 4ad69707b3..6b9f27b194 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java @@ -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() // diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java index dd8a2218e6..e4fc1eb187 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java @@ -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); + } + } + } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java new file mode 100644 index 0000000000..16903d1eb4 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java @@ -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; + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java index 593a37bd00..cd213cbf46 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java @@ -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()); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java index 5d45fdd202..168a029c8b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java @@ -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"; + } + } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java index 6f7669bb35..f8853b9925 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java @@ -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(); }); }
