From c65a763c7feff9f0bdbd550b49fdf8b2835991b1 Mon Sep 17 00:00:00 2001 From: Peter Bagrij Date: Fri, 17 Nov 2023 12:30:15 +0100 Subject: [PATCH] FINERACT-1992 Installment level delinquency is exposed to the loan api. --- .../DelinquencyReadPlatformServiceImpl.java | 42 +++ .../DelinquencyWritePlatformServiceImpl.java | 3 + .../api/LoansApiResourceSwagger.java | 24 ++ .../loanaccount/data/CollectionData.java | 4 +- .../data/InstallmentLevelDelinquency.java | 44 +++ ...tePlatformServiceRangeChangeEventTest.java | 10 +- .../BaseLoanIntegrationTest.java | 3 + .../DelinquencyActionIntegrationTests.java | 1 - ...ntLevelDelinquencyAPIIntegrationTests.java | 289 ++++++++++++++++++ .../LoanInstallmentMultiplesOfTest.java | 2 - .../LoanRescheduleTestWithDownpayment.java | 1 - ...sbursalWithDownPaymentIntegrationTest.java | 1 - .../products/DelinquencyBucketsHelper.java | 22 +- .../products/DelinquencyRangesHelper.java | 2 +- 14 files changed, 435 insertions(+), 13 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/InstallmentLevelDelinquency.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/InstallmentLevelDelinquencyAPIIntegrationTests.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java index 4ce2c9f74..bfbe2b480 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java @@ -27,9 +27,11 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collector; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData; import org.apache.fineract.portfolio.delinquency.data.DelinquencyRangeData; @@ -50,9 +52,11 @@ import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper; import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper; import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; +import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.jetbrains.annotations.NotNull; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -141,11 +145,49 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo enrichWithDelinquencyPausePeriodInfo(collectionData, retrieveLoanDelinquencyActions(loanId), ThreadLocalContextUtil.getBusinessDate()); + + if (optLoan.get().isEnableInstallmentLevelDelinquency()) { + addInstallmentLevelDelinquencyData(collectionData, loanId); + } } return collectionData; } + private void addInstallmentLevelDelinquencyData(CollectionData collectionData, Long loanId) { + Collection loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag( + loanId); + if (loanInstallmentDelinquencyTagData != null && loanInstallmentDelinquencyTagData.size() > 0) { + + // installment level delinquency grouped by rangeId, and summed up the delinquent amount + Collection 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 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); + } + } + + @NotNull + private static Collector 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 delinquencyActions, LocalDate businessDate) { // partition them based on type diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java index af58629fe..f31ee95da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java @@ -502,6 +502,9 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat installmentDelinquencyData.getDelinquentAmount()); loanInstallmentDelinquencyTagRepository.delete(previousInstallmentDelinquencyTag); // event installment delinquency range change + } else { + previousInstallmentDelinquencyTag.setOutstandingAmount(installmentDelinquencyData.getDelinquentAmount()); + installmentDelinquency = previousInstallmentDelinquencyTag; } } else { // add new range, first time delinquent diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index df128b1e1..0f86f1544 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -999,6 +999,30 @@ final class LoansApiResourceSwagger { @Schema(example = "[2022, 07, 10]") public LocalDate delinquencyPausePeriodEndDate; + @Schema(description = "List of GetLoansLoanIdLoanInstallmentLevelDelinquency") + public List installmentLevelDelinquency; + + } + + static final class GetLoansLoanIdLoanInstallmentLevelDelinquency { + + private GetLoansLoanIdLoanInstallmentLevelDelinquency() {} + + @Schema(example = "112") + public Long rangeId; + + @Schema(example = "Delinquency Range 3 to 5 days") + public String classification; + + @Schema(example = "3") + public Integer minimumAgeDays; + + @Schema(example = "5") + public Integer maximumAgeDays; + + @Schema(example = "250.0") + public BigDecimal delinquentAmount; + } @Schema(example = "1") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java index 6087f19af..1b51d221c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.loanaccount.data; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.Collection; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -46,10 +47,11 @@ public final class CollectionData { public boolean delinquencyCalculationPaused; public LocalDate delinquencyPausePeriodStartDate; public LocalDate delinquencyPausePeriodEndDate; + public Collection installmentLevelDelinquency; public static CollectionData template() { final BigDecimal zero = BigDecimal.ZERO; - return new CollectionData(zero, 0L, null, 0L, null, zero, null, zero, null, zero, false, null, null); + return new CollectionData(zero, 0L, null, 0L, null, zero, null, zero, null, zero, false, null, null, null); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/InstallmentLevelDelinquency.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/InstallmentLevelDelinquency.java new file mode 100644 index 000000000..bbd6b806d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/InstallmentLevelDelinquency.java @@ -0,0 +1,44 @@ +/** + * 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.loanaccount.data; + +import java.math.BigDecimal; +import lombok.Data; +import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData; + +@Data +public class InstallmentLevelDelinquency { + + private Long rangeId; + private String classification; + private Integer minimumAgeDays; + private Integer maximumAgeDays; + private BigDecimal delinquentAmount; + + public static InstallmentLevelDelinquency from(LoanInstallmentDelinquencyTagData loanInstallmentDelinquencyTagData) { + InstallmentLevelDelinquency installmentLevelDelinquency = new InstallmentLevelDelinquency(); + installmentLevelDelinquency.setClassification(loanInstallmentDelinquencyTagData.getDelinquencyRange().getClassification()); + installmentLevelDelinquency.setRangeId(loanInstallmentDelinquencyTagData.getDelinquencyRange().getId()); + installmentLevelDelinquency.setMinimumAgeDays(loanInstallmentDelinquencyTagData.getDelinquencyRange().getMinimumAgeDays()); + installmentLevelDelinquency.setMaximumAgeDays(loanInstallmentDelinquencyTagData.getDelinquencyRange().getMaximumAgeDays()); + installmentLevelDelinquency.setDelinquentAmount(loanInstallmentDelinquencyTagData.getOutstandingAmount()); + return installmentLevelDelinquency; + } + +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java index d821b5f77..15e542ab5 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java @@ -127,7 +127,7 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, BigDecimal.ZERO, null, null, - null, null, false, null, null); + null, null, false, null, null, null); Map installmentsCollection = new HashMap<>(); @@ -239,10 +239,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, BigDecimal.ZERO, null, null, - null, null, false, null, null); + null, null, false, null, null, null); CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, - installmentPrincipalAmount, null, null, null, null, false, null, null); + installmentPrincipalAmount, null, null, null, null, false, null, null, null); Map installmentsCollection = new HashMap<>(); installmentsCollection.put(1L, installmentCollectionData); @@ -305,10 +305,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate, BigDecimal.ZERO, null, null, - null, null, false, null, null); + null, null, false, null, null, null); CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate, - installmentPrincipalAmount, null, null, null, null, false, null, null); + installmentPrincipalAmount, null, null, null, null, false, null, null, null); Map installmentsCollection = new HashMap<>(); installmentsCollection.put(1L, installmentCollectionData); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index 1c8168cad..e7c34f46b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -53,6 +53,7 @@ import org.apache.fineract.client.models.PostLoansRequest; import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.util.CallFailedRuntimeException; import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.accounting.Account; @@ -78,6 +79,8 @@ public abstract class BaseLoanIntegrationTest { protected final LoanTransactionHelper loanTransactionHelper = new LoanTransactionHelper(requestSpec, responseSpec); protected final LoanProductHelper loanProductHelper = new LoanProductHelper(); protected JournalEntryHelper journalEntryHelper = new JournalEntryHelper(requestSpec, responseSpec); + protected ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec); + protected BusinessDateHelper businessDateHelper = new BusinessDateHelper(); protected DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN); 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 262f106c1..aaea011da 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 @@ -51,7 +51,6 @@ import org.junit.jupiter.api.extension.ExtendWith; public class DelinquencyActionIntegrationTests extends BaseLoanIntegrationTest { public static final BigDecimal DOWN_PAYMENT_PERCENTAGE = new BigDecimal(25); - private final ClientHelper clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); @Test public void testCreateAndReadPauseDelinquencyAction() { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstallmentLevelDelinquencyAPIIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstallmentLevelDelinquencyAPIIntegrationTests.java new file mode 100644 index 000000000..d34ff454c --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstallmentLevelDelinquencyAPIIntegrationTests.java @@ -0,0 +1,289 @@ +/** + * 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.integrationtests; + +import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.fineract.client.models.BusinessDateRequest; +import org.apache.fineract.client.models.GetLoansLoanIdLoanInstallmentLevelDelinquency; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.SchedulerJobHelper; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@Slf4j +@ExtendWith(LoanTestLifecycleExtension.class) +public class InstallmentLevelDelinquencyAPIIntegrationTests extends BaseLoanIntegrationTest { + + private SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); + + @Test + public void testInstallmentLevelDelinquencyFourRangesInTheBucket() { + runAt("31 May 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create DelinquencyBuckets + Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(// + Pair.of(1, 10), // + Pair.of(11, 30), // + Pair.of(31, 60), // + Pair.of(61, null)// + )); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + loanProductsRequest.setDelinquencyBucketId(delinquencyBucketId.longValue()); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // 120 days delinquent -> range4 + installment(313.0, false, "02 March 2023"), // 90 days delinquent -> range4 + installment(313.0, false, "01 April 2023"), // 60 days delinquent -> range3 + installment(311.0, false, "01 May 2023") // 30 days delinquent -> range2 + ); + + // since the current day is 31 May 2023, therefore all the installments are delinquent + verifyDelinquency(loanId, 120, "1250.0", // + delinquency(11, 30, "311.0"), // 4th installment + delinquency(31, 60, "313.0"), // 3rd installment + delinquency(61, null, "626.0") // 1st installment + 2nd installment + ); + + // Repayment of the first two installments + addRepaymentForLoan(loanId, 626.0, "31 May 2023"); + verifyDelinquency(loanId, 60, "624.0", // + delinquency(11, 30, "311.0"), // 4th installment + delinquency(31, 60, "313.0") // 3rd installment + ); + + // Partial repayment + addRepaymentForLoan(loanId, 100.0, "31 May 2023"); + verifyDelinquency(loanId, 60, "524.0", // + delinquency(11, 30, "311.0"), // 4th installment + delinquency(31, 60, "213.0") // 3rd installment + ); + + // Repay the loan fully + addRepaymentForLoan(loanId, 524.0, "31 May 2023"); + verifyDelinquency(loanId, 0, "0.0"); + }); + } + + @Test + public void testInstallmentLevelDelinquencyTwoRangesInTheBucket() { + runAt("31 May 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create DelinquencyBuckets + Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(// + Pair.of(1, 60), // + Pair.of(61, null)// + )); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + loanProductsRequest.setDelinquencyBucketId(delinquencyBucketId.longValue()); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // 120 days delinquent -> range2 + installment(313.0, false, "02 March 2023"), // 90 days delinquent -> range2 + installment(313.0, false, "01 April 2023"), // 60 days delinquent -> range1 + installment(311.0, false, "01 May 2023") // 30 days delinquent -> range1 + ); + + verifyDelinquency(loanId, 120, "1250.0", // + delinquency(1, 60, "624.0"), // 4th installment + delinquency(61, null, "626.0") // 1st installment + 2nd installment + ); + + // repay the first installment + addRepaymentForLoan(loanId, 313.0, "31 May 2023"); + + verifyDelinquency(loanId, 90, "937.0", // + delinquency(1, 60, "624.0"), // 4th installment + delinquency(61, null, "313.0") // 1st installment + 2nd installment + ); + + }); + } + + @Test + public void testInstallmentLevelDelinquencyIsTurnedOff() { + runAt("31 May 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create DelinquencyBuckets + Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(// + Pair.of(1, 60), // + Pair.of(61, null)// + )); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(false); + loanProductsRequest.setDelinquencyBucketId(delinquencyBucketId.longValue()); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // 120 days delinquent -> range2 + installment(313.0, false, "02 March 2023"), // 90 days delinquent -> range2 + installment(313.0, false, "01 April 2023"), // 60 days delinquent -> range1 + installment(311.0, false, "01 May 2023") // 30 days delinquent -> range1 + ); + + // this should be empty as the installment level delinquency is not enabled for this loan + verifyDelinquency(loanId, 120, "1250.0"); + }); + } + + @Test + public void testInstallmentLevelDelinquencyUpdatedWhenCOBIsExecuted() { + runAt("01 February 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create DelinquencyBuckets + Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(// + Pair.of(1, 1), // + Pair.of(2, null)// + )); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + loanProductsRequest.setDelinquencyBucketId(delinquencyBucketId.longValue()); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), installment(313.0, false, "31 January 2023"), + installment(313.0, false, "02 March 2023"), installment(313.0, false, "01 April 2023"), + installment(311.0, false, "01 May 2023")); + + // The first installment falls into the first range + verifyDelinquency(loanId, 1, "313.0", // + delinquency(1, 1, "313.0") // 4th installment + ); + + // Let's go one day ahead in the time + updateBusinessDateAndExecuteCOBJob("2 February 2023"); + + // The first installment is not two days delinquent and therefore falls into the second range + verifyDelinquency(loanId, 2, "313.0", // + delinquency(2, null, "313.0") // 4th installment + ); + }); + } + + private void updateBusinessDateAndExecuteCOBJob(String date) { + businessDateHelper.updateBusinessDate( + new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en")); + schedulerJobHelper.executeAndAwaitJob("Loan COB"); + } + + @AllArgsConstructor + public static class DelinquencyData { + + Integer minAgeDays; + Integer maxAgeDays; + BigDecimal delinquentAmount; + } + + private static DelinquencyData delinquency(Integer minAgeDays, Integer maxAgeDays, String delinquentAmount) { + return new DelinquencyData(minAgeDays, maxAgeDays, new BigDecimal(delinquentAmount)); + } + + private void verifyDelinquency(Long loanId, Integer loanLevelDelinquentDays, String loanLevelDelinquentAmount, + DelinquencyData... expectedInstallmentLevelDelinquencyData) { + GetLoansLoanIdResponse loan = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); + assertThat(loan.getDelinquent()).isNotNull(); + List installmentLevelDelinquency = loan.getDelinquent() + .getInstallmentLevelDelinquency(); + + assertThat(loan.getDelinquent().getDelinquentDays()).isEqualTo(loanLevelDelinquentDays); + assertThat(loan.getDelinquent().getDelinquentAmount()).isEqualByComparingTo(Double.valueOf(loanLevelDelinquentAmount)); + + if (expectedInstallmentLevelDelinquencyData != null && expectedInstallmentLevelDelinquencyData.length > 0) { + assertThat(installmentLevelDelinquency).isNotNull(); + assertThat(installmentLevelDelinquency).hasSize(expectedInstallmentLevelDelinquencyData.length); + for (int i = 0; i < expectedInstallmentLevelDelinquencyData.length; i++) { + assertThat(installmentLevelDelinquency.get(i).getMaximumAgeDays()) + .isEqualTo(expectedInstallmentLevelDelinquencyData[i].maxAgeDays); + assertThat(installmentLevelDelinquency.get(i).getMinimumAgeDays()) + .isEqualTo(expectedInstallmentLevelDelinquencyData[i].minAgeDays); + assertThat(installmentLevelDelinquency.get(i).getDelinquentAmount()) + .isEqualByComparingTo(expectedInstallmentLevelDelinquencyData[i].delinquentAmount); + } + } else { + assertThat(installmentLevelDelinquency).isNull(); + } + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java index bfdccced0..b7cd5c5d1 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java @@ -36,8 +36,6 @@ import org.junit.jupiter.params.provider.MethodSource; public class LoanInstallmentMultiplesOfTest extends BaseLoanIntegrationTest { - private final ClientHelper clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); - private static Stream interestTypes() { return Stream.of(Arguments.of(Named.of("DECLINING_BALANCE", InterestType.DECLINING_BALANCE)), // Arguments.of(Named.of("FLAT", InterestType.FLAT))); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRescheduleTestWithDownpayment.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRescheduleTestWithDownpayment.java index e1567eafc..a17929c17 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRescheduleTestWithDownpayment.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRescheduleTestWithDownpayment.java @@ -37,7 +37,6 @@ import org.junit.jupiter.api.extension.ExtendWith; public class LoanRescheduleTestWithDownpayment extends BaseLoanIntegrationTest { public static final BigDecimal DOWN_PAYMENT_PERCENTAGE = new BigDecimal(25); - private final ClientHelper clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); private final LoanRescheduleRequestHelper loanRescheduleRequestHelper = new LoanRescheduleRequestHelper(this.requestSpec, this.responseSpec); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/UndoLoanDisbursalWithDownPaymentIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/UndoLoanDisbursalWithDownPaymentIntegrationTest.java index 9091ba30d..b95b44ad5 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/UndoLoanDisbursalWithDownPaymentIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/UndoLoanDisbursalWithDownPaymentIntegrationTest.java @@ -37,7 +37,6 @@ import org.junit.jupiter.api.extension.ExtendWith; public class UndoLoanDisbursalWithDownPaymentIntegrationTest extends BaseLoanIntegrationTest { public static final BigDecimal DOWN_PAYMENT_PERCENTAGE = new BigDecimal(25); - private final ClientHelper clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); @Test public void testUndoDisbursalForLoanWithSingleDisbursalAutoDownPaymentEnabledAndNoManualTransactions() { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyBucketsHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyBucketsHelper.java index 295322beb..32efd26da 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyBucketsHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyBucketsHelper.java @@ -29,7 +29,9 @@ import io.restassured.specification.ResponseSpecification; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.apache.fineract.client.models.DeleteDelinquencyBucketResponse; import org.apache.fineract.client.models.GetDelinquencyBucketsResponse; import org.apache.fineract.client.models.GetDelinquencyRangesResponse; @@ -87,13 +89,31 @@ public class DelinquencyBucketsHelper { return GSON.fromJson(response, DeleteDelinquencyBucketResponse.class); } - public static String getAsJSON(final ArrayList rangeIds) { + public static String getAsJSON(final List rangeIds) { final HashMap map = new HashMap<>(); map.put("name", Utils.uniqueRandomStringGenerator("Delinquency_Bucket_", 4)); map.put("ranges", rangeIds.toArray()); return new Gson().toJson(map); } + public static Integer createDelinquencyBucket(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, + List> ranges) { + List rangeIds = ranges.stream().map(r -> createDelinquencyRange(requestSpec, responseSpec, r)).toList(); + String jsonBucket = DelinquencyBucketsHelper.getAsJSON(rangeIds); + PostDelinquencyBucketResponse delinquencyBucketResponse = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, + responseSpec, jsonBucket); + assertNotNull(delinquencyBucketResponse); + return delinquencyBucketResponse.getResourceId(); + } + + public static Integer createDelinquencyRange(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, + Pair range) { + String jsonRange = DelinquencyRangesHelper.getAsJSON(range.getLeft(), range.getRight()); + PostDelinquencyRangeResponse delinquencyRangeResponse = DelinquencyRangesHelper.createDelinquencyRange(requestSpec, responseSpec, + jsonRange); + return delinquencyRangeResponse.getResourceId(); + } + public static Integer createDelinquencyBucket(final RequestSpecification requestSpec, final ResponseSpecification responseSpec) { ArrayList rangeIds = new ArrayList<>(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyRangesHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyRangesHelper.java index 645b7282d..220f0ec67 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyRangesHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/products/DelinquencyRangesHelper.java @@ -83,7 +83,7 @@ public class DelinquencyRangesHelper { return GSON.fromJson(response, DeleteDelinquencyRangeResponse.class); } - public static String getAsJSON(int minimumAgeDays, int maximumAgeDays) { + public static String getAsJSON(Integer minimumAgeDays, Integer maximumAgeDays) { final HashMap map = new HashMap<>(); map.put("classification", Utils.uniqueRandomStringGenerator("Delinquency__" + minimumAgeDays + "_" + maximumAgeDays + "__", 4)); map.put("minimumAgeDays", minimumAgeDays);