Skip to content

Commit

Permalink
FINERACT-2114: Interest rate modification
Browse files Browse the repository at this point in the history
  • Loading branch information
janez89 authored and adamsaghy committed Aug 22, 2024
1 parent 80f4627 commit 1286b1e
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1890,8 +1890,4 @@ public void updateVariationDays(final long daysToAdd) {
this.variationDays += daysToAdd;
}

public LocalDate getLoanEndDate() {
return loanEndDate;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,10 @@ public void validateForApproveAction(final JsonCommand jsonCommand, LoanReschedu
}

if (rescheduleFromDate != null) {
installment = loan.getRepaymentScheduleInstallment(rescheduleFromDate);
final boolean isProgressiveLoanSchedule = loan.getLoanProductRelatedDetail()
.getLoanScheduleType() == LoanScheduleType.PROGRESSIVE;
installment = isProgressiveLoanSchedule ? loan.getRelatedRepaymentScheduleInstallment(rescheduleFromDate)
: loan.getRepaymentScheduleInstallment(rescheduleFromDate);

if (installment == null) {
dataValidatorBuilder.reset().failWithCodeNoParameterAddedToErrorCode(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* 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.math.BigDecimal;
import java.time.LocalDate;
import org.jetbrains.annotations.NotNull;

public record ProgressiveLoanInterestRate(LocalDate effectiveFrom, LocalDate validFrom,
BigDecimal interestRate) implements Comparable<ProgressiveLoanInterestRate> {

@Override
public int compareTo(@NotNull ProgressiveLoanInterestRate o) {
return this.effectiveFrom().compareTo(o.effectiveFrom());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,40 @@
*/
package org.apache.fineract.portfolio.loanaccount.loanschedule.data;

import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;

public record ProgressiveLoanInterestScheduleModel(List<ProgressiveLoanInterestRepaymentModel> repayments,
LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc) {
public record ProgressiveLoanInterestScheduleModel(List<ProgressiveLoanInterestRepaymentModel> repayments, //
List<ProgressiveLoanInterestRate> interestRates, //
LoanProductRelatedDetail loanProductRelatedDetail, //
Integer installmentAmountInMultiplesOf, //
MathContext mc) {

public ProgressiveLoanInterestScheduleModel(List<ProgressiveLoanInterestRepaymentModel> repayments,
LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc) {
this(repayments, new ArrayList<>(1), loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
}

public void addInterestRate(final LocalDate newInterestDueDate, final BigDecimal newInterestRate) {
interestRates.add(new ProgressiveLoanInterestRate(newInterestDueDate, newInterestDueDate.plusDays(1), newInterestRate));
interestRates.sort(Collections.reverseOrder());
}

public BigDecimal getInterestRate(final LocalDate effectiveDate) {
return interestRates.isEmpty() ? loanProductRelatedDetail.getNominalInterestRatePerPeriod() : findInterestRate(effectiveDate);
}

private BigDecimal findInterestRate(final LocalDate effectiveDate) {
return interestRates.stream().filter(ir -> !ir.effectiveFrom().isAfter(effectiveDate))
.map(ProgressiveLoanInterestRate::interestRate).findFirst()
.orElse(loanProductRelatedDetail.getNominalInterestRatePerPeriod());
}

public int getLoanTermInDays() {
if (repayments.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTer
if (lastDueDateVariation != null) {
loanEndDate = lastDueDateVariation.getDateValue();
}
loanApplicationTerms.updateLoanEndDate(loanEndDate);

// determine the total charges due at time of disbursement
final BigDecimal chargesDueAtTimeOfDisbursement = deriveTotalChargesDueAtTimeOfDisbursement(loanCharges);
Expand Down Expand Up @@ -111,6 +110,15 @@ public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTer
processDisbursements(loanApplicationTerms, scheduleParams, interestScheduleModel, periods, chargesDueAtTimeOfDisbursement);
repaymentPeriod.setPeriodNumber(scheduleParams.getInstalmentNumber());

for (var interestRateChange : loanApplicationTerms.getLoanTermVariations().getInterestRateFromInstallment()) {
final LocalDate interestRateChangeEffectiveDate = interestRateChange.getTermVariationApplicableFrom().minusDays(1);
final BigDecimal newInterestRate = interestRateChange.getDecimalValue();
if (interestRateChangeEffectiveDate.isAfter(repaymentPeriod.getFromDate())
&& !interestRateChangeEffectiveDate.isAfter(repaymentPeriod.getDueDate())) {
emiCalculator.changeInterestRate(interestScheduleModel, interestRateChangeEffectiveDate, newInterestRate);
}
}

emiCalculator.findInterestRepaymentPeriod(interestScheduleModel, repaymentPeriod.getDueDate())
.ifPresent(interestRepaymentPeriod -> {
final Money principalDue = interestRepaymentPeriod.getPrincipalDue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.loanproduct.calc;

import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.util.List;
Expand All @@ -42,6 +43,9 @@ Optional<ProgressiveLoanInterestRepaymentModel> findInterestRepaymentPeriod(Prog

void addDisbursement(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate disbursementDueDate, Money disbursedAmount);

void changeInterestRate(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate newInterestEffectiveDate,
BigDecimal newInterestRate);

ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(ProgressiveLoanInterestScheduleModel scheduleModel);

ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(ProgressiveLoanInterestScheduleModel scheduleModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,27 @@ Optional<ProgressiveLoanInterestRepaymentInterestPeriod> findInterestPeriodForDi
.findFirst();
}

Optional<ProgressiveLoanInterestRepaymentInterestPeriod> findInterestPeriodForInterestChange(
final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate interestRateChangeEffectiveDate) {
if (repaymentPeriod == null || interestRateChangeEffectiveDate == null) {
return Optional.empty();
}
return repaymentPeriod.getInterestPeriods().stream()//
.filter(interestPeriod -> interestRateChangeEffectiveDate.isEqual(interestPeriod.getFromDate()))//
.findFirst();
}

Optional<ProgressiveLoanInterestRepaymentModel> findInterestRepaymentPeriodForInterestChange(
final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate interestChangeEffectiveDate) {
if (scheduleModel == null || interestChangeEffectiveDate == null) {
return Optional.empty();
}
return scheduleModel.repayments().stream()//
.filter(repaymentPeriod -> !interestChangeEffectiveDate.isBefore(repaymentPeriod.getFromDate())
&& interestChangeEffectiveDate.isBefore(repaymentPeriod.getDueDate()))//
.findFirst();
}

/**
* Add disbursement to Interest Period
*/
Expand Down Expand Up @@ -154,6 +175,42 @@ public ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(final Prog
return new ProgressiveLoanInterestScheduleModel(repayments, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
}

@Override
public void changeInterestRate(final ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate newInterestEffectiveDate,
final BigDecimal newInterestRate) {
final ProgressiveLoanInterestRepaymentModel repaymentPeriod = findInterestRepaymentPeriodForInterestChange(scheduleModel,
newInterestEffectiveDate).orElse(null);
if (repaymentPeriod == null) {
return;
}
scheduleModel.addInterestRate(newInterestEffectiveDate, newInterestRate);
var interestPeriodOptional = findInterestPeriodForInterestChange(repaymentPeriod, newInterestEffectiveDate);
if (interestPeriodOptional.isEmpty()) {
insertInterestPeriod(scheduleModel, repaymentPeriod, newInterestEffectiveDate);
}

calculateEMIValueAndRateFactors(repaymentPeriod.getDueDate(), scheduleModel);
}

void insertInterestPeriod(final ProgressiveLoanInterestScheduleModel scheduleModel,
final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate interestChangeDueDate) {
// period start date
final ProgressiveLoanInterestRepaymentInterestPeriod previousInterestPeriod = repaymentPeriod.getInterestPeriods().stream()
.filter(interestPeriod -> interestChangeDueDate.isAfter(interestPeriod.getFromDate())
&& interestChangeDueDate.isBefore(interestPeriod.getDueDate()))//
.findFirst()//
.get();//

final Money zeroAmount = Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency());
final var interestPeriod = new ProgressiveLoanInterestRepaymentInterestPeriod(interestChangeDueDate,
previousInterestPeriod.getDueDate(), BigDecimal.ZERO, zeroAmount, zeroAmount);

previousInterestPeriod.setDueDate(interestChangeDueDate);

repaymentPeriod.getInterestPeriods().add(interestPeriod);
Collections.sort(repaymentPeriod.getInterestPeriods());
}

/**
* Calculate Equal Monthly Installment value and Rate Factor -1 values for calculate Interest
*/
Expand Down Expand Up @@ -259,7 +316,7 @@ BigDecimal calculateRateFactorMinus1PerPeriod(final ProgressiveLoanInterestRepay
final ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final MathContext mc = scheduleModel.mc();
final LoanProductRelatedDetail loanProductRelatedDetail = scheduleModel.loanProductRelatedDetail();
final BigDecimal interestRate = calcNominalInterestRatePercentage(loanProductRelatedDetail.getNominalInterestRatePerPeriod(), mc);
final BigDecimal interestRate = calcNominalInterestRatePercentage(scheduleModel.getInterestRate(interestPeriod.getFromDate()), mc);
final DaysInYearType daysInYearType = DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType());
final DaysInMonthType daysInMonthType = DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType());
final PeriodFrequencyType repaymentFrequency = loanProductRelatedDetail.getRepaymentPeriodFrequencyType();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
Expand All @@ -50,6 +51,7 @@ class ProgressiveEMICalculatorTest {

private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator(null);

private static MockedStatic<ThreadLocalContextUtil> threadLocalContextUtil = Mockito.mockStatic(ThreadLocalContextUtil.class);
private static MockedStatic<MoneyHelper> moneyHelper = Mockito.mockStatic(MoneyHelper.class);
private static LoanProductRelatedDetail loanProductRelatedDetail = Mockito.mock(LoanProductRelatedDetail.class);

Expand Down Expand Up @@ -241,6 +243,95 @@ public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repay
checkPeriod(interestSchedule, 5, 0, 17.13, 0.007901833333, 0.13, 17.00, 0.0);
}

@Test
public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month_reschedule_interest_on0201_4per() {
final MathContext mc = MoneyHelper.getMathContext();
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 = new BigDecimal("7");
final Integer installmentAmountInMultiplesOf = null;

Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).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 interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods,
loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);

final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100));
emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount);

final BigDecimal interestRateNewValue = new BigDecimal("4");
final LocalDate interestChangeDate = LocalDate.of(2024, 2, 2);
final LocalDate interestEffectiveDate = interestChangeDate.minusDays(1);
emiCalculator.changeInterestRate(interestSchedule, interestEffectiveDate, interestRateNewValue);

checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount);
checkPeriod(interestSchedule, 0, 0, 17.01, 0.005833333333, 0.58, 16.43, 83.57);
checkPeriod(interestSchedule, 1, 0, 16.88, 0.003333333333, 0.28, 16.60, 66.97);
checkPeriod(interestSchedule, 2, 0, 16.88, 0.003333333333, 0.22, 16.66, 50.31);
checkPeriod(interestSchedule, 3, 0, 16.88, 0.003333333333, 0.17, 16.71, 33.60);
checkPeriod(interestSchedule, 4, 0, 16.88, 0.003333333333, 0.11, 16.77, 16.83);
checkPeriod(interestSchedule, 5, 0, 16.89, 0.003333333333, 0.06, 16.83, 0.0);
}

@Test
public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month_reschedule_interest_on0215_4per() {
final MathContext mc = MoneyHelper.getMathContext();
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 = new BigDecimal("7");
final Integer installmentAmountInMultiplesOf = null;

Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).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 interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods,
loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);

final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100));
emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount);

final BigDecimal interestRateNewValue = new BigDecimal("4");
final LocalDate interestChangeDate = LocalDate.of(2024, 2, 15);
final LocalDate interestEffectiveDate = interestChangeDate.minusDays(1);
emiCalculator.changeInterestRate(interestSchedule, interestEffectiveDate, interestRateNewValue);

checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount);
checkPeriod(interestSchedule, 0, 0, 17.01, 0.005833333333, 0.58, 16.43, 83.57);
checkPeriod(interestSchedule, 1, 0, 16.90, 0.002614942529, 0.22, 0.37, 16.53, 67.04);
checkPeriod(interestSchedule, 1, 1, 16.90, 0.001839080460, 0.15, 0.37, 16.53, 67.04);
checkPeriod(interestSchedule, 2, 0, 16.90, 0.003333333333, 0.22, 16.68, 50.36);
checkPeriod(interestSchedule, 3, 0, 16.90, 0.003333333333, 0.17, 16.73, 33.63);
checkPeriod(interestSchedule, 4, 0, 16.90, 0.003333333333, 0.11, 16.79, 16.84);
checkPeriod(interestSchedule, 5, 0, 16.90, 0.003333333333, 0.06, 16.84, 0.0);
}

// @Test
// public void testEMICalculation_disbursedAmt100_dayInYearsActual_daysInMonthActual_repayEvery1Month_reschedule() {
// final MathContext mc = MoneyHelper.getMathContext();
Expand Down

0 comments on commit 1286b1e

Please sign in to comment.