FINERACT-1981: EMI Calculation, Daily, Weekly, Monthly support plug to Repayment Schedule

This commit is contained in:
Janos Meszaros
2024-06-26 09:21:38 +02:00
committed by Adam Saghy
parent 9f5044b358
commit db8000b4f3
15 changed files with 1312 additions and 328 deletions
@@ -24,7 +24,6 @@ import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.Year;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@@ -35,7 +34,6 @@ import java.util.Locale;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
public final class DateUtils {
@@ -396,12 +394,4 @@ public final class DateUtils {
return formatter;
}
public static Integer daysInYear(final DaysInYearType daysInYear, final LocalDate referenceDate) {
return daysInYear.isActual() ? DateUtils.getDaysInYear(referenceDate.getYear()) : daysInYear.getValue();
}
public static Integer getDaysInYear(final Integer year) {
return Year.isLeap(year) ? 366 : 365;
}
}
@@ -467,4 +467,14 @@ public final class MathUtil {
}
return percentageOf;
}
/**
* Remove unused zeros from end of BigDecimal fraction and keep the format "0.0"
*
* @param value
* @return
*/
public static BigDecimal stripTrailingZeros(final BigDecimal value) {
return value == null ? null : new BigDecimal(value.stripTrailingZeros().toPlainString());
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.common.domain;
import java.time.LocalDate;
import java.util.Arrays;
/**
@@ -74,4 +75,11 @@ public enum DaysInMonthType {
public boolean isDaysInMonth_30() {
return DaysInMonthType.DAYS_30.getValue().equals(this.value);
}
public Integer getNumberOfDays(final LocalDate referenceDate) {
if (referenceDate == null) {
return null;
}
return this == ACTUAL ? referenceDate.lengthOfMonth() : this.getValue();
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.common.domain;
import java.time.LocalDate;
import java.util.Arrays;
/**
@@ -83,4 +84,11 @@ public enum DaysInYearType {
public boolean isActual() {
return DaysInYearType.ACTUAL.getValue().equals(this.value);
}
public Integer getNumberOfDays(final LocalDate referenceDate) {
if (referenceDate == null) {
return null;
}
return this == ACTUAL ? referenceDate.lengthOfYear() : this.getValue();
}
}
@@ -0,0 +1,96 @@
/**
* 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.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Set;
import lombok.Data;
import lombok.experimental.Accessors;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalcualtionAdditionalDetails;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePeriodData;
@Data
@Accessors(fluent = true)
public class PreGeneratedLoanSchedulePeriod implements LoanScheduleModelPeriod {
private Integer periodNumber;
private boolean isRepaymentPeriod;
private boolean isDownPaymentPeriod;
private LocalDate periodFromDate;
private LocalDate periodDueDate;
private BigDecimal principalDue;
private BigDecimal interestDue;
private BigDecimal feeChargesDue;
private BigDecimal penaltyChargesDue;
private BigDecimal rescheduleInterestPortion;
private boolean isRecalculatedInterestComponent;
private boolean isEMIFixedSpecificToInstallment;
public PreGeneratedLoanSchedulePeriod(Integer periodNumber, LocalDate periodFromDate, LocalDate periodDueDate) {
this.periodNumber = periodNumber;
this.isRepaymentPeriod = true;
this.isDownPaymentPeriod = false;
this.periodFromDate = periodFromDate;
this.periodDueDate = periodDueDate;
this.principalDue = BigDecimal.ZERO;
this.interestDue = BigDecimal.ZERO;
this.feeChargesDue = BigDecimal.ZERO;
this.penaltyChargesDue = BigDecimal.ZERO;
this.rescheduleInterestPortion = BigDecimal.ZERO;
this.isRecalculatedInterestComponent = false;
this.isEMIFixedSpecificToInstallment = false;
}
@Override
public LoanSchedulePeriodData toData() {
return null;
}
@Override
public void addLoanCharges(BigDecimal feeCharge, BigDecimal penaltyCharge) {
throw new UnsupportedOperationException();
}
@Override
public void addPrincipalAmount(Money principalDue) {
this.principalDue = this.principalDue.add(principalDue.getAmount());
}
@Override
public void addInterestAmount(Money interestDue) {
this.interestDue = this.principalDue.add(interestDue.getAmount());
}
@Override
public Set<LoanInterestRecalcualtionAdditionalDetails> getLoanCompoundingDetails() {
return Set.of();
}
@Override
public void setEMIFixedSpecificToInstallmentTrue() {
throw new UnsupportedOperationException();
}
@Override
public void setRescheduleInterestPortion(BigDecimal rescheduleInterestPortion) {
throw new UnsupportedOperationException();
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.organisation.workingdays.data.AdjustedDateDetailsDTO;
import org.apache.fineract.portfolio.calendar.domain.Calendar;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
@@ -26,6 +27,9 @@ import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
public interface ScheduledDateGenerator {
List<PreGeneratedLoanSchedulePeriod> generateRepaymentPeriods(LoanApplicationTerms loanApplicationTerms,
HolidayDetailDTO holidayDetailDTO);
LocalDate getLastRepaymentDate(LoanApplicationTerms loanApplicationTerms, HolidayDetailDTO holidayDetailDTO);
LocalDate idealDisbursementDateBasedOnFirstRepaymentDate(PeriodFrequencyType repaymentPeriodFrequencyType, int repaidEvery,
@@ -48,6 +48,8 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleD
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.MultiDisbursementOutstandingAmoutException;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculationResult;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType;
public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
@@ -83,6 +85,11 @@ public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanSc
List<LoanScheduleModelPeriod> periods = createNewLoanScheduleListWithDisbursementDetails(loanApplicationTerms, scheduleParams,
chargesDueAtTimeOfDisbursement);
List<PreGeneratedLoanSchedulePeriod> expectedRepaymentPeriods = getScheduledDateGenerator()
.generateRepaymentPeriods(loanApplicationTerms, holidayDetailDTO);
EMICalculationResult emiCalculationResult = getEMICalculator().calculateEMIValueAndRateFactors(scheduleParams,
loanApplicationTerms.toLoanProductRelatedDetail(), expectedRepaymentPeriods, mc);
boolean isFirstRepayment = true;
// charges which depends on total loan interest will be added to this
@@ -115,10 +122,11 @@ public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanSc
// 5 determine principal,interest of repayment period
PrincipalInterest principalInterestForThisPeriod = calculatePrincipalInterestComponentsForPeriod(loanApplicationTerms,
scheduleParams, mc);
scheduleParams, emiCalculationResult, mc);
// update cumulative fields for principal
currentPeriodParams.setPrincipalForThisPeriod(principalInterestForThisPeriod.principal());
currentPeriodParams.setInterestForThisPeriod(principalInterestForThisPeriod.interest());
updateOutstandingBalance(scheduleParams, currentPeriodParams);
if (scheduleParams.getOutstandingBalance().isLessThanZero() || !isNextRepaymentAvailable) {
@@ -226,7 +234,9 @@ public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanSc
public abstract PaymentPeriodsInOneYearCalculator getPaymentPeriodsInOneYearCalculator();
public abstract PrincipalInterest calculatePrincipalInterestComponentsForPeriod(LoanApplicationTerms loanApplicationTerms,
LoanScheduleParams loanScheduleParams, MathContext mc);
LoanScheduleParams loanScheduleParams, EMICalculationResult emiCalculationResult, MathContext mc);
protected abstract EMICalculator getEMICalculator();
// Private, internal methods
private BigDecimal deriveTotalChargesDueAtTimeOfDisbursement(final Set<LoanCharge> loanCharges) {
@@ -260,7 +270,6 @@ public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanSc
loanScheduleParams.addOutstandingBalance(principalMoney);
loanScheduleParams.addOutstandingBalanceAsPerRest(principalMoney);
loanScheduleParams.addPrincipalToBeScheduled(principalMoney);
loanApplicationTerms.setPrincipal(loanApplicationTerms.getPrincipal().plus(principalMoney));
loanApplicationTerms.resetFixedEmiAmount();
if (loanApplicationTerms.isDownPaymentEnabled()) {
final LoanScheduleModelDownPaymentPeriod downPaymentPeriod = createDownPaymentPeriod(loanApplicationTerms,
@@ -23,6 +23,8 @@ import java.math.MathContext;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculationResult;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.springframework.stereotype.Component;
@Component
@@ -31,6 +33,7 @@ public class ProgressiveLoanScheduleGenerator extends AbstractProgressiveLoanSch
private final ScheduledDateGenerator scheduledDateGenerator;
private final PaymentPeriodsInOneYearCalculator paymentPeriodsInOneYearCalculator;
private final EMICalculator emiCalculator;
@Override
public ScheduledDateGenerator getScheduledDateGenerator() {
@@ -43,30 +46,32 @@ public class ProgressiveLoanScheduleGenerator extends AbstractProgressiveLoanSch
}
@Override
public PrincipalInterest calculatePrincipalInterestComponentsForPeriod(LoanApplicationTerms loanApplicationTerms,
LoanScheduleParams loanScheduleParams, MathContext mc) {
// TODO: handle interest calculation
int periodNumber = loanScheduleParams.getPeriodNumber();
BigDecimal fixedEMIAmount = loanApplicationTerms.getFixedEmiAmount();
Money calculatedPrincipal;
if (fixedEMIAmount == null) {
int noRemainingPeriod = loanApplicationTerms.getActualNoOfRepaymnets() - periodNumber + 1;
calculatedPrincipal = loanScheduleParams.getOutstandingBalance().dividedBy(noRemainingPeriod, mc.getRoundingMode());
if (loanApplicationTerms.getInstallmentAmountInMultiplesOf() != null) {
calculatedPrincipal = Money.roundToMultiplesOf(calculatedPrincipal,
loanApplicationTerms.getInstallmentAmountInMultiplesOf());
}
loanApplicationTerms.setFixedEmiAmount(calculatedPrincipal.getAmount());
} else {
calculatedPrincipal = Money.of(loanApplicationTerms.getCurrency(), fixedEMIAmount);
}
// adjust if needed
if (periodNumber == loanApplicationTerms.getActualNoOfRepaymnets()) {
Money remainingAmount = loanScheduleParams.getOutstandingBalance().minus(calculatedPrincipal);
calculatedPrincipal = calculatedPrincipal.plus(remainingAmount);
}
protected EMICalculator getEMICalculator() {
return emiCalculator;
}
return new PrincipalInterest(calculatedPrincipal, Money.zero(loanApplicationTerms.getCurrency()),
Money.zero(loanApplicationTerms.getCurrency()));
@Override
public PrincipalInterest calculatePrincipalInterestComponentsForPeriod(final LoanApplicationTerms loanApplicationTerms,
final LoanScheduleParams loanScheduleParams, final EMICalculationResult emiCalculationResult, final MathContext mc) {
final Money equalMonthlyInstallmentValue = Money.of(loanApplicationTerms.getCurrency(),
emiCalculationResult.getEqualMonthlyInstallmentValue());
final BigDecimal rateFactorMinus1 = emiCalculationResult.getNextRepaymentPeriodRateFactorMinus1();
final Money calculatedInterest = loanScheduleParams.getOutstandingBalance().multipliedBy(rateFactorMinus1);
final Money calculatedPrincipal = equalMonthlyInstallmentValue.minus(calculatedInterest);
return new PrincipalInterest(
adjustCalculatedPrincipalWithRemainingBalanceInLastPeriod(calculatedPrincipal, loanApplicationTerms, loanScheduleParams),
calculatedInterest, Money.zero(loanApplicationTerms.getCurrency()));
}
private Money adjustCalculatedPrincipalWithRemainingBalanceInLastPeriod(final Money calculatedPrincipal,
final LoanApplicationTerms loanApplicationTerms, final LoanScheduleParams loanScheduleParams) {
final boolean isLastRepaymentPeriod = loanScheduleParams.getPeriodNumber() == loanApplicationTerms.getActualNoOfRepaymnets();
if (isLastRepaymentPeriod) {
final Money remainingAmount = loanScheduleParams.getOutstandingBalance().minus(calculatedPrincipal);
return calculatedPrincipal.plus(remainingAmount);
}
return calculatedPrincipal;
}
}
@@ -21,20 +21,23 @@ package org.apache.fineract.portfolio.loanproduct.calc;
import java.math.BigDecimal;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@AllArgsConstructor(access = AccessLevel.PACKAGE)
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class EMICalculationResult {
@Getter
private final BigDecimal equalMonthlyInstallmentValue;
private final List<BigDecimal> repaymentPeriodRateFactorMinus1List;
public BigDecimal getRateFactorMinus1ForRepaymentPeriod(Integer repaymentPeriodNumber) {
return repaymentPeriodNumber != null && repaymentPeriodNumber > 0
&& repaymentPeriodNumber <= repaymentPeriodRateFactorMinus1List.size()
? repaymentPeriodRateFactorMinus1List.get(repaymentPeriodNumber - 1)
: BigDecimal.ZERO;
private int counter = 0;
public BigDecimal getNextRepaymentPeriodRateFactorMinus1() {
return counter < repaymentPeriodRateFactorMinus1List.size() ? repaymentPeriodRateFactorMinus1List.get(counter++) : BigDecimal.ZERO;
}
public void reset() {
counter = 0;
}
}
@@ -18,133 +18,15 @@
*/
package org.apache.fineract.portfolio.loanproduct.calc;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.List;
import org.springframework.stereotype.Component;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
@Component
public class EMICalculator {
public interface EMICalculator {
/**
* Calculate EMI parts and return an EMI calculation result object with repayment installment rate factors
*
* @param repaymentPeriodDays
* List of day gaps between periods (zero interest period values should be 0)
* @param principal
* @param interestRate
* @param daysInYear
* @param mc
* @return
*/
public EMICalculationResult calculateEMI(final List<Integer> repaymentPeriodDays, final BigDecimal principal,
final BigDecimal interestRate, final Integer daysInYear, final MathContext mc) {
final List<BigDecimal> rateFactorList = getRateFactorList(repaymentPeriodDays, interestRate, daysInYear, mc);
final BigDecimal rateFactorN = calculateRateFactorN(rateFactorList, mc);
final BigDecimal fnResult = calculateFnResult(rateFactorList, mc);
final BigDecimal emiValue = calculateEMIValue(rateFactorN, principal, fnResult, mc);
final List<BigDecimal> rateFactorMinus1List = getRateFactorMinus1List(rateFactorList, mc);
return new EMICalculationResult(emiValue, rateFactorMinus1List);
}
/**
* Calculate rate factors from repayment periods
*
* @param repaymentPeriodDays
* @param interestRate
* @param daysInYear
* @param mc
* @return
*/
List<BigDecimal> getRateFactorList(final List<Integer> repaymentPeriodDays, final BigDecimal interestRate, final Integer daysInYear,
final MathContext mc) {
return repaymentPeriodDays.stream().map(daysInPeriod -> rateFactor(interestRate, daysInPeriod, daysInYear, mc)).toList();
}
/**
* Rate factor -1 values
*
* @param rateFactors
* @param mc
* @return
*/
List<BigDecimal> getRateFactorMinus1List(final List<BigDecimal> rateFactors, final MathContext mc) {
return rateFactors.stream().map(it -> it.subtract(BigDecimal.ONE, mc)).toList();
}
/**
* Calculate Rate Factor Product from rate factors
*
* @param rateFactors
* @param mc
* @return
*/
BigDecimal calculateRateFactorN(final List<BigDecimal> rateFactors, final MathContext mc) {
return rateFactors.stream().reduce(BigDecimal.ONE, (BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc));
}
/**
* Summarize Fn values
*
* @param rateFactors
* @param mc
* @return
*/
BigDecimal calculateFnResult(final List<BigDecimal> rateFactors, final MathContext mc) {
return rateFactors.stream().skip(1).reduce(BigDecimal.ONE,
(BigDecimal previousValue, BigDecimal rateFactor) -> fnValue(previousValue, rateFactor, mc));
}
/**
* Calculate the EMI (Equal Monthly Installment) value
*
* @param rateFactorN
* @param principal
* @param fnResult
* @param mc
* @return
*/
BigDecimal calculateEMIValue(final BigDecimal rateFactorN, final BigDecimal principal, final BigDecimal fnResult,
final MathContext mc) {
return rateFactorN.multiply(principal, mc).divide(fnResult, mc);
}
/**
* To calculate the monthly payment, we first need to calculate something called the Rate Factor. We're going to be
* using simple interest. The Rate Factor for simple interest is calculated by the following formula:
*
*
* R = 1 + (r * d / y)
*
* @param interestRate
* (r)
* @param daysInPeriod
* (d)
* @param daysInYear
* (y)
*/
BigDecimal rateFactor(final BigDecimal interestRate, final Integer daysInPeriod, final Integer daysInYear, final MathContext mc) {
final BigDecimal daysPeriod = BigDecimal.valueOf(daysInPeriod);
final BigDecimal daysYear = BigDecimal.valueOf(daysInYear);
return BigDecimal.ONE.add(interestRate.multiply(daysPeriod.divide(daysYear, mc), mc), mc);
}
/**
* To calculate the function value for each period, we are going to use the next formula:
*
* fn = 1 + fnValueFrom * rateFactorEnd
*
* @param previousFnValue
*
* @param currentRateFactor
*
* @param mc
*
*/
BigDecimal fnValue(final BigDecimal previousFnValue, final BigDecimal currentRateFactor, final MathContext mc) {
return BigDecimal.ONE.add(previousFnValue.multiply(currentRateFactor, mc), mc);
}
EMICalculationResult calculateEMIValueAndRateFactors(LoanScheduleParams scheduleParams,
LoanProductRelatedDetail loanProductRelatedDetail, List<? extends LoanScheduleModelPeriod> expectedRepaymentPeriods,
MathContext mc);
}
@@ -0,0 +1,437 @@
/**
* 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.loanproduct.calc;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.Year;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.springframework.stereotype.Component;
@Component
public final class ProgressiveEMICalculator implements EMICalculator {
private static final BigDecimal DIVISOR_100 = new BigDecimal("100");
private static final BigDecimal ONE_WEEK_IN_DAYS = BigDecimal.valueOf(7);
/**
* Calculate Equal Monthly Installment value and Rate Factor -1 values for calculate Interest
*
* @param scheduleParams
* Loan Schedule Params
*
* @param loanProductRelatedDetail
* Loan Product Related Detail from LoanTermApplication
*
* @param expectedRepaymentPeriods
* Expected Repayment Periods
*
* @param mc
* @return
*/
@Override
public EMICalculationResult calculateEMIValueAndRateFactors(final LoanScheduleParams scheduleParams,
final LoanProductRelatedDetail loanProductRelatedDetail, final List<? extends LoanScheduleModelPeriod> expectedRepaymentPeriods,
final MathContext mc) {
// TODO: decide which outstanding balance method needed in the future
final BigDecimal nominalInterestRatePerPeriod = calcNominalInterestRatePerPeriod(
loanProductRelatedDetail.getNominalInterestRatePerPeriod(), mc);
final BigDecimal outstandingBalance = scheduleParams.getOutstandingBalanceAsPerRest().getAmount().compareTo(BigDecimal.ZERO) > 0
? scheduleParams.getOutstandingBalanceAsPerRest().getAmount()
: loanProductRelatedDetail.getPrincipal().getAmount();
final DaysInYearType daysInYearType = DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType());
final DaysInMonthType daysInMonthType = DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType());
final PeriodFrequencyType repaymentFrequency = loanProductRelatedDetail.getRepaymentPeriodFrequencyType();
final BigDecimal repaymentEvery = BigDecimal.valueOf(loanProductRelatedDetail.getRepayEvery());
final List<BigDecimal> rateFactorList = getRateFactorList(expectedRepaymentPeriods, nominalInterestRatePerPeriod, daysInYearType,
daysInMonthType, repaymentFrequency, repaymentEvery, mc);
return calculateEMI(rateFactorList, outstandingBalance, mc);
}
/**
* Convert Interest Percentage to fraction of 1
*
* @param interestRate
* Interest Rate in Percentage
*
* @param mc
*
* @return Rate Interest Rate in fraction format
*/
BigDecimal calcNominalInterestRatePerPeriod(final BigDecimal interestRate, MathContext mc) {
return MathUtil.nullToZero(interestRate).divide(DIVISOR_100, mc);
}
/**
* * Calculate rate factors from ONLY repayment periods
*
* @param expectedRepaymentPeriods
* @param interestRate
* @param daysInYearType
* @param daysInMonthType
* @param repaymentFrequency
* @param repaymentEvery
* @param mc
* @return
*/
List<BigDecimal> getRateFactorList(final List<? extends LoanScheduleModelPeriod> expectedRepaymentPeriods,
final BigDecimal interestRate, final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType,
final PeriodFrequencyType repaymentFrequency, final BigDecimal repaymentEvery, final MathContext mc) {
return expectedRepaymentPeriods.stream().filter(LoanScheduleModelPeriod::isRepaymentPeriod)
.map(period -> MathUtil.stripTrailingZeros(calculateRateFactorPerPeriod(period, interestRate, daysInYearType,
daysInMonthType, repaymentFrequency, repaymentEvery, mc)))
.toList();
}
/**
* Calculate Rate Factor for an exact Period
*
* @param repaymentPeriod
* @param interestRate
* @param daysInYearType
* @param daysInMonthType
* @param repaymentFrequency
* @param repaymentEvery
* @param mc
* @return
*/
BigDecimal calculateRateFactorPerPeriod(final LoanScheduleModelPeriod repaymentPeriod, final BigDecimal interestRate,
final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType, final PeriodFrequencyType repaymentFrequency,
final BigDecimal repaymentEvery, final MathContext mc) {
BigDecimal daysInMonth = BigDecimal.valueOf(daysInMonthType.getNumberOfDays(repaymentPeriod.periodFromDate()));
BigDecimal daysInYear = BigDecimal.valueOf(daysInYearType.getNumberOfDays(repaymentPeriod.periodFromDate()));
final BigDecimal actualDaysInPeriod = BigDecimal
.valueOf(DateUtils.getDifferenceInDays(repaymentPeriod.periodFromDate(), repaymentPeriod.periodDueDate()));
final BigDecimal calculatedDaysInPeriod = BigDecimal
.valueOf(DateUtils.getDifferenceInDays(repaymentPeriod.periodFromDate(), repaymentPeriod.periodDueDate()));
final int numberOfYearsDifferenceInPeriod = repaymentPeriod.periodDueDate().getYear() - repaymentPeriod.periodFromDate().getYear();
final boolean partialPeriodCalculationNeeded = daysInYearType == DaysInYearType.ACTUAL && numberOfYearsDifferenceInPeriod > 0;
// TODO check: loanApplicationTerms.calculatePeriodsBetweenDates(startDate, endDate); // calculate period data
// TODO review: (repayment frequency: days, weeks, years; validation day is month fix 30)
if (partialPeriodCalculationNeeded) {
final BigDecimal cumulatedPeriodFractions = calculatePeriodFractions(repaymentPeriod, mc);
return rateFactorByRepaymentPartialPeriod(interestRate, repaymentEvery, cumulatedPeriodFractions, BigDecimal.ONE,
BigDecimal.ONE, mc);
}
return calculateRateFactorPerPeriodBasedOnRepaymentFrequency(interestRate, repaymentFrequency, repaymentEvery, daysInMonth,
daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc);
}
/**
* Calculate Period fractions part based on how much year has in the period
*
* @param repaymentPeriod
* @param mc
* @return
*/
BigDecimal calculatePeriodFractions(LoanScheduleModelPeriod repaymentPeriod, MathContext mc) {
BigDecimal cumulatedRateFactor = BigDecimal.ZERO;
int actualYear = repaymentPeriod.periodFromDate().getYear();
int endYear = repaymentPeriod.periodDueDate().getYear();
LocalDate actualDate = repaymentPeriod.periodFromDate();
LocalDate endOfActualYear;
while (actualYear <= endYear) {
endOfActualYear = actualYear == endYear ? repaymentPeriod.periodDueDate() : LocalDate.of(actualYear, 12, 31);
BigDecimal numberOfDaysInYear = BigDecimal.valueOf(Year.of(actualYear).length());
BigDecimal calculatedDaysInActualYear = BigDecimal.valueOf(DateUtils.getDifferenceInDays(actualDate, endOfActualYear));
cumulatedRateFactor = cumulatedRateFactor.add(calculatedDaysInActualYear.divide(numberOfDaysInYear, mc), mc);
actualDate = endOfActualYear;
actualYear++;
}
return cumulatedRateFactor;
}
/**
* Calculate Rate Factor based on Repayment Frequency Type
*
* @param interestRate
* @param repaymentFrequency
* @param repaymentEvery
* @param daysInMonth
* @param daysInYear
* @param actualDaysInPeriod
* @param calculatedDaysInPeriod
* @param mc
* @return
*/
BigDecimal calculateRateFactorPerPeriodBasedOnRepaymentFrequency(final BigDecimal interestRate,
final PeriodFrequencyType repaymentFrequency, final BigDecimal repaymentEvery, final BigDecimal daysInMonth,
final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod,
final MathContext mc) {
return switch (repaymentFrequency) {
case DAYS ->
rateFactorByRepaymentEveryDay(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc);
case WEEKS ->
rateFactorByRepaymentEveryWeek(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc);
case MONTHS -> rateFactorByRepaymentEveryMonth(interestRate, repaymentEvery, daysInMonth, daysInYear, actualDaysInPeriod,
calculatedDaysInPeriod, mc);
default -> throw new UnsupportedOperationException("Invalid repayment frequency"); // not supported yet
};
}
/**
* Calculate EMI parts and return an EMI calculation result object with repayment installment rate factors
*
* @param rateFactorList
* @param principal
* @param mc
* @return
*/
EMICalculationResult calculateEMI(final List<BigDecimal> rateFactorList, final BigDecimal principal, final MathContext mc) {
final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorN(rateFactorList, mc));
final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(rateFactorList, mc));
final BigDecimal emiValue = MathUtil.stripTrailingZeros(calculateEMIValue(rateFactorN, principal, fnResult, mc));
final List<BigDecimal> rateFactorMinus1List = getRateFactorMinus1List(rateFactorList, mc);
return new EMICalculationResult(emiValue, rateFactorMinus1List);
}
/**
* Rate factor -1 values
*
* @param rateFactors
* @param mc
* @return
*/
List<BigDecimal> getRateFactorMinus1List(final List<BigDecimal> rateFactors, final MathContext mc) {
return rateFactors.stream().map(it -> it.subtract(BigDecimal.ONE, mc)).toList();
}
/**
* Calculate Rate Factor Product from rate factors
*
* @param rateFactors
* @param mc
* @return
*/
BigDecimal calculateRateFactorN(final List<BigDecimal> rateFactors, final MathContext mc) {
return rateFactors.stream().reduce(BigDecimal.ONE, (BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc));
}
/**
* Summarize Fn values
*
* @param rateFactors
* @param mc
* @return
*/
BigDecimal calculateFnResult(final List<BigDecimal> rateFactors, final MathContext mc) {
return rateFactors.stream().skip(1).reduce(BigDecimal.ONE,
(BigDecimal previousValue, BigDecimal rateFactor) -> fnValue(previousValue, rateFactor, mc));
}
/**
* Calculate the EMI (Equal Monthly Installment) value
*
* @param rateFactorN
* @param principal
* @param fnResult
* @param mc
* @return
*/
BigDecimal calculateEMIValue(final BigDecimal rateFactorN, final BigDecimal principal, final BigDecimal fnResult,
final MathContext mc) {
return rateFactorN.multiply(principal, mc).divide(fnResult, mc);
}
/**
* To calculate the daily payment, we first need to calculate something called the Rate Factor. We're going to be
* using simple interest. The Rate Factor for simple interest is calculated by the following formula:
*
* Rate factor = 1 + (rate of interest * (repaid every / days in year) * actual days in period / calculated days in
* period ) Where
*
* @param interestRate
* Rate of Interest
*
* @param repaymentEvery
* Repaid Every
*
* @param daysInYear
* Days is Year based on DaysInYear enum
*
* @param actualDaysInPeriod
* Always the actual number of days in the actual period
*
* @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule)
*
* @param mc
* @return Rate Factor for period
*/
BigDecimal rateFactorByRepaymentEveryDay(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInYear,
final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) {
return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, daysInYear, actualDaysInPeriod,
calculatedDaysInPeriod, mc);
}
/**
* To calculate the weekly payment, we first need to calculate something called the Rate Factor. We're going to be
* using simple interest. The Rate Factor for simple interest is calculated by the following formula:
*
* Rate factor = 1 + (rate of interest * (7 * repaid every / days in year) * actual days in period / calculated days
* in period ) Where
*
* @param interestRate
* Rate of Interest
*
* @param repaymentEvery
* Repaid Every
*
* @param daysInYear
* Days is Year based on DaysInYear enum
*
* @param actualDaysInPeriod
* Always the actual number of days in the actual period
*
* @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule)
*
* @param mc
* @return Rate Factor for period
*/
BigDecimal rateFactorByRepaymentEveryWeek(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInYear,
final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) {
return rateFactorByRepaymentPeriod(interestRate, ONE_WEEK_IN_DAYS, repaymentEvery, daysInYear, actualDaysInPeriod,
calculatedDaysInPeriod, mc);
}
/**
* To calculate the monthly payment, we first need to calculate something called the Rate Factor. We're going to be
* using simple interest. The Rate Factor for simple interest is calculated by the following formula:
*
* Rate factor = 1 + (rate of interest * (days in month * repaid every / days in year) * actual days in period /
* calculated days in period ) Where
*
* @param interestRate
* Rate of Interest
*
* @param repaymentEvery
* Repaid Every
*
* @param daysInMonth
* Days in Month based on DaysInMonth enum
*
* @param daysInYear
* Days is Year based on DaysInYear enum
*
* @param actualDaysInPeriod
* Always the actual number of days in the actual period
*
* @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule)
*
* @param mc
* @return Rate Factor for period
*/
BigDecimal rateFactorByRepaymentEveryMonth(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInMonth,
final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod,
final MathContext mc) {
return rateFactorByRepaymentPeriod(interestRate, daysInMonth, repaymentEvery, daysInYear, actualDaysInPeriod,
calculatedDaysInPeriod, mc);
}
/**
* To calculate installment period payment. We're going to be using simple interest. The Rate Factor for simple
* interest is calculated by the following formula:
*
* Rate factor = 1 + (rate of interest * ( repayment period multiplier in days * repaid every * days in month / days
* in year) * actual days in period / calculated days in period ) Where
*
* @param interestRate
* Rate of Interest
*
* @param repaymentPeriodMultiplierInDays
* Multiplier number in days of the repayment every parameter
*
* @param repaymentEvery
* Repaid Every
*
* @param daysInYear
* Days is Year based on DaysInYear enum
*
* @param actualDaysInPeriod
* Always the actual number of days in the actual period
*
* @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule)
*
* @param mc
* @return Rate Factor for period
*/
BigDecimal rateFactorByRepaymentPeriod(final BigDecimal interestRate, final BigDecimal repaymentPeriodMultiplierInDays,
final BigDecimal repaymentEvery, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod,
final BigDecimal calculatedDaysInPeriod, final MathContext mc) {
final BigDecimal interestFractionPerPeriod = repaymentPeriodMultiplierInDays.multiply(repaymentEvery, mc).divide(daysInYear, mc);
return BigDecimal.ONE.add(
interestRate.multiply(interestFractionPerPeriod, mc).multiply(actualDaysInPeriod, mc).divide(calculatedDaysInPeriod, mc),
mc);
}
/**
* Calculate Rate Factor based on Partial Period
*
* @param interestRate
* @param repaymentEvery
* @param cumulatedPeriodRatio
* @param actualDaysInPeriod
* @param calculatedDaysInPeriod
* @param mc
* @return
*/
BigDecimal rateFactorByRepaymentPartialPeriod(final BigDecimal interestRate, final BigDecimal repaymentEvery,
final BigDecimal cumulatedPeriodRatio, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod,
final MathContext mc) {
final BigDecimal interestFractionPerPeriod = repaymentEvery.multiply(cumulatedPeriodRatio);
return BigDecimal.ONE.add(
interestRate.multiply(interestFractionPerPeriod, mc).multiply(actualDaysInPeriod, mc).divide(calculatedDaysInPeriod, mc),
mc);
}
/**
* To calculate the function value for each period, we are going to use the next formula:
*
* fn = 1 + fnValueFrom * rateFactorEnd
*
* @param previousFnValue
*
* @param currentRateFactor
*
* @param mc
*
*/
BigDecimal fnValue(final BigDecimal previousFnValue, final BigDecimal currentRateFactor, final MathContext mc) {
return BigDecimal.ONE.add(previousFnValue.multiply(currentRateFactor, mc), mc);
}
}
@@ -1,159 +0,0 @@
/**
* 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.loanproduct.calc;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class EMICalculatorTest {
private static EMICalculator emiCalculator = new EMICalculator();
private static MockedStatic<MoneyHelper> moneyHelper = Mockito.mockStatic(MoneyHelper.class);
private static List<LoanRepaymentScheduleInstallment> periods;
private final BigDecimal interestRate = BigDecimal.valueOf(0.094822);
private final BigDecimal principal = BigDecimal.valueOf(100);
@BeforeAll
public static void init() {
periods = new ArrayList<>();
LocalDate startDate = LocalDate.of(2024, 01, 1);
periods.add(createPeriod(1, startDate, startDate.plusMonths(1)));
periods.add(createPeriod(2, startDate.plusMonths(1), startDate.plusMonths(2)));
periods.add(createPeriod(3, startDate.plusMonths(2), startDate.plusMonths(3)));
periods.add(createPeriod(4, startDate.plusMonths(3), startDate.plusMonths(4)));
periods.add(createPeriod(5, startDate.plusMonths(4), startDate.plusMonths(5)));
periods.add(createPeriod(6, startDate.plusMonths(5), startDate.plusMonths(6)));
// When
moneyHelper.when(() -> MoneyHelper.getRoundingMode()).thenReturn(RoundingMode.HALF_EVEN);
moneyHelper.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.HALF_EVEN));
}
@Test
public void testRateFactorFunctionDay365() {
// Given
final DaysInYearType daysInYearType = DaysInYearType.DAYS_365;
final String[] expectedValues = new String[] { "1.00805337534", "1.00753380274", "1.00805337534", "1.00779358904", "1.00805337534",
"1.00779358904" };
// Then
for (LoanRepaymentScheduleInstallment period : periods) {
final Integer daysInPeriod = Math.toIntExact(DateUtils.getDifferenceInDays(period.getFromDate(), period.getDueDate()));
final Integer daysInYear = DateUtils.daysInYear(daysInYearType, period.getFromDate());
BigDecimal rateFactor = emiCalculator.rateFactor(interestRate, daysInPeriod, daysInYear, MoneyHelper.getMathContext());
Assertions.assertEquals(expectedValues[period.getInstallmentNumber() - 1], rateFactor.toString());
}
}
@Test
public void testRateFactorFunctionActual() {
// Given
final DaysInYearType daysInYearType = DaysInYearType.ACTUAL;
final String[] expectedValues = new String[] { "1.00803137158", "1.00751321858", "1.00803137158", "1.00777229508", "1.00803137158",
"1.00777229508" };
// Then
for (LoanRepaymentScheduleInstallment period : periods) {
final Integer daysInPeriod = Math.toIntExact(DateUtils.getDifferenceInDays(period.getFromDate(), period.getDueDate()));
final Integer daysInYear = DateUtils.daysInYear(daysInYearType, period.getFromDate());
BigDecimal rateFactor = emiCalculator.rateFactor(interestRate, daysInPeriod, daysInYear, MoneyHelper.getMathContext());
Assertions.assertEquals(expectedValues[period.getInstallmentNumber() - 1], rateFactor.toString());
}
}
@Test
public void testFnValueFunctionDay365() {
// Given
final DaysInYearType daysInYearType = DaysInYearType.DAYS_365;
final MathContext mc = MoneyHelper.getMathContext();
final String[] expectedValues = new String[] { "1.00000000000", "2.00753380274", "3.02370122596", "4.04726671069", "5.07986086861",
"6.11945121660" };
final List<BigDecimal> fnValuesCalculated = new ArrayList<>();
BigDecimal previousFnValue = BigDecimal.ZERO;
for (LoanRepaymentScheduleInstallment period : periods) {
final Integer daysInPeriod = Math.toIntExact(DateUtils.getDifferenceInDays(period.getFromDate(), period.getDueDate()));
final Integer daysInYear = DateUtils.daysInYear(daysInYearType, period.getFromDate());
final BigDecimal rateFactor = emiCalculator.rateFactor(interestRate, daysInPeriod, daysInYear, MoneyHelper.getMathContext());
final BigDecimal currentFnValue = emiCalculator.fnValue(previousFnValue, rateFactor, mc);
fnValuesCalculated.add(currentFnValue);
previousFnValue = currentFnValue;
}
int idx = 0;
for (BigDecimal fnValue : fnValuesCalculated) {
Assertions.assertEquals(expectedValues[idx++], fnValue.toString());
}
}
@Test
public void testEMICalculation() {
final MathContext mc = MoneyHelper.getMathContext();
final List<Integer> repaymentPeriodDays = Arrays.asList(31, 29, 31, 30, 31, 30);
final EMICalculationResult result = emiCalculator.calculateEMI(repaymentPeriodDays, principal, interestRate,
DaysInYearType.DAYS_365.getValue(), mc);
// 17.13
Assertions.assertEquals(BigDecimal.valueOf(17.1293512777), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getRateFactorMinus1ForRepaymentPeriod(1));
Assertions.assertEquals(BigDecimal.valueOf(0.00753380274), result.getRateFactorMinus1ForRepaymentPeriod(2));
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getRateFactorMinus1ForRepaymentPeriod(3));
Assertions.assertEquals(BigDecimal.valueOf(0.00779358904), result.getRateFactorMinus1ForRepaymentPeriod(4));
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getRateFactorMinus1ForRepaymentPeriod(5));
Assertions.assertEquals(BigDecimal.valueOf(0.00779358904), result.getRateFactorMinus1ForRepaymentPeriod(6));
}
@NotNull
private static LoanRepaymentScheduleInstallment createPeriod(int periodId, LocalDate start, LocalDate end) {
LoanRepaymentScheduleInstallment period = Mockito.mock(LoanRepaymentScheduleInstallment.class);
Mockito.when(period.getInstallmentNumber()).thenReturn(periodId);
Mockito.when(period.getFromDate()).thenReturn(start);
Mockito.when(period.getDueDate()).thenReturn(end);
return period;
}
}
@@ -0,0 +1,621 @@
/**
* 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.loanproduct.calc;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
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.LoanScheduleModelDownPaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelDisbursementPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.PreGeneratedLoanSchedulePeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ProgressiveEMICalculatorTest {
private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator();
private static MockedStatic<MoneyHelper> moneyHelper = Mockito.mockStatic(MoneyHelper.class);
private static LoanScheduleParams scheduleParams = Mockito.mock(LoanScheduleParams.class);
private static LoanProductRelatedDetail loanProductRelatedDetail = Mockito.mock(LoanProductRelatedDetail.class);
private static final MonetaryCurrency monetaryCurrency = MonetaryCurrency
.fromApplicationCurrency(new ApplicationCurrency("USD", "USD", 2, 1, "USD", "$"));
private static List<LoanRepaymentScheduleInstallment> periods;
private final BigDecimal interestRate = BigDecimal.valueOf(0.094822);
@BeforeAll
public static void init() {
periods = new ArrayList<>();
LocalDate startDate = LocalDate.of(2024, 01, 1);
periods.add(createPeriod(1, startDate, startDate.plusMonths(1)));
periods.add(createPeriod(2, startDate.plusMonths(1), startDate.plusMonths(2)));
periods.add(createPeriod(3, startDate.plusMonths(2), startDate.plusMonths(3)));
periods.add(createPeriod(4, startDate.plusMonths(3), startDate.plusMonths(4)));
periods.add(createPeriod(5, startDate.plusMonths(4), startDate.plusMonths(5)));
periods.add(createPeriod(6, startDate.plusMonths(5), startDate.plusMonths(6)));
// When
moneyHelper.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN);
moneyHelper.when(MoneyHelper::getMathContext).thenReturn(new MathContext(12, RoundingMode.HALF_EVEN));
}
private BigDecimal getRateFactorsByMonth(final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType,
final BigDecimal interestRate, LoanRepaymentScheduleInstallment period) {
final BigDecimal daysInPeriod = BigDecimal.valueOf(DateUtils.getDifferenceInDays(period.getFromDate(), period.getDueDate()));
final BigDecimal daysInYear = BigDecimal.valueOf(daysInYearType.getNumberOfDays(period.getFromDate()));
final BigDecimal daysInMonth = BigDecimal.valueOf(daysInMonthType.getNumberOfDays(period.getFromDate()));
return emiCalculator.rateFactorByRepaymentEveryMonth(interestRate, BigDecimal.ONE, daysInMonth, daysInYear, daysInPeriod,
daysInPeriod, MoneyHelper.getMathContext());
}
@Test
public void testRateFactorByRepaymentEveryMonthMethod_DayInYear365_DaysInMonthActual() {
// Given
final DaysInYearType daysInYearType = DaysInYearType.DAYS_365;
final DaysInMonthType daysInMonthType = DaysInMonthType.ACTUAL;
final String[] expectedValues = new String[] { "1.00805337534", "1.00753380274", "1.00805337534", "1.00779358904", "1.00805337534",
"1.00779358904" };
// Then
for (LoanRepaymentScheduleInstallment period : periods) {
BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period);
Assertions.assertEquals(expectedValues[period.getInstallmentNumber() - 1], rateFactor.toString());
}
}
@Test
public void testRateFactorByRepaymentEveryMonthMethod_DayInYearActual_DaysInMonthActual() {
// Given
final DaysInYearType daysInYearType = DaysInYearType.ACTUAL;
final DaysInMonthType daysInMonthType = DaysInMonthType.ACTUAL;
final String[] expectedValues = new String[] { "1.00803137158", "1.00751321858", "1.00803137158", "1.00777229508", "1.00803137158",
"1.00777229508" };
// Then
for (LoanRepaymentScheduleInstallment period : periods) {
BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period);
Assertions.assertEquals(expectedValues[period.getInstallmentNumber() - 1], rateFactor.toString());
}
}
@Test
public void testFnValueFunction_RepayEvery1Month_DayInYear365_DaysInMonthActual() {
// Given
final DaysInYearType daysInYearType = DaysInYearType.DAYS_365;
final DaysInMonthType daysInMonthType = DaysInMonthType.ACTUAL;
final MathContext mc = MoneyHelper.getMathContext();
final String[] expectedValues = new String[] { "1.00000000000", "2.00753380274", "3.02370122596", "4.04726671069", "5.07986086861",
"6.11945121660" };
final List<BigDecimal> fnValuesCalculated = new ArrayList<>();
BigDecimal previousFnValue = BigDecimal.ZERO;
for (LoanRepaymentScheduleInstallment period : periods) {
BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period);
final BigDecimal currentFnValue = emiCalculator.fnValue(previousFnValue, rateFactor, mc);
fnValuesCalculated.add(currentFnValue);
previousFnValue = currentFnValue;
}
int idx = 0;
for (BigDecimal fnValue : fnValuesCalculated) {
Assertions.assertEquals(expectedValues[idx++], fnValue.toString());
}
}
@Test
public void testEMICalculation_principal100_dayInYearsActual_daysInMonthActual_repayEvery1Month() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 17.13
Assertions.assertEquals(BigDecimal.valueOf(17.1280789505), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00751321858), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00777229508), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00777229508), result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.valueOf(0), result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_principal10000_dayInYearsActual_daysInMonthActual_repayEvery1Month_DisbursementInPreviousYear() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2023, 12, 12), LocalDate.of(2024, 1, 12)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 12), LocalDate.of(2024, 2, 12)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 2, 12), LocalDate.of(2024, 3, 12)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 3, 12), LocalDate.of(2024, 4, 12)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 4, 12), LocalDate.of(2024, 5, 12)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 5, 12), LocalDate.of(2024, 6, 12)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(10_000);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 1713.12
Assertions.assertEquals(BigDecimal.valueOf(1713.12436983), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.valueOf(0.00804485776), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00751321858), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00777229508), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_principal100_dayInYears365_daysInMonthActual_repayEvery1Month() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_365.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 17.13
Assertions.assertEquals(BigDecimal.valueOf(17.1293512777), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00753380274), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00779358904), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00779358904), result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_principal100_dayInYears360_daysInMonth30_repayEvery1Month() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
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);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 17.13
Assertions.assertEquals(BigDecimal.valueOf(17.1306301273), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_principal100_dayInYears364_daysInMonthDoesntMatter_repayEvery1Week() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 8)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 8), LocalDate.of(2024, 1, 15)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 22)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 1, 22), LocalDate.of(2024, 1, 29)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 1, 29), LocalDate.of(2024, 1, 5)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 2, 5), LocalDate.of(2024, 2, 12)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_364.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 16.77
Assertions.assertEquals(BigDecimal.valueOf(16.7731989919), result.getEqualMonthlyInstallmentValue());
final BigDecimal fixValue = new BigDecimal("0.0018235");
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_principal100_dayInYears360_daysInMonthDoesntMatter_repayEvery2Week() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 15)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 29)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 1, 29), LocalDate.of(2024, 2, 12)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 2, 12), LocalDate.of(2024, 2, 26)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 2, 26), LocalDate.of(2024, 3, 11)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 3, 11), LocalDate.of(2024, 3, 25)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(2);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 16.88
Assertions.assertEquals(new BigDecimal("16.8824319133"), result.getEqualMonthlyInstallmentValue());
final BigDecimal fixValue = new BigDecimal("0.00368752222");
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_principal100_dayInYears360_daysInMonthDoesntMatter_repayEvery15Days() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 16), LocalDate.of(2024, 1, 31)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 15)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 2, 15), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 16)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 3, 16), LocalDate.of(2024, 3, 31)));
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.DAYS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(15);
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 16.90
Assertions.assertEquals(new BigDecimal("16.8978941103"), result.getEqualMonthlyInstallmentValue());
final BigDecimal fixValue = new BigDecimal("0.00395091667");
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_Disbursement_DownPayment_Principal100_dayInYears360_daysInMonth30_repayEvery1Month() {
final MathContext mc = MoneyHelper.getMathContext();
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final Money principal = Money.of(monetaryCurrency, BigDecimal.valueOf(100));
final Money downPaymentValue = Money.of(monetaryCurrency, BigDecimal.valueOf(25));
final Money outstandingBalance = principal.minus(downPaymentValue);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
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);
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods
.add(LoanScheduleModelDisbursementPeriod.disbursement(LocalDate.of(2024, 1, 1), principal, BigDecimal.ZERO));
expectedRepaymentPeriods
.add(LoanScheduleModelDownPaymentPeriod.downPayment(1, LocalDate.of(2024, 1, 1), downPaymentValue, outstandingBalance));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 17.13
Assertions.assertEquals(new BigDecimal("15.3574482569"), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testEMICalculation_Principal1000_NoInterest_repayEvery1Month() {
final MathContext mc = MoneyHelper.getMathContext();
final BigDecimal interestRate = BigDecimal.valueOf(0);
final Money principal = Money.of(monetaryCurrency, BigDecimal.valueOf(1000));
final Money outstandingBalance = Money.of(monetaryCurrency, BigDecimal.ZERO);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getPrincipal()).thenReturn(principal);
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);
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail,
expectedRepaymentPeriods, mc);
// 17.13
Assertions.assertEquals(new BigDecimal("250"), result.getEqualMonthlyInstallmentValue());
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
// no more period, no more interest
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
result.reset();
// check reset
Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1());
}
@Test
public void testUnsupportedRepaymentEveryYear() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.YEARS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16)));
try {
emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail, expectedRepaymentPeriods, mc);
Assertions.fail();
} catch (Exception e) {
Assertions.assertInstanceOf(UnsupportedOperationException.class, e);
}
}
@Test
public void testUnsupportedRepaymentEveryWholeTerm() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WHOLE_TERM);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16)));
try {
emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail, expectedRepaymentPeriods, mc);
Assertions.fail();
} catch (Exception e) {
Assertions.assertInstanceOf(UnsupportedOperationException.class, e);
}
}
@Test
public void testInvalidRepaymentEveryValue() {
final MathContext mc = MoneyHelper.getMathContext();
final List<LoanScheduleModelPeriod> expectedRepaymentPeriods = new ArrayList<>();
final BigDecimal interestRate = BigDecimal.valueOf(9.4822);
final BigDecimal principal = BigDecimal.valueOf(100);
final Money outstandingBalance = Money.of(monetaryCurrency, principal);
Mockito.when(scheduleParams.getOutstandingBalanceAsPerRest()).thenReturn(outstandingBalance);
Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.INVALID);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16)));
try {
emiCalculator.calculateEMIValueAndRateFactors(scheduleParams, loanProductRelatedDetail, expectedRepaymentPeriods, mc);
Assertions.fail();
} catch (Exception e) {
Assertions.assertInstanceOf(UnsupportedOperationException.class, e);
}
}
@NotNull
private static LoanRepaymentScheduleInstallment createPeriod(int periodId, LocalDate start, LocalDate end) {
LoanRepaymentScheduleInstallment period = Mockito.mock(LoanRepaymentScheduleInstallment.class);
Mockito.when(period.getInstallmentNumber()).thenReturn(periodId);
Mockito.when(period.getFromDate()).thenReturn(start);
Mockito.when(period.getDueDate()).thenReturn(end);
return period;
}
}
@@ -20,6 +20,8 @@ package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import net.fortuna.ical4j.model.Recur;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -40,6 +42,32 @@ import org.springframework.stereotype.Component;
@Slf4j
public class DefaultScheduledDateGenerator implements ScheduledDateGenerator {
@Override
public List<PreGeneratedLoanSchedulePeriod> generateRepaymentPeriods(final LoanApplicationTerms loanApplicationTerms,
final HolidayDetailDTO holidayDetailDTO) {
final int numberOfRepayments = loanApplicationTerms.getNumberOfRepayments();
final ArrayList<PreGeneratedLoanSchedulePeriod> repaymentPeriods = new ArrayList<>(numberOfRepayments);
// TODO: check periodStartDate
LocalDate lastRepaymentDate = loanApplicationTerms.getRepaymentStartDate();
LocalDate nextRepaymentDate;
boolean isFirstRepayment = true;
for (int repaymentPeriod = 1; repaymentPeriod <= numberOfRepayments; repaymentPeriod++) {
nextRepaymentDate = generateNextRepaymentDate(lastRepaymentDate, loanApplicationTerms, isFirstRepayment);
if (repaymentPeriod == numberOfRepayments) { // last period
if (loanApplicationTerms.getFixedLength() != null
&& loanApplicationTerms.calculateMaxDateForFixedLength().compareTo(nextRepaymentDate) != 0) {
nextRepaymentDate = loanApplicationTerms.calculateMaxDateForFixedLength();
}
nextRepaymentDate = adjustRepaymentDate(nextRepaymentDate, loanApplicationTerms, holidayDetailDTO).getChangedScheduleDate();
}
repaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(repaymentPeriod, lastRepaymentDate, nextRepaymentDate));
lastRepaymentDate = nextRepaymentDate;
isFirstRepayment = false;
}
return repaymentPeriods;
}
@Override
public LocalDate getLastRepaymentDate(final LoanApplicationTerms loanApplicationTerms, final HolidayDetailDTO holidayDetailDTO) {
@@ -38,6 +38,7 @@ import static org.mockito.BDDMockito.given;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.junit.context.WithTenantContext;
import org.apache.fineract.junit.context.WithTenantContextExtension;
@@ -74,6 +75,47 @@ public class DefaultScheduledDateGeneratorTest {
moneyHelper.initialize();
}
@Test
@WithSystemTimeZone(EUROPE_BERLIN_ID)
@WithTenantContext(tenantTimeZoneId = EUROPE_BERLIN_ID)
@WithSystemProperty(key = FLOATING_TIMEZONE_PROPERTY_KEY, value = "true")
public void test_generateRepaymentPeriods() {
// given
HolidayDetailDTO holidayDetailDTO = createHolidayDTO();
ApplicationCurrency dollarCurrency = new ApplicationCurrency("USD", "US Dollar", 2, 0, "currency.USD", "$");
Money principalAmount = Money.of(fromApplicationCurrency(dollarCurrency), BigDecimal.valueOf(100));
LocalDate expectedDisbursementDate = LocalDate.of(2024, 1, 1);
LocalDate dueRepaymentPeriodDate = LocalDate.of(2024, 2, 1);
LocalDate submittedOnDate = LocalDate.of(2024, 1, 1);
LoanApplicationTerms loanApplicationTerms = LoanApplicationTerms.assembleFrom(dollarCurrency, 1, MONTHS, 4, 1, MONTHS, null,
INVALID, EQUAL_PRINCIPAL, FLAT, ZERO, MONTHS, ZERO, SAME_AS_REPAYMENT_PERIOD, false, principalAmount,
expectedDisbursementDate, null, dueRepaymentPeriodDate, null, null, null, null, null,
Money.of(fromApplicationCurrency(dollarCurrency), ZERO), false, null, EMPTY_LIST, BigDecimal.valueOf(36_000L), null,
DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, null, NONE, null, ZERO,
EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, DISBURSEMENT_DATE,
submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null);
// when
List<PreGeneratedLoanSchedulePeriod> result = underTest.generateRepaymentPeriods(loanApplicationTerms, holidayDetailDTO);
// then
assertThat(result.size()).isEqualTo(4);
assertThat(result.get(0).periodNumber()).isEqualTo(1);
assertThat(result.get(0).periodFromDate().toString()).isEqualTo("2024-01-01");
assertThat(result.get(0).periodDueDate().toString()).isEqualTo("2024-02-01");
assertThat(result.get(1).periodNumber()).isEqualTo(2);
assertThat(result.get(1).periodFromDate().toString()).isEqualTo("2024-02-01");
assertThat(result.get(1).periodDueDate().toString()).isEqualTo("2024-03-01");
assertThat(result.get(2).periodNumber()).isEqualTo(3);
assertThat(result.get(2).periodFromDate().toString()).isEqualTo("2024-03-01");
assertThat(result.get(2).periodDueDate().toString()).isEqualTo("2024-04-01");
assertThat(result.get(3).periodNumber()).isEqualTo(4);
assertThat(result.get(3).periodFromDate().toString()).isEqualTo("2024-04-01");
assertThat(result.get(3).periodDueDate().toString()).isEqualTo("2024-05-01");
}
@Test
@WithSystemTimeZone(EUROPE_BERLIN_ID)
@WithTenantContext(tenantTimeZoneId = EUROPE_BERLIN_ID)