From 75fa740ff66aa3e7ff67a0bde0504526fc71840e Mon Sep 17 00:00:00 2001 From: Janos Meszaros Date: Wed, 20 Nov 2024 17:28:25 +0100 Subject: [PATCH] FINERACT-2114: Fix EMI adjustment when Interest Rate change has with Early Repayment Adjust Last case --- .../organisation/monetary/domain/Money.java | 16 +++++ .../loanschedule/data/EmiAdjustment.java | 57 ++++++++++++++++++ .../calc/ProgressiveEMICalculator.java | 58 ++++++++----------- .../calc/ProgressiveEMICalculatorTest.java | 47 +++++++++++++++ 4 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/EmiAdjustment.java diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/Money.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/Money.java index c157a5486db..e70974cc6ad 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/Money.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/Money.java @@ -198,6 +198,14 @@ public Money copy() { return new Money(this.currencyCode, this.currencyDigitsAfterDecimal, this.amount.stripTrailingZeros(), this.inMultiplesOf, this.mc); } + public Money copy(final BigDecimal amount) { + return new Money(this.currencyCode, this.currencyDigitsAfterDecimal, amount.stripTrailingZeros(), this.inMultiplesOf, this.mc); + } + + public Money copy(final double amount) { + return copy(BigDecimal.valueOf(amount)); + } + public Money plus(final Iterable moniesToAdd) { BigDecimal total = this.amount; for (final Money moneyProvider : moniesToAdd) { @@ -313,6 +321,14 @@ public Money dividedBy(final long valueToDivideBy, final MathContext mc) { return Money.of(monetaryCurrency(), newAmount, mc); } + public Money dividedBy(final long valueToDivideBy) { + if (valueToDivideBy == 1) { + return this; + } + final BigDecimal newAmount = this.amount.divide(BigDecimal.valueOf(valueToDivideBy), getMc()); + return Money.of(monetaryCurrency(), newAmount, getMc()); + } + public Money multipliedBy(final BigDecimal valueToMultiplyBy) { return multipliedBy(valueToMultiplyBy, getMc()); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/EmiAdjustment.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/EmiAdjustment.java new file mode 100644 index 00000000000..0766b5213a0 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/EmiAdjustment.java @@ -0,0 +1,57 @@ +/** + * 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.loanschedule.data; + +import java.util.List; +import org.apache.fineract.organisation.monetary.domain.Money; + +public record EmiAdjustment(// + Money originalEmi, // + Money emiDifference, // + List relatedRepaymentPeriods, // + long uncountablePeriods// +) { + + public boolean shouldBeAdjusted() { + double lowerHalfOfRelatedPeriods = Math.floor(numberOfRelatedPeriods() / 2.0); + return lowerHalfOfRelatedPeriods > 0.0 && !emiDifference.isZero() && emiDifference.abs() // + .multipliedBy(100) // + .isGreaterThan(originalEmi.copy(lowerHalfOfRelatedPeriods)); // + } + + public Money adjustment() { + return emiDifference.dividedBy(Math.max(1, numberOfRelatedPeriods() - uncountablePeriods)); + } + + public Money adjustedEmi() { + return originalEmi.plus(adjustment()); + } + + public boolean hasLessEmiDifference(EmiAdjustment previousAdjustment) { + return emiDifference.abs().isLessThan(previousAdjustment.emiDifference.abs()); + } + + public boolean hasUncountablePeriods() { + return uncountablePeriods > 0; + } + + private int numberOfRelatedPeriods() { + return relatedRepaymentPeriods.size(); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index e9d21cf85f7..87a6e8c06db 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -34,6 +34,7 @@ import org.apache.fineract.portfolio.common.domain.DaysInYearType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.EmiAdjustment; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.InterestPeriod; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OutstandingDetails; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.PeriodDueDetails; @@ -287,33 +288,16 @@ private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final Progressiv final List relatedRepaymentPeriods) { MathContext mc = scheduleModel.mc(); ProgressiveLoanInterestScheduleModel newScheduleModel = null; - - final int numberOfRelatedPeriods = relatedRepaymentPeriods.size(); - double lowerHalfOfRelatedPeriods = Math.floor(numberOfRelatedPeriods / 2.0); - if (lowerHalfOfRelatedPeriods == 0.0) { - return; - } - - long uncountablePeriods; int adjustCounter = 0; + EmiAdjustment emiAdjustment; do { - final Money emiDifference = getDifferenceBetweenLastTwoPeriod(relatedRepaymentPeriods, scheduleModel); - if (emiDifference.isZero(mc)) { + emiAdjustment = getEmiAdjustment(relatedRepaymentPeriods); + if (!emiAdjustment.shouldBeAdjusted()) { break; } - final Money originalEmi = relatedRepaymentPeriods.get(numberOfRelatedPeriods - 2).getEmi(); - boolean shouldBeAdjusted = emiDifference.abs(mc).multipliedBy(100, mc) - .isGreaterThan(Money.of(originalEmi.getCurrency(), BigDecimal.valueOf(lowerHalfOfRelatedPeriods), mc)); - if (!shouldBeAdjusted) { - break; - } - - uncountablePeriods = relatedRepaymentPeriods.stream().filter(rp -> originalEmi.isLessThan(rp.getTotalPaidAmount())).count(); - Money adjustment = emiDifference.dividedBy(Math.max(1, numberOfRelatedPeriods - uncountablePeriods), mc); - Money adjustedEqualMonthlyInstallmentValue = applyInstallmentAmountInMultiplesOf(scheduleModel, - originalEmi.plus(adjustment, mc)); - if (adjustedEqualMonthlyInstallmentValue.isEqualTo(originalEmi)) { + Money adjustedEqualMonthlyInstallmentValue = applyInstallmentAmountInMultiplesOf(scheduleModel, emiAdjustment.adjustedEmi()); + if (adjustedEqualMonthlyInstallmentValue.isEqualTo(emiAdjustment.originalEmi())) { break; } if (newScheduleModel == null) { @@ -329,9 +313,7 @@ private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final Progressiv }); calculateOutstandingBalance(newScheduleModel); calculateLastUnpaidRepaymentPeriodEMI(newScheduleModel); - final Money newEmiDifference = getDifferenceBetweenLastTwoPeriod(newScheduleModel.repaymentPeriods(), scheduleModel); - final boolean newEmiHasLessDifference = newEmiDifference.abs(mc).isLessThan(emiDifference.abs(mc)); - if (!newEmiHasLessDifference) { + if (!getEmiAdjustment(newScheduleModel.repaymentPeriods()).hasLessEmiDifference(emiAdjustment)) { break; } @@ -349,7 +331,7 @@ private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final Progressiv }); calculateOutstandingBalance(scheduleModel); adjustCounter++; - } while (uncountablePeriods > 0 && adjustCounter < 3); + } while (emiAdjustment.hasUncountablePeriods() && adjustCounter < 3); } /** @@ -500,16 +482,17 @@ Money applyInstallmentAmountInMultiplesOf(final ProgressiveLoanInterestScheduleM : equalMonthlyInstallment; } - Money getDifferenceBetweenLastTwoPeriod(final List repaymentPeriods, - final ProgressiveLoanInterestScheduleModel scheduleModel) { - MathContext mc = scheduleModel.mc(); - int numberOfUpcomingPeriods = repaymentPeriods.size(); - if (numberOfUpcomingPeriods < 2) { - return Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency(), mc); + public EmiAdjustment getEmiAdjustment(final List repaymentPeriods) { + for (int idx = repaymentPeriods.size() - 1; idx > 0; --idx) { + RepaymentPeriod lastPeriod = repaymentPeriods.get(idx); + RepaymentPeriod penultimatePeriod = repaymentPeriods.get(idx - 1); + if (!lastPeriod.isFullyPaid() && !penultimatePeriod.isFullyPaid()) { + Money emiDifference = lastPeriod.getEmi().minus(penultimatePeriod.getEmi()); + return new EmiAdjustment(penultimatePeriod.getEmi(), emiDifference, repaymentPeriods, + getUncountablePeriods(repaymentPeriods, penultimatePeriod.getEmi())); + } } - final RepaymentPeriod lastPeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 1); - final RepaymentPeriod penultimatePeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 2); - return lastPeriod.getEmi().minus(penultimatePeriod.getEmi(), mc); + return new EmiAdjustment(repaymentPeriods.get(0).getEmi(), repaymentPeriods.get(0).getEmi().copy(0.0), repaymentPeriods, 0); } /** @@ -734,4 +717,9 @@ public Money getSumOfDueInterestsOnDate(ProgressiveLoanInterestScheduleModel sch .reduce(scheduleModel.getZero(), Money::add); // } + private long getUncountablePeriods(final List relatedRepaymentPeriods, final Money originalEmi) { + return relatedRepaymentPeriods.stream() // + .filter(repaymentPeriod -> originalEmi.isLessThan(repaymentPeriod.getTotalPaidAmount())) // + .count(); // + } } diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java index 507b8ab08a6..8c0686acf30 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java @@ -480,6 +480,53 @@ public void test_reschedule_interest_on0201_2nd_EMI_not_changeable_disbursedAmt1 checkPeriod(interestModel, 5, 0, 16.85, 0.003333333333, 0.06, 16.79, 0.0); } + @Test + public void test_reschedule_interest_on0120_adjsLst_dsbAmt100_dayInYears360_daysInMonth30_rpEvery1M() { + + final List expectedRepaymentPeriods = new ArrayList<>(); + + expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); + expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); + expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); + expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); + expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); + expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); + + final BigDecimal interestRate = BigDecimal.valueOf(7.0); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); + + threadLocalContextUtil.when(ThreadLocalContextUtil::getBusinessDate).thenReturn(LocalDate.of(2024, 2, 14)); + + final ProgressiveLoanInterestScheduleModel interestModel = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + final Money disbursedAmount = toMoney(100.0); + emiCalculator.addDisbursement(interestModel, LocalDate.of(2024, 1, 1), disbursedAmount); + + emiCalculator.payPrincipal(interestModel, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 1, 15), toMoney(17.01)); + + final BigDecimal interestRateNewValue = BigDecimal.valueOf(4.0); + final LocalDate interestChangeDate = LocalDate.of(2024, 1, 20); + emiCalculator.changeInterestRate(interestModel, interestChangeDate, interestRateNewValue); + + checkPeriod(interestModel, 0, 0, 16.80, 0.0, 0.0, 0.44, 16.36, 66.63); + checkPeriod(interestModel, 0, 1, 16.80, 0.002634408602, 0.26, 0.44, 16.36, 66.63); + checkPeriod(interestModel, 0, 2, 16.80, 0.000752688172, 0.06, 0.44, 16.36, 66.63); + checkPeriod(interestModel, 0, 3, 16.80, 0.001397849462, 0.12, 0.44, 16.36, 66.63); + checkPeriod(interestModel, 1, 0, 16.80, 0.003333333333, 0.22, 16.58, 50.05); + checkPeriod(interestModel, 2, 0, 16.80, 0.003333333333, 0.17, 16.63, 33.42); + checkPeriod(interestModel, 3, 0, 16.80, 0.003333333333, 0.11, 16.69, 16.73); + checkPeriod(interestModel, 4, 0, 16.79, 0.003333333333, 0.06, 16.73, 0.0); + checkPeriod(interestModel, 5, 0, 17.01, 0.003333333333, 0.0, 17.01, 0.0); + } + @Test public void test_reschedule_interest_on0215_4per_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month() {