FINERACT-2114: Fix EMI adjustment when Interest Rate change has with Early Repayment Adjust Last case
This commit is contained in:
committed by
Adam Saghy
parent
425085f4eb
commit
75fa740ff6
+16
@@ -198,6 +198,14 @@ public class Money implements Comparable<Money> {
|
|||||||
return new Money(this.currencyCode, this.currencyDigitsAfterDecimal, this.amount.stripTrailingZeros(), this.inMultiplesOf, this.mc);
|
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<? extends Money> moniesToAdd) {
|
public Money plus(final Iterable<? extends Money> moniesToAdd) {
|
||||||
BigDecimal total = this.amount;
|
BigDecimal total = this.amount;
|
||||||
for (final Money moneyProvider : moniesToAdd) {
|
for (final Money moneyProvider : moniesToAdd) {
|
||||||
@@ -313,6 +321,14 @@ public class Money implements Comparable<Money> {
|
|||||||
return Money.of(monetaryCurrency(), newAmount, 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) {
|
public Money multipliedBy(final BigDecimal valueToMultiplyBy) {
|
||||||
return multipliedBy(valueToMultiplyBy, getMc());
|
return multipliedBy(valueToMultiplyBy, getMc());
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -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<RepaymentPeriod> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-35
@@ -34,6 +34,7 @@ import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
|
|||||||
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
|
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
|
||||||
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
|
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
|
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.InterestPeriod;
|
||||||
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OutstandingDetails;
|
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OutstandingDetails;
|
||||||
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.PeriodDueDetails;
|
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.PeriodDueDetails;
|
||||||
@@ -287,33 +288,16 @@ public final class ProgressiveEMICalculator implements EMICalculator {
|
|||||||
final List<RepaymentPeriod> relatedRepaymentPeriods) {
|
final List<RepaymentPeriod> relatedRepaymentPeriods) {
|
||||||
MathContext mc = scheduleModel.mc();
|
MathContext mc = scheduleModel.mc();
|
||||||
ProgressiveLoanInterestScheduleModel newScheduleModel = null;
|
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;
|
int adjustCounter = 0;
|
||||||
|
EmiAdjustment emiAdjustment;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
final Money emiDifference = getDifferenceBetweenLastTwoPeriod(relatedRepaymentPeriods, scheduleModel);
|
emiAdjustment = getEmiAdjustment(relatedRepaymentPeriods);
|
||||||
if (emiDifference.isZero(mc)) {
|
if (!emiAdjustment.shouldBeAdjusted()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final Money originalEmi = relatedRepaymentPeriods.get(numberOfRelatedPeriods - 2).getEmi();
|
Money adjustedEqualMonthlyInstallmentValue = applyInstallmentAmountInMultiplesOf(scheduleModel, emiAdjustment.adjustedEmi());
|
||||||
boolean shouldBeAdjusted = emiDifference.abs(mc).multipliedBy(100, mc)
|
if (adjustedEqualMonthlyInstallmentValue.isEqualTo(emiAdjustment.originalEmi())) {
|
||||||
.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)) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (newScheduleModel == null) {
|
if (newScheduleModel == null) {
|
||||||
@@ -329,9 +313,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
|
|||||||
});
|
});
|
||||||
calculateOutstandingBalance(newScheduleModel);
|
calculateOutstandingBalance(newScheduleModel);
|
||||||
calculateLastUnpaidRepaymentPeriodEMI(newScheduleModel);
|
calculateLastUnpaidRepaymentPeriodEMI(newScheduleModel);
|
||||||
final Money newEmiDifference = getDifferenceBetweenLastTwoPeriod(newScheduleModel.repaymentPeriods(), scheduleModel);
|
if (!getEmiAdjustment(newScheduleModel.repaymentPeriods()).hasLessEmiDifference(emiAdjustment)) {
|
||||||
final boolean newEmiHasLessDifference = newEmiDifference.abs(mc).isLessThan(emiDifference.abs(mc));
|
|
||||||
if (!newEmiHasLessDifference) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +331,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
|
|||||||
});
|
});
|
||||||
calculateOutstandingBalance(scheduleModel);
|
calculateOutstandingBalance(scheduleModel);
|
||||||
adjustCounter++;
|
adjustCounter++;
|
||||||
} while (uncountablePeriods > 0 && adjustCounter < 3);
|
} while (emiAdjustment.hasUncountablePeriods() && adjustCounter < 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,16 +482,17 @@ public final class ProgressiveEMICalculator implements EMICalculator {
|
|||||||
: equalMonthlyInstallment;
|
: equalMonthlyInstallment;
|
||||||
}
|
}
|
||||||
|
|
||||||
Money getDifferenceBetweenLastTwoPeriod(final List<RepaymentPeriod> repaymentPeriods,
|
public EmiAdjustment getEmiAdjustment(final List<RepaymentPeriod> repaymentPeriods) {
|
||||||
final ProgressiveLoanInterestScheduleModel scheduleModel) {
|
for (int idx = repaymentPeriods.size() - 1; idx > 0; --idx) {
|
||||||
MathContext mc = scheduleModel.mc();
|
RepaymentPeriod lastPeriod = repaymentPeriods.get(idx);
|
||||||
int numberOfUpcomingPeriods = repaymentPeriods.size();
|
RepaymentPeriod penultimatePeriod = repaymentPeriods.get(idx - 1);
|
||||||
if (numberOfUpcomingPeriods < 2) {
|
if (!lastPeriod.isFullyPaid() && !penultimatePeriod.isFullyPaid()) {
|
||||||
return Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency(), mc);
|
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);
|
return new EmiAdjustment(repaymentPeriods.get(0).getEmi(), repaymentPeriods.get(0).getEmi().copy(0.0), repaymentPeriods, 0);
|
||||||
final RepaymentPeriod penultimatePeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 2);
|
|
||||||
return lastPeriod.getEmi().minus(penultimatePeriod.getEmi(), mc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -734,4 +717,9 @@ public final class ProgressiveEMICalculator implements EMICalculator {
|
|||||||
.reduce(scheduleModel.getZero(), Money::add); //
|
.reduce(scheduleModel.getZero(), Money::add); //
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long getUncountablePeriods(final List<RepaymentPeriod> relatedRepaymentPeriods, final Money originalEmi) {
|
||||||
|
return relatedRepaymentPeriods.stream() //
|
||||||
|
.filter(repaymentPeriod -> originalEmi.isLessThan(repaymentPeriod.getTotalPaidAmount())) //
|
||||||
|
.count(); //
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
@@ -480,6 +480,53 @@ class ProgressiveEMICalculatorTest {
|
|||||||
checkPeriod(interestModel, 5, 0, 16.85, 0.003333333333, 0.06, 16.79, 0.0);
|
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<LoanScheduleModelRepaymentPeriod> 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
|
@Test
|
||||||
public void test_reschedule_interest_on0215_4per_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month() {
|
public void test_reschedule_interest_on0215_4per_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month() {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user