Skip to content

Commit

Permalink
FINERACT-2114: Fix EMI adjustment when Interest Rate change has with …
Browse files Browse the repository at this point in the history
…Early Repayment Adjust Last case
  • Loading branch information
janez89 authored and adamsaghy committed Nov 21, 2024
1 parent 425085f commit 75fa740
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<? extends Money> moniesToAdd) {
BigDecimal total = this.amount;
for (final Money moneyProvider : moniesToAdd) {
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -287,33 +288,16 @@ private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final Progressiv
final List<RepaymentPeriod> 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) {
Expand All @@ -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;
}

Expand All @@ -349,7 +331,7 @@ private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final Progressiv
});
calculateOutstandingBalance(scheduleModel);
adjustCounter++;
} while (uncountablePeriods > 0 && adjustCounter < 3);
} while (emiAdjustment.hasUncountablePeriods() && adjustCounter < 3);
}

/**
Expand Down Expand Up @@ -500,16 +482,17 @@ Money applyInstallmentAmountInMultiplesOf(final ProgressiveLoanInterestScheduleM
: equalMonthlyInstallment;
}

Money getDifferenceBetweenLastTwoPeriod(final List<RepaymentPeriod> 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<RepaymentPeriod> 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);
}

/**
Expand Down Expand Up @@ -734,4 +717,9 @@ public Money getSumOfDueInterestsOnDate(ProgressiveLoanInterestScheduleModel sch
.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(); //
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
public void test_reschedule_interest_on0215_4per_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month() {

Expand Down

0 comments on commit 75fa740

Please sign in to comment.