From db8000b4f3ebedfee00a888fe9fb0c099d1828a8 Mon Sep 17 00:00:00 2001 From: Janos Meszaros Date: Fri, 21 Jun 2024 17:21:06 +0200 Subject: [PATCH] FINERACT-1981: EMI Calculation, Daily, Weekly, Monthly support plug to Repayment Schedule --- .../core/service/DateUtils.java | 10 - .../infrastructure/core/service/MathUtil.java | 10 + .../common/domain/DaysInMonthType.java | 8 + .../common/domain/DaysInYearType.java | 8 + .../PreGeneratedLoanSchedulePeriod.java | 96 +++ .../domain/ScheduledDateGenerator.java | 4 + ...tractProgressiveLoanScheduleGenerator.java | 15 +- .../ProgressiveLoanScheduleGenerator.java | 53 +- .../calc/EMICalculationResult.java | 17 +- .../loanproduct/calc/EMICalculator.java | 132 +--- .../calc/ProgressiveEMICalculator.java | 437 ++++++++++++ .../loanproduct/calc/EMICalculatorTest.java | 159 ----- .../calc/ProgressiveEMICalculatorTest.java | 621 ++++++++++++++++++ .../domain/DefaultScheduledDateGenerator.java | 28 + .../DefaultScheduledDateGeneratorTest.java | 42 ++ 15 files changed, 1312 insertions(+), 328 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java delete mode 100644 fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculatorTest.java create mode 100644 fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java index e972204cd..bd235d4d2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java @@ -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; - } - } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java index ba58b25f7..7bdd21e46 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java @@ -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()); + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInMonthType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInMonthType.java index 20eca6bfe..c7725d29b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInMonthType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInMonthType.java @@ -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(); + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInYearType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInYearType.java index 80a42d484..dde64bf82 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInYearType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/common/domain/DaysInYearType.java @@ -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(); + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java new file mode 100644 index 000000000..636cf0046 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java @@ -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 getLoanCompoundingDetails() { + return Set.of(); + } + + @Override + public void setEMIFixedSpecificToInstallmentTrue() { + throw new UnsupportedOperationException(); + } + + @Override + public void setRescheduleInterestPortion(BigDecimal rescheduleInterestPortion) { + throw new UnsupportedOperationException(); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java index 50edf46fa..1fd14d85f 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java @@ -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 generateRepaymentPeriods(LoanApplicationTerms loanApplicationTerms, + HolidayDetailDTO holidayDetailDTO); + LocalDate getLastRepaymentDate(LoanApplicationTerms loanApplicationTerms, HolidayDetailDTO holidayDetailDTO); LocalDate idealDisbursementDateBasedOnFirstRepaymentDate(PeriodFrequencyType repaymentPeriodFrequencyType, int repaidEvery, diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java index 4984ed514..960e79dd7 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java @@ -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 periods = createNewLoanScheduleListWithDisbursementDetails(loanApplicationTerms, scheduleParams, chargesDueAtTimeOfDisbursement); + List 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 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, diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java index f05675e3c..10bb88987 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java @@ -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; } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculationResult.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculationResult.java index 0fd12b5ee..71b9435c1 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculationResult.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculationResult.java @@ -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 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; } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java index 37659e62f..bf217b73e 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java @@ -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 repaymentPeriodDays, final BigDecimal principal, - final BigDecimal interestRate, final Integer daysInYear, final MathContext mc) { - final List 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 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 getRateFactorList(final List 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 getRateFactorMinus1List(final List 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 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 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 expectedRepaymentPeriods, + MathContext mc); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java new file mode 100644 index 000000000..477ff9377 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -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 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 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 getRateFactorList(final List 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 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 rateFactorMinus1List = getRateFactorMinus1List(rateFactorList, mc); + + return new EMICalculationResult(emiValue, rateFactorMinus1List); + } + + /** + * Rate factor -1 values + * + * @param rateFactors + * @param mc + * @return + */ + List getRateFactorMinus1List(final List 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 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 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); + } +} diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculatorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculatorTest.java deleted file mode 100644 index 30b819762..000000000 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculatorTest.java +++ /dev/null @@ -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 = Mockito.mockStatic(MoneyHelper.class); - - private static List 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 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 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; - } - -} diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java new file mode 100644 index 000000000..447948e8b --- /dev/null +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java @@ -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 = 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java index 7d3685d7b..1d2e9f487 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java @@ -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 generateRepaymentPeriods(final LoanApplicationTerms loanApplicationTerms, + final HolidayDetailDTO holidayDetailDTO) { + final int numberOfRepayments = loanApplicationTerms.getNumberOfRepayments(); + final ArrayList 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) { diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java index 10d966e4d..5b6987bea 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java @@ -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 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)