From 04ec56382aeba5a052516ba176f2737abda78417 Mon Sep 17 00:00:00 2001 From: Kristof Jozsa Date: Wed, 25 Sep 2024 18:31:18 +0200 Subject: [PATCH] FINERACT-1981: interest recalculation fix for overdue cases --- .../stepdef/common/BusinessStepStepDef.java | 57 +++++++++++++++ .../src/test/resources/features/Loan.feature | 71 +++++++++++++++++++ ...edPaymentScheduleTransactionProcessor.java | 38 +++++----- .../LoanInterestRecalculationCOBTest.java | 14 ++++ 4 files changed, 164 insertions(+), 16 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/BusinessStepStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/BusinessStepStepDef.java index 9d92fa26504..9e32c13740c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/BusinessStepStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/BusinessStepStepDef.java @@ -22,8 +22,12 @@ import io.cucumber.java.en.Then; import java.io.IOException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.models.BusinessStep; +import org.apache.fineract.client.models.GetBusinessStepConfigResponse; import org.apache.fineract.client.models.UpdateBusinessStepConfigRequest; import org.apache.fineract.client.services.BusinessStepConfigurationApi; import org.apache.fineract.test.helper.ErrorHelper; @@ -31,6 +35,7 @@ import org.springframework.beans.factory.annotation.Autowired; import retrofit2.Response; +@Slf4j public class BusinessStepStepDef extends AbstractStepDef { private static final String WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS = "LOAN_CLOSE_OF_BUSINESS"; @@ -149,4 +154,56 @@ public void removeCheckDueInstallmentsJobInCOB() throws IOException { .execute(); ErrorHelper.checkSuccessfulApiCall(response); } + + @Given("Admin puts {string} business step into LOAN_CLOSE_OF_BUSINESS workflow") + public void putGivenJobInCOB(String businessStepName) throws IOException { + List businessSteps = retrieveLoanCOBJobSteps(); + if (businessSteps.stream().anyMatch(businessStep -> businessStep.getStepName().equals(businessStepName))) { + return; + } + + businessSteps.add(new BusinessStep().stepName(businessStepName).order((long) (1 + businessSteps.size()))); + + UpdateBusinessStepConfigRequest request = new UpdateBusinessStepConfigRequest().businessSteps(businessSteps); + Response response = businessStepConfigurationApi.updateJobBusinessStepConfig(WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS, request) + .execute(); + ErrorHelper.checkSuccessfulApiCall(response); + + logChanges(); + } + + @Then("Admin removes {string} business step into LOAN_CLOSE_OF_BUSINESS workflow") + public void removeGivenJobInCOB(String businessStepName) throws IOException { + List businessSteps = retrieveLoanCOBJobSteps(); + businessSteps.removeIf(businessStep -> businessStep.getStepName().equals(businessStepName)); + + UpdateBusinessStepConfigRequest request = new UpdateBusinessStepConfigRequest().businessSteps(businessSteps); + Response response = businessStepConfigurationApi.updateJobBusinessStepConfig(WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS, request) + .execute(); + ErrorHelper.checkSuccessfulApiCall(response); + + logChanges(); + } + + private List retrieveLoanCOBJobSteps() throws IOException { + Response businessStepConfigResponse = businessStepConfigurationApi + .retrieveAllConfiguredBusinessStep(WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS).execute(); + ErrorHelper.checkSuccessfulApiCall(businessStepConfigResponse); + return businessStepConfigResponse.body().getBusinessSteps(); + } + + private void logChanges() throws IOException { + // --- log changes --- + Response changesResponse = businessStepConfigurationApi + .retrieveAllConfiguredBusinessStep(WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS).execute(); + List businessStepsChanged = changesResponse.body().getBusinessSteps(); + List changes = businessStepsChanged// + .stream()// + .sorted(Comparator.comparingLong(BusinessStep::getOrder))// + .map(BusinessStep::getStepName)// + .collect(Collectors.toList());// + + log.info("Business steps has been CHANGED to the following:"); + changes.forEach(e -> log.info(e)); + } } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature index 60041ca1572..ccd8c5f16cb 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature @@ -5657,3 +5657,74 @@ Feature: Loan | 15 February 2024 | Repayment | 84.06 | 83.57 | 0.49 | 0.0 | 0.0 | 0.0 | false | Then Loan's all installments have obligations met When Admin set "LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY" loan product "DEFAULT" transaction type to "LAST_INSTALLMENT" future installment allocation rule + + Scenario: Interest recalculation - S1 daily for overdue loan + Given Global configuration "enable_business_date" is enabled + When Admin sets the business date to "1 January 2024" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE | 01 January 2024 | 100 | 7.0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2024" with "100" amount and expected disbursement date on "1 January 2024" + When Admin successfully disburse the loan on "1 January 2024" with "100" EUR transaction amount + When Admin sets the business date to "15 July 2024" + When Admin puts "LOAN_INTEREST_RECALCULATION" business step into LOAN_CLOSE_OF_BUSINESS workflow + When Admin runs inline COB job for Loan + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.57 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 2 | 29 | 01 March 2024 | | 67.14 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 3 | 31 | 01 April 2024 | | 50.71 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 4 | 30 | 01 May 2024 | | 34.28 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 5 | 31 | 01 June 2024 | | 17.85 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.85 | 0.58 | 0.0 | 0.0 | 18.43 | 0.0 | 0.0 | 0.0 | 18.43 | + When Admin removes "LOAN_INTEREST_RECALCULATION" business step into LOAN_CLOSE_OF_BUSINESS workflow + + Scenario: Interest recalculation - S2 2 overdue + Given Global configuration "enable_business_date" is enabled + When Admin sets the business date to "1 January 2024" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE | 01 January 2024 | 100 | 7.0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2024" with "100" amount and expected disbursement date on "1 January 2024" + When Admin successfully disburse the loan on "1 January 2024" with "100" EUR transaction amount + When Admin sets the business date to "10 March 2024" + When Admin puts "LOAN_INTEREST_RECALCULATION" business step into LOAN_CLOSE_OF_BUSINESS workflow + When Admin runs inline COB job for Loan + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.57 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 2 | 29 | 01 March 2024 | | 67.14 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 3 | 31 | 01 April 2024 | | 50.58 | 16.56 | 0.45 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 4 | 30 | 01 May 2024 | | 33.87 | 16.71 | 0.3 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 5 | 31 | 01 June 2024 | | 17.06 | 16.81 | 0.2 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.06 | 0.1 | 0.0 | 0.0 | 17.16 | 0.0 | 0.0 | 0.0 | 17.16 | + When Admin removes "LOAN_INTEREST_RECALCULATION" business step into LOAN_CLOSE_OF_BUSINESS workflow + + Scenario: Interest recalculation - S3 1 paid, 1 overdue + Given Global configuration "enable_business_date" is enabled + When Admin sets the business date to "1 January 2024" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE | 01 January 2024 | 100 | 7.0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2024" with "100" amount and expected disbursement date on "1 January 2024" + When Admin successfully disburse the loan on "1 January 2024" with "100" EUR transaction amount + When Admin sets the business date to "1 February 2024" + And Customer makes "AUTOPAY" repayment on "01 February 2024" with 17.01 EUR transaction amount + When Admin sets the business date to "10 March 2024" + When Admin puts "LOAN_INTEREST_RECALCULATION" business step into LOAN_CLOSE_OF_BUSINESS workflow + When Admin runs inline COB job for Loan + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | 01 February 2024 | 83.57 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 17.01 | 0.0 | 0.0 | 0.0 | + | 2 | 29 | 01 March 2024 | | 67.05 | 16.52 | 0.49 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 3 | 31 | 01 April 2024 | | 50.46 | 16.59 | 0.42 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 4 | 30 | 01 May 2024 | | 33.74 | 16.72 | 0.29 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 5 | 31 | 01 June 2024 | | 16.93 | 16.81 | 0.2 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | + | 6 | 30 | 01 July 2024 | | 0.0 | 16.93 | 0.1 | 0.0 | 0.0 | 17.03 | 0.0 | 0.0 | 0.0 | 17.03 | + When Admin removes "LOAN_INTEREST_RECALCULATION" business step into LOAN_CLOSE_OF_BUSINESS workflow diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 22a8c3a5c71..2ea034dc44d 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -871,9 +871,8 @@ private void allocateOverpayment(LoanTransaction loanTransaction, TransactionCtx private List findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(LocalDate currentDate, ProgressiveTransactionCtx transactionCtx) { - final LocalDate fromDate = transactionCtx.getInstallments().get(0).getLoan().getApprovedOnDate(); - return transactionCtx.getInstallments().stream().filter(installment -> !installment.getFromDate().isBefore(fromDate)) - .filter(installment -> installment.getDueDate().isBefore(currentDate)) + return transactionCtx.getInstallments().stream() // + .filter(installment -> !installment.isDownPayment() && !installment.isAdditional()) .filter(installment -> installment.isOverdueOn(currentDate)) .sorted(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).toList(); } @@ -885,27 +884,33 @@ private void recalculateInterestForDate(LocalDate currentDate, ProgressiveTransa List overdueInstallmentsSortedByInstallmentNumber = findOverdueInstallmentsBeforeDateSortedByInstallmentNumber( currentDate, ctx); if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) { - List possibleCurrentInstallment = ctx.getInstallments().stream().filter( + List normalInstallments = ctx.getInstallments().stream() // + .filter(installment -> !installment.isAdditional() && !installment.isDownPayment()).toList(); + + Optional currentInstallmentOptional = normalInstallments.stream().filter( installment -> installment.getFromDate().isBefore(currentDate) && !installment.getDueDate().isBefore(currentDate)) - .toList(); + .findAny(); // get DUE installment or last installment - LoanRepaymentScheduleInstallment currentInstallment = !possibleCurrentInstallment.isEmpty() - ? possibleCurrentInstallment.get(0) - : ctx.getInstallments().stream().max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)) - .orElseThrow(); + LoanRepaymentScheduleInstallment lastInstallment = normalInstallments.stream() + .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).get(); + LoanRepaymentScheduleInstallment currentInstallment = currentInstallmentOptional.orElse(lastInstallment); Money overDuePrincipal = Money.zero(ctx.getCurrency()); for (LoanRepaymentScheduleInstallment processingInstallment : overdueInstallmentsSortedByInstallmentNumber) { // add and subtract outstanding principal - if (overDuePrincipal.compareTo(Money.zero(ctx.getCurrency())) != 0) { + if (!overDuePrincipal.isZero()) { adjustOverduePrincipalForInstallment(currentDate, isLastRecalculation, processingInstallment, overDuePrincipal, ctx); } overDuePrincipal = overDuePrincipal.add(processingInstallment.getPrincipalOutstanding(ctx.getCurrency()).getAmount()); } - adjustOverduePrincipalForInstallment(currentDate, isLastRecalculation, currentInstallment, overDuePrincipal, ctx); + + boolean adjustNeeded = !currentInstallment.equals(lastInstallment) || !lastInstallment.isOverdueOn(currentDate); + if (adjustNeeded) { + adjustOverduePrincipalForInstallment(currentDate, isLastRecalculation, currentInstallment, overDuePrincipal, ctx); + } } } } @@ -916,7 +921,7 @@ private void adjustOverduePrincipalForInstallment(LocalDate currentDate, boolean boolean hasUpdate = false; if (currentInstallment.getLoan().getLoanInterestRecalculationDetails().getRestFrequencyType().isSameAsRepayment()) { - // if we have same date for fromDate & last overdue balance change then it meas we have the up-to-date + // if we have same date for fromDate & last overdue balance change then it means we have the up-to-date // model. if (ctx.getLastOverdueBalanceChange() == null || fromDate.isAfter(ctx.getLastOverdueBalanceChange())) { emiCalculator.addBalanceCorrection(ctx.getModel(), fromDate, overduePrincipal); @@ -954,12 +959,13 @@ private void adjustOverduePrincipalForInstallment(LocalDate currentDate, boolean } private void updateInstallmentsPrincipalAndInterestByModel(ProgressiveTransactionCtx ctx) { - ctx.getModel().repayments().forEach(rm -> { + ctx.getModel().repayments().forEach(repayment -> { LoanRepaymentScheduleInstallment installment = ctx.getInstallments().stream() - .filter(ri -> !ri.isDownPayment() && Objects.equals(ri.getFromDate(), rm.getFromDate())).findFirst().orElse(null); + .filter(ri -> !ri.isDownPayment() && Objects.equals(ri.getFromDate(), repayment.getFromDate())) // + .findFirst().orElse(null); if (installment != null) { - installment.updatePrincipal(rm.getPrincipalDue().getAmount()); - installment.updateInterestCharged(rm.getInterestDue().getAmount()); + installment.updatePrincipal(repayment.getPrincipalDue().getAmount()); + installment.updateInterestCharged(repayment.getInterestDue().getAmount()); installment.setRecalculatedInterestComponent(true); } }); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRecalculationCOBTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRecalculationCOBTest.java index 9fad3ab8113..3378f7b5c95 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRecalculationCOBTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRecalculationCOBTest.java @@ -168,6 +168,20 @@ public void verifyLoanInstallmentRecalculatedIfThereIsOverdueInstallmentOn4IProg validateFullyUnpaidRepaymentPeriod(loanDetails, 3, "01 April 2023", 2008.09, 0.0, 0.0, 33.75); validateFullyUnpaidRepaymentPeriod(loanDetails, 4, "01 May 2023", 2041.57, 0.0, 0.0, 17.01); + }); + runAt("20 April 2023", () -> { + Long loanId = loanIdRef.get(); + + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + logLoanDetails(loanDetails); + + validateFullyUnpaidRepaymentPeriod(loanDetails, 1, "01 February 2023", 1975.17, 0.0, 0.0, 66.67); + validateFullyUnpaidRepaymentPeriod(loanDetails, 2, "01 March 2023", 1975.17, 0.0, 0.0, 66.67); + validateFullyUnpaidRepaymentPeriod(loanDetails, 3, "01 April 2023", 1975.17, 0.0, 0.0, 66.67); + validateFullyUnpaidRepaymentPeriod(loanDetails, 4, "01 May 2023", 2074.49, 0.0, 0.0, 48.56); + }); payoffOnDateAndVerifyStatus("1 February 2023", loanIdRef.get()); }