FINERACT-1968: Progressive Loan schedule handling - interest handling (FLAT interest, Same as repayment period, Equal installments)

This commit is contained in:
Soma Sörös
2025-07-09 14:39:18 +02:00
committed by Adam Saghy
parent 3f359bb368
commit 40f8b59ab4
21 changed files with 710 additions and 77 deletions
@@ -290,10 +290,13 @@ public final class LoanApplicationTerms {
this.buyDownFeeCalculationType = builder.buyDownFeeCalculationType;
this.buyDownFeeStrategy = builder.buyDownFeeStrategy;
this.buyDownFeeIncomeType = builder.buyDownFeeIncomeType;
this.interestMethod = builder.interestMethod;
this.allowPartialPeriodInterestCalcualtion = builder.allowPartialPeriodInterestCalculation;
}
public static class Builder {
private InterestMethod interestMethod;
private CurrencyData currency;
private Integer loanTermFrequency;
private PeriodFrequencyType loanTermPeriodFrequencyType;
@@ -327,6 +330,12 @@ public final class LoanApplicationTerms {
private LoanBuyDownFeeCalculationType buyDownFeeCalculationType;
private LoanBuyDownFeeStrategy buyDownFeeStrategy;
private LoanBuyDownFeeIncomeType buyDownFeeIncomeType;
private boolean allowPartialPeriodInterestCalculation;
public Builder interestMethod(InterestMethod interestMethod) {
this.interestMethod = interestMethod;
return this;
}
public Builder currency(CurrencyData currency) {
this.currency = currency;
@@ -491,6 +500,12 @@ public final class LoanApplicationTerms {
this.daysInYearCustomStrategy = daysInYearCustomStrategy;
return this;
}
public Builder allowPartialPeriodInterestCalculation(boolean allowPartialPeriodInterestCalculation) {
this.allowPartialPeriodInterestCalculation = allowPartialPeriodInterestCalculation;
return this;
}
}
public static LoanApplicationTerms assembleFrom(LoanRepaymentScheduleModelData modelData, MathContext mc) {
@@ -518,7 +533,8 @@ public final class LoanApplicationTerms {
.isDownPaymentEnabled(modelData.downPaymentEnabled()).downPaymentPercentage(downPaymentPercentage)
.submittedOnDate(modelData.scheduleGenerationStartDate()).seedDate(seedDate)
.interestRecognitionOnDisbursementDate(modelData.interestRecognitionOnDisbursementDate())
.daysInYearCustomStrategy(modelData.daysInYearCustomStrategy()).mc(mc).build();
.daysInYearCustomStrategy(modelData.daysInYearCustomStrategy()).interestMethod(modelData.interestMethod())
.allowPartialPeriodInterestCalculation(modelData.allowPartialPeriodInterestCalculation()).mc(mc).build();
}
public static LoanApplicationTerms assembleFrom(final CurrencyData currency, final Integer loanTermFrequency,
@@ -526,7 +542,7 @@ public final class LoanApplicationTerms {
final PeriodFrequencyType repaymentPeriodFrequencyType, Integer nthDay, DayOfWeekType weekDayType,
final AmortizationMethod amortizationMethod, final InterestMethod interestMethod, final BigDecimal interestRatePerPeriod,
final PeriodFrequencyType interestRatePeriodFrequencyType, final BigDecimal annualNominalInterestRate,
final InterestCalculationPeriodMethod interestCalculationPeriodMethod, final boolean allowPartialPeriodInterestCalcualtion,
final InterestCalculationPeriodMethod interestCalculationPeriodMethod, final boolean allowPartialPeriodInterestCalculation,
final Money principalMoney, final LocalDate expectedDisbursementDate, final LocalDate repaymentsStartingFromDate,
final LocalDate calculatedRepaymentsStartingFromDate, final Integer graceOnPrincipalPayment,
final Integer recurringMoratoriumOnPrincipalPeriods, final Integer graceOnInterestPayment, final Integer graceOnInterestCharged,
@@ -561,7 +577,7 @@ public final class LoanApplicationTerms {
return new LoanApplicationTerms(currency, loanTermFrequency, loanTermPeriodFrequencyType, numberOfRepayments, repaymentEvery,
repaymentPeriodFrequencyType, nthDay, weekDayType, amortizationMethod, interestMethod, interestRatePerPeriod,
interestRatePeriodFrequencyType, annualNominalInterestRate, interestCalculationPeriodMethod,
allowPartialPeriodInterestCalcualtion, principalMoney, expectedDisbursementDate, repaymentsStartingFromDate,
allowPartialPeriodInterestCalculation, principalMoney, expectedDisbursementDate, repaymentsStartingFromDate,
calculatedRepaymentsStartingFromDate, graceOnPrincipalPayment, recurringMoratoriumOnPrincipalPeriods,
graceOnInterestPayment, graceOnInterestCharged, interestChargedFromDate, inArrearsTolerance, multiDisburseLoan, emiAmount,
disbursementDatas, maxOutstandingBalance, graceOnArrearsAgeing, daysInMonthType, daysInYearType,
@@ -608,7 +624,7 @@ public final class LoanApplicationTerms {
final PeriodFrequencyType interestRatePeriodFrequencyType = loanProductRelatedDetail.getInterestPeriodFrequencyType();
final InterestCalculationPeriodMethod interestCalculationPeriodMethod = loanProductRelatedDetail
.getInterestCalculationPeriodMethod();
final boolean allowPartialPeriodInterestCalcualtion = loanProductRelatedDetail.isAllowPartialPeriodInterestCalcualtion();
final boolean allowPartialPeriodInterestCalculation = loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation();
final Money principalMoney = loanProductRelatedDetail.getPrincipal();
//
@@ -636,7 +652,7 @@ public final class LoanApplicationTerms {
return new LoanApplicationTerms(currency, loanTermFrequency, loanTermPeriodFrequencyType, numberOfRepayments, repaymentEvery,
repaymentPeriodFrequencyType, ((nthDay != null) ? nthDay.getValue() : null), dayOfWeek, amortizationMethod, interestMethod,
interestRatePerPeriod, interestRatePeriodFrequencyType, annualNominalInterestRate, interestCalculationPeriodMethod,
allowPartialPeriodInterestCalcualtion, principalMoney, expectedDisbursementDate, repaymentsStartingFromDate,
allowPartialPeriodInterestCalculation, principalMoney, expectedDisbursementDate, repaymentsStartingFromDate,
calculatedRepaymentsStartingFromDate, graceOnPrincipalPayment, recurringMoratoriumOnPrincipalPeriods,
graceOnInterestPayment, graceOnInterestCharged, interestChargedFromDate, inArrearsTolerance, multiDisburseLoan, emiAmount,
disbursementDatas, maxOutstandingBalance, loanProductRelatedDetail.getGraceOnArrearsAgeing(), daysInMonthType,
@@ -1674,7 +1690,7 @@ public final class LoanApplicationTerms {
interestCalculationPeriodMethod, daysInYearType, daysInMonthType, amortizationMethod, repaymentPeriodFrequencyType,
repaymentEvery, numberOfRepayments,
isInterestChargedFromDateSameAsDisbursalDateEnabled != null && isInterestChargedFromDateSameAsDisbursalDateEnabled,
daysInYearCustomStrategy);
daysInYearCustomStrategy, allowPartialPeriodInterestCalcualtion);
}
public Integer getLoanTermFrequency() {
@@ -27,11 +27,13 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.apache.fineract.portfolio.common.domain.DaysInYearCustomStrategyType;
import org.apache.fineract.portfolio.common.domain.DaysInYearType;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
public record LoanRepaymentScheduleModelData(@NotNull LocalDate scheduleGenerationStartDate, @NotNull CurrencyData currency,
@NotNull BigDecimal disbursementAmount, @NotNull LocalDate disbursementDate, @NotNull int numberOfRepayments,
@NotNull int repaymentFrequency, @NotBlank String repaymentFrequencyType, @NotNull BigDecimal annualNominalInterestRate,
@NotNull boolean downPaymentEnabled, @NotNull DaysInMonthType daysInMonth, @NotNull DaysInYearType daysInYear,
BigDecimal downPaymentPercentage, Integer installmentAmountInMultiplesOf, Integer fixedLength,
@NotNull Boolean interestRecognitionOnDisbursementDate, @Nullable DaysInYearCustomStrategyType daysInYearCustomStrategy) {
@NotNull Boolean interestRecognitionOnDisbursementDate, @Nullable DaysInYearCustomStrategyType daysInYearCustomStrategy,
@NotNull InterestMethod interestMethod, @NotNull boolean allowPartialPeriodInterestCalculation) {
}
@@ -48,6 +48,7 @@ public class LoanProductRelatedDetailMinimumData implements LoanProductMinimumRe
private final Integer numberOfRepayments;
private final boolean interestRecognitionOnDisbursementDate;
private final DaysInYearCustomStrategyType daysInYearCustomStrategy;
private final boolean allowPartialPeriodInterestCalculation;
public LoanProductRelatedDetailMinimumData(CurrencyData currency, BigDecimal interestRatePerPeriod,
BigDecimal annualNominalInterestRate, Integer interestChargingGrace, Integer interestPaymentGrace, Integer principalGrace,
@@ -55,7 +56,7 @@ public class LoanProductRelatedDetailMinimumData implements LoanProductMinimumRe
InterestCalculationPeriodMethod interestCalculationPeriodMethod, DaysInYearType daysInYearType, DaysInMonthType daysInMonthType,
AmortizationMethod amortizationMethod, PeriodFrequencyType repaymentPeriodFrequencyType, Integer repaymentEvery,
Integer numberOfRepayments, boolean interestRecognitionOnDisbursementDate,
DaysInYearCustomStrategyType daysInYearCustomStrategy) {
DaysInYearCustomStrategyType daysInYearCustomStrategy, boolean allowPartialPeriodInterestCalculation) {
this.currency = currency;
this.interestRatePerPeriod = interestRatePerPeriod;
this.annualNominalInterestRate = annualNominalInterestRate;
@@ -73,6 +74,7 @@ public class LoanProductRelatedDetailMinimumData implements LoanProductMinimumRe
this.numberOfRepayments = numberOfRepayments;
this.interestRecognitionOnDisbursementDate = interestRecognitionOnDisbursementDate;
this.daysInYearCustomStrategy = daysInYearCustomStrategy;
this.allowPartialPeriodInterestCalculation = allowPartialPeriodInterestCalculation;
}
private Integer defaultToNullIfZero(final Integer value) {
@@ -83,6 +85,11 @@ public class LoanProductRelatedDetailMinimumData implements LoanProductMinimumRe
return defaultTo;
}
@Override
public boolean isAllowPartialPeriodInterestCalculation() {
return allowPartialPeriodInterestCalculation;
}
@Override
public CurrencyData getCurrencyData() {
return currency;
@@ -53,4 +53,8 @@ public enum InterestCalculationPeriodMethod {
return this.value.equals(InterestCalculationPeriodMethod.DAILY.getValue());
}
public boolean isSameAsRepaymentPeriod() {
return this.value.equals(InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD.getValue());
}
}
@@ -52,4 +52,8 @@ public enum InterestMethod {
public boolean isDecliningBalance() {
return this.value.equals(InterestMethod.DECLINING_BALANCE.getValue());
}
public boolean isFlat() {
return this.value.equals(InterestMethod.FLAT.getValue());
}
}
@@ -48,6 +48,8 @@ public interface LoanProductMinimumRepaymentScheduleRelatedDetail {
InterestCalculationPeriodMethod getInterestCalculationPeriodMethod();
boolean isAllowPartialPeriodInterestCalculation();
Integer getRepayEvery();
PeriodFrequencyType getRepaymentPeriodFrequencyType();
@@ -86,7 +86,7 @@ public class LoanProductRelatedDetail implements LoanProductMinimumRepaymentSche
private InterestCalculationPeriodMethod interestCalculationPeriodMethod;
@Column(name = "allow_partial_period_interest_calcualtion", nullable = false)
private boolean allowPartialPeriodInterestCalcualtion;
private boolean allowPartialPeriodInterestCalculation;
@Column(name = "repay_every", nullable = false)
private Integer repayEvery;
@@ -248,7 +248,7 @@ public class LoanProductRelatedDetail implements LoanProductMinimumRepaymentSche
public LoanProductRelatedDetail(final MonetaryCurrency currency, final BigDecimal defaultPrincipal,
final BigDecimal defaultNominalInterestRatePerPeriod, final PeriodFrequencyType interestPeriodFrequencyType,
final BigDecimal defaultAnnualNominalInterestRate, final InterestMethod interestMethod,
final InterestCalculationPeriodMethod interestCalculationPeriodMethod, final boolean allowPartialPeriodInterestCalcualtion,
final InterestCalculationPeriodMethod interestCalculationPeriodMethod, final boolean allowPartialPeriodInterestCalculation,
final Integer repayEvery, final PeriodFrequencyType repaymentFrequencyType, final Integer defaultNumberOfRepayments,
final Integer graceOnPrincipalPayment, final Integer recurringMoratoriumOnPrincipalPeriods,
final Integer graceOnInterestPayment, final Integer graceOnInterestCharged, final AmortizationMethod amortizationMethod,
@@ -272,7 +272,7 @@ public class LoanProductRelatedDetail implements LoanProductMinimumRepaymentSche
this.annualNominalInterestRate = defaultAnnualNominalInterestRate;
this.interestMethod = interestMethod;
this.interestCalculationPeriodMethod = interestCalculationPeriodMethod;
this.allowPartialPeriodInterestCalcualtion = allowPartialPeriodInterestCalcualtion;
this.allowPartialPeriodInterestCalculation = allowPartialPeriodInterestCalculation;
this.repayEvery = repayEvery;
this.repaymentPeriodFrequencyType = repaymentFrequencyType;
this.numberOfRepayments = defaultNumberOfRepayments;
@@ -27,6 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleP
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePlanRepaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.EmbeddableProgressiveLoanScheduleGenerator;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanRepaymentScheduleModelData;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import java.math.BigDecimal;
import java.math.MathContext;
@@ -58,8 +59,10 @@ public class Main {
final Integer fixedLength = null;
final Boolean interestRecognitionOnDisbursementDate = false;
final DaysInYearCustomStrategyType dasInYearCustomStrategy = null;
final InterestMethod interestMethod = InterestMethod.DECLINING_BALANCE;
final boolean allowPartialPeriodInterestCalculation = true;
var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, dasInYearCustomStrategy);
var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, dasInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation);
final LoanSchedulePlan plan = calculator.generate(mc, config);
printPlan(plan);
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleP
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePlanDisbursementPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePlanPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePlanRepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -60,11 +61,13 @@ class EmbeddableProgressiveLoanScheduleGeneratorTest {
final Integer fixedLength = null;
final Boolean interestRecognitionOnDisbursementDate = false;
final DaysInYearCustomStrategyType daysInYearCustomStrategy = null;
final InterestMethod interestMethod = InterestMethod.DECLINING_BALANCE;
final boolean allowPartialPeriodInterestCalculation = true;
var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments,
repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType,
daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate,
daysInYearCustomStrategy);
daysInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation);
final LoanSchedulePlan plan = calculator.generate(mc, config);
@@ -42,7 +42,7 @@ public class InterestScheduleModelServiceGsonContext {
public RepaymentPeriod createRepaymentPeriodInstance(Type type) {
if (type == RepaymentPeriod.class) {
setPrev(RepaymentPeriod.empty(getPrev(), getMc()));
setPrev(RepaymentPeriod.empty(getPrev(), getMc(), getLoanProductRelatedDetail()));
return getPrev();
}
throw new IllegalArgumentException("Unsupported RepaymentPeriod type: " + type);
@@ -35,6 +35,7 @@ import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.apache.fineract.portfolio.common.domain.DaysInYearCustomStrategyType;
@@ -91,7 +92,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
final Money zero = Money.zero(loanProductRelatedDetail.getCurrencyData(), mc);
final AtomicReference<RepaymentPeriod> prev = new AtomicReference<>();
List<RepaymentPeriod> repaymentPeriods = periods.stream().map(e -> {
RepaymentPeriod rp = RepaymentPeriod.create(prev.get(), from.apply(e), to.apply(e), zero, mc);
RepaymentPeriod rp = RepaymentPeriod.create(prev.get(), from.apply(e), to.apply(e), zero, mc, loanProductRelatedDetail);
prev.set(rp);
return rp;
}).toList();
@@ -114,10 +115,19 @@ public final class ProgressiveEMICalculator implements EMICalculator {
@Override
public void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate disbursementDueDate,
final Money disbursedAmount) {
addDisbursement(scheduleModel, EmiChangeOperation.disburse(disbursementDueDate, disbursedAmount));
LocalDate effectiveDueDate = scheduleModel.loanProductRelatedDetail().getInterestCalculationPeriodMethod() != null
&& scheduleModel.loanProductRelatedDetail().getInterestCalculationPeriodMethod().isSameAsRepaymentPeriod()
&& !scheduleModel.loanProductRelatedDetail().isAllowPartialPeriodInterestCalculation()
? scheduleModel.repaymentPeriods().stream().filter(rp -> rp.getDueDate().isAfter(disbursementDueDate)).findFirst()
.map(RepaymentPeriod::getFromDate).orElse(disbursementDueDate)
: disbursementDueDate;
addDisbursement(scheduleModel, EmiChangeOperation.disburse(effectiveDueDate, disbursedAmount));
}
private void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate())).forEach(
rp -> rp.setTotalDisbursedAmount(MathUtil.nullToZero(rp.getTotalDisbursedAmount()).add(operation.getAmount().getAmount())));
scheduleModel
.changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(),
scheduleModel.zero(), scheduleModel.zero())
@@ -444,11 +454,33 @@ public final class ProgressiveEMICalculator implements EMICalculator {
return scheduleModelCopy;
}
private void calculateEMIValueAndRateFactorsForFlatInterestMethod(final LocalDate calculateFromRepaymentPeriodDueDate,
final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
final List<RepaymentPeriod> relatedRepaymentPeriods = scheduleModel.getRelatedRepaymentPeriods(calculateFromRepaymentPeriodDueDate);
calculateRateFactorForPeriods(relatedRepaymentPeriods, scheduleModel);
if (relatedRepaymentPeriods.isEmpty()) {
return;
}
calculateEMIOnActualModelWithFlatInterestMethod(relatedRepaymentPeriods, scheduleModel);
}
/**
* Calculate Equal Monthly Installment value and Rate Factor -1 values for calculate Interest
*/
private void calculateEMIValueAndRateFactors(final LocalDate calculateFromRepaymentPeriodDueDate,
final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
switch (scheduleModel.loanProductRelatedDetail().getInterestMethod()) {
case FLAT ->
calculateEMIValueAndRateFactorsForFlatInterestMethod(calculateFromRepaymentPeriodDueDate, scheduleModel, operation);
case DECLINING_BALANCE -> calculateEMIValueAndRateFactorsForDecliningBalanceInterestMethod(calculateFromRepaymentPeriodDueDate,
scheduleModel, operation);
default -> throw new UnsupportedOperationException(
"Unsupported interest method: " + scheduleModel.loanProductRelatedDetail().getInterestMethod());
}
}
private void calculateEMIValueAndRateFactorsForDecliningBalanceInterestMethod(final LocalDate calculateFromRepaymentPeriodDueDate,
final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
final List<RepaymentPeriod> relatedRepaymentPeriods = scheduleModel.getRelatedRepaymentPeriods(calculateFromRepaymentPeriodDueDate);
final boolean onlyOnActualModelShouldApply = scheduleModel.isEmpty()
|| operation.getAction() == EmiChangeOperation.Action.INTEREST_RATE_CHANGE || scheduleModel.isCopy();
@@ -637,6 +669,20 @@ public final class ProgressiveEMICalculator implements EMICalculator {
final boolean partialPeriodCalculationNeeded = daysInYearType == DaysInYearType.ACTUAL && numberOfYearsDifferenceInPeriod > 0
&& (!DaysInYearCustomStrategyType.FEB_29_PERIOD_ONLY.equals(daysInYearCustomStrategy)
|| isPeriodContainsFeb29(repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate()));
final BigDecimal repaymentEvery = BigDecimal.valueOf(loanProductRelatedDetail.getRepayEvery());
if (loanProductRelatedDetail.getInterestCalculationPeriodMethod() != null
&& loanProductRelatedDetail.getInterestCalculationPeriodMethod().isSameAsRepaymentPeriod()) {
if (loanProductRelatedDetail.getRepaymentPeriodFrequencyType().isMonthly()) {
return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, BigDecimal.valueOf(12), actualDaysInPeriod,
calculatedDaysInPeriod, mc);
}
if (loanProductRelatedDetail.getRepaymentPeriodFrequencyType().isWeekly()) {
return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, BigDecimal.valueOf(52), actualDaysInPeriod,
calculatedDaysInPeriod, mc);
}
}
// TODO check: loanApplicationTerms.calculatePeriodsBetweenDates(startDate, endDate); // calculate period data
// TODO review: (repayment frequency: days, weeks, years; validation day is month fix 30)
@@ -747,13 +793,26 @@ public final class ProgressiveEMICalculator implements EMICalculator {
repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate());
final BigDecimal actualDaysInPeriod = BigDecimal
.valueOf(DateUtils.getDifferenceInDays(interestPeriodFromDate, interestPeriodDueDate));
final BigDecimal calculatedDaysInPeriod = BigDecimal
final BigDecimal calculatedDaysInRepaymentPeriod = BigDecimal
.valueOf(DateUtils.getDifferenceInDays(repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate()));
final int numberOfYearsDifferenceInPeriod = interestPeriodDueDate.getYear() - interestPeriodFromDate.getYear();
final boolean partialPeriodCalculationNeeded = daysInYearType == DaysInYearType.ACTUAL && numberOfYearsDifferenceInPeriod > 0
&& (!DaysInYearCustomStrategyType.FEB_29_PERIOD_ONLY.equals(daysInYearCustomStrategy)
|| isPeriodContainsFeb29(repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate()));
final BigDecimal daysInMonth = daysInMonthType.isDaysInMonth_30() ? BigDecimal.valueOf(30) : calculatedDaysInPeriod;
final BigDecimal daysInMonth = daysInMonthType.isDaysInMonth_30() ? BigDecimal.valueOf(30) : calculatedDaysInRepaymentPeriod;
if (loanProductRelatedDetail.getInterestCalculationPeriodMethod() != null
&& loanProductRelatedDetail.getInterestCalculationPeriodMethod().isSameAsRepaymentPeriod()) {
if (loanProductRelatedDetail.getRepaymentPeriodFrequencyType().isMonthly()) {
return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, BigDecimal.valueOf(12), actualDaysInPeriod,
calculatedDaysInRepaymentPeriod, mc);
}
if (loanProductRelatedDetail.getRepaymentPeriodFrequencyType().isWeekly()) {
return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, BigDecimal.valueOf(52), actualDaysInPeriod,
calculatedDaysInRepaymentPeriod, mc);
}
}
// TODO check: loanApplicationTerms.calculatePeriodsBetweenDates(startDate, endDate); // calculate period data
// TODO review: (repayment frequency: days, weeks, years; validation day is month fix 30)
@@ -769,7 +828,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
case ACTUAL -> rateFactorByRepaymentPeriod(interestRate, actualDaysInPeriod, BigDecimal.ONE, daysInYear, BigDecimal.ONE,
BigDecimal.ONE, mc);
case DAYS_30 -> calculateRateFactorPerPeriodBasedOnRepaymentFrequency(interestRate, repaymentFrequency, repaymentEvery,
daysInMonth, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc);
daysInMonth, daysInYear, actualDaysInPeriod, calculatedDaysInRepaymentPeriod, mc);
default -> throw new UnsupportedOperationException("Unsupported combination: Days in month: " + daysInMonthType);
};
}
@@ -845,10 +904,51 @@ public final class ProgressiveEMICalculator implements EMICalculator {
};
}
private void calculateEMIOnActualModelWithFlatInterestMethod(List<RepaymentPeriod> repaymentPeriods,
ProgressiveLoanInterestScheduleModel scheduleModel) {
final MathContext mc = scheduleModel.mc();
final CurrencyData currency = scheduleModel.loanProductRelatedDetail().getCurrencyData();
RepaymentPeriod first = repaymentPeriods.getFirst();
RepaymentPeriod last = repaymentPeriods.getLast();
Money sumOfInterest = Money.zero(currency);
for (RepaymentPeriod rp : repaymentPeriods) {
Money interest = rp.calculateCalculatedDueInterest();
sumOfInterest = sumOfInterest.add(interest);
rp.setEmi(interest);
}
// already repaid principals should be subtracted from total disbursed amount to calculate correct EMI.
BigDecimal alreadyRepaidPrincipals = first.getPrevious()
.map(rp -> rp.calculateTotalDisbursedAmountTillGivenPeriod(null).subtract(rp.getOutstandingLoanBalance().getAmount()))
.orElse(BigDecimal.ZERO);
Money total = Money.of(currency, first.calculateTotalDisbursedAmountTillGivenPeriod(first.getLastInterestPeriod()))
.plus(sumOfInterest).minus(alreadyRepaidPrincipals);
Money periodEmi = total.dividedBy(repaymentPeriods.size(), mc);
Money remainder = total.minus(periodEmi.multipliedBy(repaymentPeriods.size(), mc));
repaymentPeriods.forEach(rp -> {
Money emi = rp.equals(last) ? periodEmi.add(remainder) : periodEmi;
rp.setEmi(emi);
rp.setOriginalEmi(emi);
rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance);
});
}
private void calculateEMIOnActualModel(List<RepaymentPeriod> repaymentPeriods, ProgressiveLoanInterestScheduleModel scheduleModel) {
if (repaymentPeriods.isEmpty()) {
return;
}
switch (scheduleModel.loanProductRelatedDetail().getInterestMethod()) {
case FLAT -> calculateEMIOnActualModelWithFlatInterestMethod(repaymentPeriods, scheduleModel);
case DECLINING_BALANCE -> calculateEMIOnActualModelWithDecliningBalanceInterestMethod(repaymentPeriods, scheduleModel);
default -> throw new UnsupportedOperationException("Unsupported interest method");
}
}
private void calculateEMIOnActualModelWithDecliningBalanceInterestMethod(List<RepaymentPeriod> repaymentPeriods,
ProgressiveLoanInterestScheduleModel scheduleModel) {
final MathContext mc = scheduleModel.mc();
final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorPlus1N(repaymentPeriods, mc));
final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(repaymentPeriods, mc));
@@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
@Getter
@ToString(exclude = { "repaymentPeriod" })
@@ -55,7 +56,7 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
/** Stores credited principals. Related transactions: Chargeback or Credit Balance Refound */
private Money creditedPrincipal;
/** Stores credited principals. Related transaction: Chargeback */
/** Stores credited interest. Related transaction: Chargeback */
private Money creditedInterest;
private Money disbursementAmount;
@@ -139,15 +140,26 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
}
long lengthTillPeriodDueDate = getLengthTillPeriodDueDate();
final BigDecimal interestDueTillRepaymentDueDate = lengthTillPeriodDueDate == 0 //
? BigDecimal.ZERO //
: getOutstandingLoanBalance().getAmount() //
.multiply(getRateFactorTillPeriodDueDate(), mc) //
.divide(BigDecimal.valueOf(lengthTillPeriodDueDate), mc) //
.multiply(BigDecimal.valueOf(getLength()), mc); //
final BigDecimal interestDueTillRepaymentDueDate = getCalculatedDueInterest(
getRepaymentPeriod().getLoanProductRelatedDetail().getInterestMethod(), lengthTillPeriodDueDate); //
return MathUtil.negativeToZero(MathUtil.add(mc, creditedInterest.getAmount(), interestDueTillRepaymentDueDate));
}
public BigDecimal getCalculatedDueInterest(InterestMethod method, long lengthTillPeriodDueDate) {
if (lengthTillPeriodDueDate == 0) {
return BigDecimal.ZERO;
}
BigDecimal baseAmount = switch (method) {
case FLAT -> MathUtil.nullToZero(getRepaymentPeriod().calculateTotalDisbursedAmountTillGivenPeriod(this));
case DECLINING_BALANCE -> getOutstandingLoanBalance().getAmount();
default -> throw new UnsupportedOperationException("Method not implemented: " + method);
};
return baseAmount //
.multiply(getRateFactorTillPeriodDueDate(), mc) //
.divide(BigDecimal.valueOf(lengthTillPeriodDueDate), mc) //
.multiply(BigDecimal.valueOf(getLength()), mc);
}
public long getLength() {
return DateUtils.getDifferenceInDays(fromDate, dueDate);
}
@@ -199,18 +199,8 @@ public class ProgressiveLoanInterestScheduleModel {
if (balanceChangeDate == null) {
return Optional.empty();
}
// TODO use isInPeriod
return repaymentPeriods.stream()//
.filter(repaymentPeriod -> {
final boolean isFirstPeriod = repaymentPeriod.getPrevious().isEmpty();
if (isFirstPeriod) {
return !balanceChangeDate.isBefore(repaymentPeriod.getFromDate())
&& !balanceChangeDate.isAfter(repaymentPeriod.getDueDate());
} else {
return balanceChangeDate.isAfter(repaymentPeriod.getFromDate())
&& !balanceChangeDate.isAfter(repaymentPeriod.getDueDate());
}
})//
.filter(period -> isInPeriod(balanceChangeDate, period.getFromDate(), period.getDueDate(), period.isFirstRepaymentPeriod()))//
.findFirst();
}
@@ -34,6 +34,7 @@ import lombok.ToString;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
import org.apache.fineract.portfolio.util.Memo;
@ToString(exclude = { "previous" })
@@ -79,8 +80,17 @@ public final class RepaymentPeriod {
@Setter
private boolean isInterestMoved = false;
@Setter
@Getter
private BigDecimal totalDisbursedAmount;
@JsonExclude
@Getter
private final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail;
private RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, List<InterestPeriod> interestPeriods,
Money emi, Money originalEmi, Money paidPrincipal, Money paidInterest, Money futureUnrecognizedInterest, MathContext mc) {
Money emi, Money originalEmi, Money paidPrincipal, Money paidInterest, Money futureUnrecognizedInterest, MathContext mc,
LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail) {
this.previous = previous;
this.fromDate = fromDate;
this.dueDate = dueDate;
@@ -91,16 +101,19 @@ public final class RepaymentPeriod {
this.paidInterest = paidInterest;
this.futureUnrecognizedInterest = futureUnrecognizedInterest;
this.mc = mc;
this.loanProductRelatedDetail = loanProductRelatedDetail;
}
public static RepaymentPeriod empty(RepaymentPeriod previous, MathContext mc) {
return new RepaymentPeriod(previous, null, null, new ArrayList<>(), null, null, null, null, null, mc);
public static RepaymentPeriod empty(RepaymentPeriod previous, MathContext mc,
LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail) {
return new RepaymentPeriod(previous, null, null, new ArrayList<>(), null, null, null, null, null, mc, loanProductRelatedDetail);
}
public static RepaymentPeriod create(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, Money emi, MathContext mc) {
public static RepaymentPeriod create(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, Money emi, MathContext mc,
LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail) {
final Money zero = emi.zero();
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, fromDate, dueDate, new ArrayList<>(), emi, emi, zero, zero,
zero, mc);
zero, mc, loanProductRelatedDetail);
// There is always at least 1 interest period, by default with same from-due date as repayment period
newRepaymentPeriod.interestPeriods.add(InterestPeriod.withEmptyAmounts(newRepaymentPeriod, fromDate, dueDate));
return newRepaymentPeriod;
@@ -109,7 +122,8 @@ public final class RepaymentPeriod {
public static RepaymentPeriod copy(RepaymentPeriod previous, RepaymentPeriod repaymentPeriod, MathContext mc) {
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, repaymentPeriod.fromDate, repaymentPeriod.dueDate,
new ArrayList<>(), repaymentPeriod.emi, repaymentPeriod.originalEmi, repaymentPeriod.paidPrincipal,
repaymentPeriod.paidInterest, repaymentPeriod.futureUnrecognizedInterest, mc);
repaymentPeriod.paidInterest, repaymentPeriod.futureUnrecognizedInterest, mc,
repaymentPeriod.getLoanProductRelatedDetail());
// There is always at least 1 interest period, by default with same from-due date as repayment period
for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) {
newRepaymentPeriod.interestPeriods.add(InterestPeriod.copy(newRepaymentPeriod, interestPeriod, mc));
@@ -120,7 +134,8 @@ public final class RepaymentPeriod {
public static RepaymentPeriod copyWithoutPaidAmounts(RepaymentPeriod previous, RepaymentPeriod repaymentPeriod, MathContext mc) {
final Money zero = repaymentPeriod.emi.zero();
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, repaymentPeriod.fromDate, repaymentPeriod.dueDate,
new ArrayList<>(), repaymentPeriod.emi, repaymentPeriod.originalEmi, zero, zero, zero, mc);
new ArrayList<>(), repaymentPeriod.emi, repaymentPeriod.originalEmi, zero, zero, zero, mc,
repaymentPeriod.getLoanProductRelatedDetail());
// There is always at least 1 interest period, by default with same from-due date as repayment period
for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) {
var interestPeriodCopy = InterestPeriod.copy(newRepaymentPeriod, interestPeriod);
@@ -160,13 +175,13 @@ public final class RepaymentPeriod {
@NotNull
public Money getCalculatedDueInterest() {
if (calculatedDueInterestCalculation == null) {
calculatedDueInterestCalculation = Memo.of(this::calculateCalculatedDueInterest,
() -> new Object[] { this.previous, this.interestPeriods, this.futureUnrecognizedInterest, this.isInterestMoved });
calculatedDueInterestCalculation = Memo.of(this::calculateCalculatedDueInterest, () -> new Object[] { this.previous,
this.interestPeriods, this.futureUnrecognizedInterest, this.isInterestMoved, this.totalDisbursedAmount });
}
return calculatedDueInterestCalculation.get();
}
private Money calculateCalculatedDueInterest() {
public Money calculateCalculatedDueInterest() {
Money calculatedDueInterest = getZero(mc);
if (!isInterestMoved) {
calculatedDueInterest = Money.of(emi.getCurrencyData(),
@@ -192,7 +207,8 @@ public final class RepaymentPeriod {
() -> MathUtil.max(getPaidPrincipal().isGreaterThan(getCalculatedDuePrincipal()) ? getPaidInterest()
: MathUtil.min(getCalculatedDueInterest(), getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest(), false),
getPaidInterest(), false),
() -> new Object[] { paidPrincipal, paidInterest, interestPeriods, futureUnrecognizedInterest });
() -> new Object[] { paidPrincipal, paidInterest, interestPeriods, futureUnrecognizedInterest, totalDisbursedAmount,
emi });
}
return dueInterestCalculation.get();
}
@@ -307,7 +323,7 @@ public final class RepaymentPeriod {
.minus(getDuePrincipal(), mc)//
.plus(getPaidPrincipal(), mc);//
return MathUtil.negativeToZero(calculatedOutStandingLoanBalance, mc);
}, () -> new Object[] { paidPrincipal, paidInterest, interestPeriods });
}, () -> new Object[] { paidPrincipal, paidInterest, interestPeriods, totalDisbursedAmount });
}
return outstandingBalanceCalculation.get();
}
@@ -369,4 +385,22 @@ public final class RepaymentPeriod {
this.paidInterest = paidInterest.zero();
this.paidPrincipal = paidPrincipal.zero();
}
/**
* @param tillPeriod
* can be null. if null it calculates total disbursement including last interest period.
* @return disbursed amount til interest period.
*/
public BigDecimal calculateTotalDisbursedAmountTillGivenPeriod(InterestPeriod tillPeriod) {
BigDecimal res = MathUtil.nullToZero(getTotalDisbursedAmount());
for (InterestPeriod interestPeriod : this.interestPeriods) {
if (interestPeriod.equals(tillPeriod)) {
break;
}
if (!interestPeriod.getDueDate().equals(getFromDate()) && interestPeriod.getDisbursementAmount() != null) {
res = res.add(interestPeriod.getDisbursementAmount().getAmount());
}
}
return res;
}
}
@@ -36,6 +36,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleP
import org.apache.fineract.portfolio.loanaccount.service.InterestScheduleModelRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionProcessingService;
import org.apache.fineract.portfolio.loanproduct.calc.ProgressiveEMICalculator;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -63,7 +64,8 @@ class LoanScheduleGeneratorTest {
void testGenerateLoanSchedule() {
LoanRepaymentScheduleModelData modelData = new LoanRepaymentScheduleModelData(LocalDate.of(2024, 1, 1), CURRENCY,
DISBURSEMENT_AMOUNT, DISBURSEMENT_DATE, NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY, REPAYMENT_FREQUENCY_TYPE,
NOMINAL_INTEREST_RATE, false, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, null, null, null, false, null);
NOMINAL_INTEREST_RATE, false, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, null, null, null, false, null,
InterestMethod.DECLINING_BALANCE, true);
ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator();
ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(scheduledDateGenerator, emiCalculator,
@@ -101,7 +103,7 @@ class LoanScheduleGeneratorTest {
LoanRepaymentScheduleModelData modelData = new LoanRepaymentScheduleModelData(LocalDate.of(2024, 1, 1), CURRENCY,
DISBURSEMENT_AMOUNT_100, LocalDate.of(2024, 1, 1), NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY, REPAYMENT_FREQUENCY_TYPE,
NOMINAL_INTEREST_RATE, true, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, DOWN_PAYMENT_PORTION, null, null, false,
null);
null, InterestMethod.DECLINING_BALANCE, true);
ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator();
ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(scheduledDateGenerator, emiCalculator,
@@ -24,6 +24,8 @@ import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
@@ -42,6 +44,8 @@ import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod;
import org.apache.fineract.portfolio.loanproduct.calc.data.PeriodDueDetails;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.calc.data.RepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.InterestCalculationPeriodMethod;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
@@ -56,6 +60,7 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.lang.NonNull;
@Slf4j
@ExtendWith(MockitoExtension.class)
class ProgressiveEMICalculatorTest {
@@ -99,6 +104,9 @@ class ProgressiveEMICalculatorTest {
public void setupTestDefaults() {
Mockito.when(loanProductRelatedDetail.isInterestRecognitionOnDisbursementDate()).thenReturn(false);
Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy()).thenReturn(null);
Mockito.when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.DECLINING_BALANCE);
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod()).thenReturn(InterestCalculationPeriodMethod.DAILY);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
}
private BigDecimal getRateFactorsByMonth(final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType,
@@ -3226,6 +3234,404 @@ class ProgressiveEMICalculatorTest {
}
@Nested
public class InterestTypeFlat {
@BeforeEach
public void setupTestDefaults() {
Mockito.when(loanProductRelatedDetail.isInterestRecognitionOnDisbursementDate()).thenReturn(false);
Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy()).thenReturn(null);
Mockito.when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.FLAT);
Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency);
// InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod())
.thenReturn(InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
}
@Test
void test_sameAsRepayment_days_repay_every_6_periods_5() {
final BigDecimal interestRate = BigDecimal.valueOf(12.2);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.DAYS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(5);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(6);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 202.00, 2.00, 200.00, 800.00, false); //
checkPeriod(interestSchedule, 1, 202.00, 2.00, 200.00, 600.00, false); //
checkPeriod(interestSchedule, 2, 202.00, 2.00, 200.00, 400.00, false); //
checkPeriod(interestSchedule, 3, 202.00, 2.00, 200.00, 200.00, false); //
checkPeriod(interestSchedule, 4, 202.00, 2.00, 200.00, 0.00, false); //
}
@Test
void test_sameAsRepayment_week_repay_every_1_periods_10() {
final BigDecimal interestRate = BigDecimal.valueOf(5.2);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = expectedRepaymentWeeks(disbursementDate, 10, 1);
final Integer installmentAmountInMultiplesOf = null;
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_364.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS);
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod())
.thenReturn(InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 101.00, 1.00, 100.00, 900.00, false); //
checkPeriod(interestSchedule, 1, 101.00, 1.00, 100.00, 800.00, false); //
checkPeriod(interestSchedule, 2, 101.00, 1.00, 100.00, 700.00, false); //
checkPeriod(interestSchedule, 3, 101.00, 1.00, 100.00, 600.00, false); //
checkPeriod(interestSchedule, 4, 101.00, 1.00, 100.00, 500.00, false); //
checkPeriod(interestSchedule, 5, 101.00, 1.00, 100.00, 400.00, false); //
checkPeriod(interestSchedule, 6, 101.00, 1.00, 100.00, 300.00, false); //
checkPeriod(interestSchedule, 7, 101.00, 1.00, 100.00, 200.00, false); //
checkPeriod(interestSchedule, 8, 101.00, 1.00, 100.00, 100.00, false); //
checkPeriod(interestSchedule, 9, 101.00, 1.00, 100.00, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_2_periods_8() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(8);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(2);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 145.00, 20.00, 125.00, 875.00, false); //
checkPeriod(interestSchedule, 1, 145.00, 20.00, 125.00, 750.00, false); //
checkPeriod(interestSchedule, 2, 145.00, 20.00, 125.00, 625.00, false); //
checkPeriod(interestSchedule, 3, 145.00, 20.00, 125.00, 500.00, false); //
checkPeriod(interestSchedule, 4, 145.00, 20.00, 125.00, 375.00, false); //
checkPeriod(interestSchedule, 5, 145.00, 20.00, 125.00, 250.00, false); //
checkPeriod(interestSchedule, 6, 145.00, 20.00, 125.00, 125.00, false); //
checkPeriod(interestSchedule, 7, 145.00, 20.00, 125.00, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_1_periods_3_second_disbursement_on_repayment_due_date_allow_partial() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 343.33, 10.00, 333.33, 333.34, false); //
checkPeriod(interestSchedule, 2, 343.34, 10.00, 333.34, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusMonths(1), toMoney(250.0));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 916.67, false); //
checkPeriod(interestSchedule, 1, 470.84, 12.50, 458.34, 458.33, false); //
checkPeriod(interestSchedule, 2, 470.83, 12.50, 458.33, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_1_periods_3_second_disbursement_on_repayment_due_date_disallow_partial() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(false);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 343.33, 10.00, 333.33, 333.34, false); //
checkPeriod(interestSchedule, 2, 343.34, 10.00, 333.34, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusMonths(1), toMoney(250.0));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 916.67, false); //
checkPeriod(interestSchedule, 1, 470.84, 12.50, 458.34, 458.33, false); //
checkPeriod(interestSchedule, 2, 470.83, 12.50, 458.33, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_1_periods_3() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 343.33, 10.00, 333.33, 333.34, false); //
checkPeriod(interestSchedule, 2, 343.34, 10.00, 333.34, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusDays(4), toMoney(250.0));
checkPeriod(interestSchedule, 0, 429.06, 12.18, 416.88, 833.12, false); //
checkPeriod(interestSchedule, 1, 429.06, 12.50, 416.56, 416.56, false); //
checkPeriod(interestSchedule, 2, 429.06, 12.50, 416.56, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusDays(4), toMoney(250.0));
checkPeriod(interestSchedule, 0, 514.78, 14.35, 500.43, 999.57, false); //
checkPeriod(interestSchedule, 1, 514.78, 15.00, 499.78, 499.79, false); //
checkPeriod(interestSchedule, 2, 514.79, 15.00, 499.79, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_1_periods_20() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(20);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 60.00, 10.00, 50.00, 950.00, false); //
checkPeriod(interestSchedule, 1, 60.00, 10.00, 50.00, 900.00, false); //
checkPeriod(interestSchedule, 2, 60.00, 10.00, 50.00, 850.00, false); //
checkPeriod(interestSchedule, 3, 60.00, 10.00, 50.00, 800.00, false); //
checkPeriod(interestSchedule, 4, 60.00, 10.00, 50.00, 750.00, false); //
checkPeriod(interestSchedule, 5, 60.00, 10.00, 50.00, 700.00, false); //
checkPeriod(interestSchedule, 6, 60.00, 10.00, 50.00, 650.00, false); //
checkPeriod(interestSchedule, 7, 60.00, 10.00, 50.00, 600.00, false); //
checkPeriod(interestSchedule, 8, 60.00, 10.00, 50.00, 550.00, false); //
checkPeriod(interestSchedule, 9, 60.00, 10.00, 50.00, 500.00, false); //
checkPeriod(interestSchedule, 10, 60.00, 10.00, 50.00, 450.00, false); //
checkPeriod(interestSchedule, 11, 60.00, 10.00, 50.00, 400.00, false); //
checkPeriod(interestSchedule, 12, 60.00, 10.00, 50.00, 350.00, false); //
checkPeriod(interestSchedule, 13, 60.00, 10.00, 50.00, 300.00, false); //
checkPeriod(interestSchedule, 14, 60.00, 10.00, 50.00, 250.00, false); //
checkPeriod(interestSchedule, 15, 60.00, 10.00, 50.00, 200.00, false); //
checkPeriod(interestSchedule, 16, 60.00, 10.00, 50.00, 150.00, false); //
checkPeriod(interestSchedule, 17, 60.00, 10.00, 50.00, 100.00, false); //
checkPeriod(interestSchedule, 18, 60.00, 10.00, 50.00, 50.00, false); //
checkPeriod(interestSchedule, 19, 60.00, 10.00, 50.00, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_1_periods_24() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(24);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(7500.0));
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(2500.0));
checkPeriod(interestSchedule, 0, 516.67, 100.00, 416.67, 9583.33, false); //
checkPeriod(interestSchedule, 1, 516.67, 100.00, 416.67, 9166.66, false); //
checkPeriod(interestSchedule, 2, 516.67, 100.00, 416.67, 8749.99, false); //
checkPeriod(interestSchedule, 3, 516.67, 100.00, 416.67, 8333.32, false); //
checkPeriod(interestSchedule, 4, 516.67, 100.00, 416.67, 7916.65, false); //
checkPeriod(interestSchedule, 5, 516.67, 100.00, 416.67, 7499.98, false); //
checkPeriod(interestSchedule, 6, 516.67, 100.00, 416.67, 7083.31, false); //
checkPeriod(interestSchedule, 7, 516.67, 100.00, 416.67, 6666.64, false); //
checkPeriod(interestSchedule, 8, 516.67, 100.00, 416.67, 6249.97, false); //
checkPeriod(interestSchedule, 9, 516.67, 100.00, 416.67, 5833.30, false); //
checkPeriod(interestSchedule, 10, 516.67, 100.00, 416.67, 5416.63, false); //
checkPeriod(interestSchedule, 11, 516.67, 100.00, 416.67, 4999.96, false); //
checkPeriod(interestSchedule, 12, 516.67, 100.00, 416.67, 4583.29, false); //
checkPeriod(interestSchedule, 13, 516.67, 100.00, 416.67, 4166.62, false); //
checkPeriod(interestSchedule, 14, 516.67, 100.00, 416.67, 3749.95, false); //
checkPeriod(interestSchedule, 15, 516.67, 100.00, 416.67, 3333.28, false); //
checkPeriod(interestSchedule, 16, 516.67, 100.00, 416.67, 2916.61, false); //
checkPeriod(interestSchedule, 17, 516.67, 100.00, 416.67, 2499.94, false); //
checkPeriod(interestSchedule, 18, 516.67, 100.00, 416.67, 2083.27, false); //
checkPeriod(interestSchedule, 19, 516.67, 100.00, 416.67, 1666.60, false); //
checkPeriod(interestSchedule, 20, 516.67, 100.00, 416.67, 1249.93, false); //
checkPeriod(interestSchedule, 21, 516.67, 100.00, 416.67, 833.26, false); //
checkPeriod(interestSchedule, 22, 516.67, 100.00, 416.67, 416.59, false); //
checkPeriod(interestSchedule, 23, 516.59, 100.00, 416.59, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusMonths(3).plusDays(4), toMoney(1000.0));
checkPeriod(interestSchedule, 0, 516.67, 100.00, 416.67, 9583.33, false); //
checkPeriod(interestSchedule, 1, 516.67, 100.00, 416.67, 9166.66, false); //
checkPeriod(interestSchedule, 2, 516.67, 100.00, 416.67, 8749.99, false); //
checkPeriod(interestSchedule, 3, 574.22, 108.67, 465.55, 9284.44, false); //
checkPeriod(interestSchedule, 4, 574.22, 110.00, 464.22, 8820.22, false); //
checkPeriod(interestSchedule, 5, 574.22, 110.00, 464.22, 8356.00, false); //
checkPeriod(interestSchedule, 6, 574.22, 110.00, 464.22, 7891.78, false); //
checkPeriod(interestSchedule, 7, 574.22, 110.00, 464.22, 7427.56, false); //
checkPeriod(interestSchedule, 8, 574.22, 110.00, 464.22, 6963.34, false); //
checkPeriod(interestSchedule, 9, 574.22, 110.00, 464.22, 6499.12, false); //
checkPeriod(interestSchedule, 10, 574.22, 110.00, 464.22, 6034.90, false); //
checkPeriod(interestSchedule, 11, 574.22, 110.00, 464.22, 5570.68, false); //
checkPeriod(interestSchedule, 12, 574.22, 110.00, 464.22, 5106.46, false); //
checkPeriod(interestSchedule, 13, 574.22, 110.00, 464.22, 4642.24, false); //
checkPeriod(interestSchedule, 14, 574.22, 110.00, 464.22, 4178.02, false); //
checkPeriod(interestSchedule, 15, 574.22, 110.00, 464.22, 3713.80, false); //
checkPeriod(interestSchedule, 16, 574.22, 110.00, 464.22, 3249.58, false); //
checkPeriod(interestSchedule, 17, 574.22, 110.00, 464.22, 2785.36, false); //
checkPeriod(interestSchedule, 18, 574.22, 110.00, 464.22, 2321.14, false); //
checkPeriod(interestSchedule, 19, 574.22, 110.00, 464.22, 1856.92, false); //
checkPeriod(interestSchedule, 20, 574.22, 110.00, 464.22, 1392.70, false); //
checkPeriod(interestSchedule, 21, 574.22, 110.00, 464.22, 928.48, false); //
checkPeriod(interestSchedule, 22, 574.22, 110.00, 464.22, 464.26, false); //
checkPeriod(interestSchedule, 23, 574.26, 110.00, 464.26, 0.00, false); //
}
@Test
void test_sameAsRepayment_month_repay_every_1_periods_3__not_calculate_exact_days() {
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final LocalDate disbursementDate = LocalDate.of(2024, 1, 1);
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(false);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000.0));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 343.33, 10.00, 333.33, 333.34, false); //
checkPeriod(interestSchedule, 2, 343.34, 10.00, 333.34, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusDays(4), toMoney(250.0));
checkPeriod(interestSchedule, 0, 429.17, 12.50, 416.67, 833.33, false); //
checkPeriod(interestSchedule, 1, 429.17, 12.50, 416.67, 416.66, false); //
checkPeriod(interestSchedule, 2, 429.16, 12.50, 416.66, 0.00, false); //
emiCalculator.addDisbursement(interestSchedule, disbursementDate.plusDays(17), toMoney(250.0));
checkPeriod(interestSchedule, 0, 515.00, 15.00, 500.00, 1000.00, false); //
checkPeriod(interestSchedule, 1, 515.00, 15.00, 500.00, 500.00, false); //
checkPeriod(interestSchedule, 2, 515.00, 15.00, 500.00, 0.00, false); //
}
}
// utilities
private List<LoanScheduleModelRepaymentPeriod> generateExpectedRepaymentPeriods(LocalDate disbursementDate) {
return switch (loanProductRelatedDetail.getRepaymentPeriodFrequencyType()) {
case MONTHS -> expectedRepaymentsMonthly(disbursementDate, loanProductRelatedDetail.getNumberOfRepayments(),
loanProductRelatedDetail.getRepayEvery());
case WEEKS -> expectedRepaymentWeeks(disbursementDate, loanProductRelatedDetail.getNumberOfRepayments(),
loanProductRelatedDetail.getRepayEvery());
case DAYS -> expectedRepaymentDays(disbursementDate, loanProductRelatedDetail.getNumberOfRepayments(),
loanProductRelatedDetail.getRepayEvery());
default -> throw new UnsupportedOperationException();
};
}
List<LoanScheduleModelRepaymentPeriod> expectedRepaymentDays(final LocalDate disbursementDate, final int periods, final int length) {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = new ArrayList<>(periods);
IntStream.range(0, periods).forEach(i -> expectedRepaymentPeriods
.add(repayment(i + 1, disbursementDate.plusDays((long) i * length), disbursementDate.plusDays((long) (i + 1) * length))));
return expectedRepaymentPeriods;
}
List<LoanScheduleModelRepaymentPeriod> expectedRepaymentsMonthly(final LocalDate disbursementDate, final int periods,
final int length) {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = new ArrayList<>(periods);
IntStream.range(0, periods).forEach(i -> expectedRepaymentPeriods.add(
repayment(i + 1, disbursementDate.plusMonths((long) i * length), disbursementDate.plusMonths((long) (i + 1) * length))));
return expectedRepaymentPeriods;
}
List<LoanScheduleModelRepaymentPeriod> expectedRepaymentWeeks(final LocalDate disbursementDate, final int periods, final int length) {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = new ArrayList<>(periods);
IntStream.range(0, periods).forEach(i -> expectedRepaymentPeriods
.add(repayment(i + 1, disbursementDate.plusWeeks((long) i * length), disbursementDate.plusWeeks((long) (i + 1) * length))));
return expectedRepaymentPeriods;
}
private static LoanScheduleModelRepaymentPeriod repayment(int periodNumber, LocalDate fromDate, LocalDate dueDate) {
final Money zeroAmount = Money.zero(currency);
return LoanScheduleModelRepaymentPeriod.repayment(periodNumber, fromDate, dueDate, zeroAmount, zeroAmount, zeroAmount, zeroAmount,
@@ -232,7 +232,7 @@ public class LoanScheduleAssembler {
Boolean allowPartialPeriodInterestCalcualtion = this.fromApiJsonHelper
.extractBooleanNamed(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME, element);
if (allowPartialPeriodInterestCalcualtion == null) {
allowPartialPeriodInterestCalcualtion = loanProduct.getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalcualtion();
allowPartialPeriodInterestCalcualtion = loanProduct.getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalculation();
}
final BigDecimal interestRatePerPeriod = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRatePerPeriod", element);
@@ -1359,15 +1359,15 @@ public class LoanScheduleAssembler {
}
if (command.isChangeInBooleanParameterNamed(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME,
loanProductRelatedDetail.isAllowPartialPeriodInterestCalcualtion())) {
loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation())) {
final boolean newValue = command
.booleanPrimitiveValueOfParameterNamed(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME);
changes.put(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME, newValue);
loanProductRelatedDetail.setAllowPartialPeriodInterestCalcualtion(newValue);
loanProductRelatedDetail.setAllowPartialPeriodInterestCalculation(newValue);
}
if (loanProductRelatedDetail.getInterestCalculationPeriodMethod().isDaily()) {
loanProductRelatedDetail.setAllowPartialPeriodInterestCalcualtion(false);
loanProductRelatedDetail.setAllowPartialPeriodInterestCalculation(false);
}
final String graceOnPrincipalPaymentParamName = "graceOnPrincipalPayment";
@@ -1667,14 +1667,20 @@ public final class LoanApplicationValidator {
}
}
public void validateLoanMultiDisbursementDate(final JsonElement element, LocalDate expectedDisbursementDate, BigDecimal principal) {
public void validateLoanMultiDisbursementDate(final JsonElement element, LocalDate expectedDisbursementDate, BigDecimal principal,
Loan loan) {
Validator.validateOrThrow("loan", baseDataValidator -> {
validateLoanMultiDisbursementDate(element, baseDataValidator, expectedDisbursementDate, principal);
validateLoanMultiDisbursementDate(element, baseDataValidator, expectedDisbursementDate, principal, loan);
});
}
public void validateLoanMultiDisbursementDate(final JsonElement element, final DataValidatorBuilder baseDataValidator,
LocalDate expectedDisbursement, BigDecimal totalPrincipal) {
validateLoanMultiDisbursementDate(element, baseDataValidator, expectedDisbursement, totalPrincipal, null);
}
public void validateLoanMultiDisbursementDate(final JsonElement element, final DataValidatorBuilder baseDataValidator,
LocalDate expectedDisbursement, BigDecimal totalPrincipal, Loan loan) {
this.validateDisbursementsAreDatewiseOrdered(element, baseDataValidator);
final JsonObject topLevelJsonElement = element.getAsJsonObject();
@@ -1732,11 +1738,31 @@ public final class LoanApplicationValidator {
baseDataValidator.reset().parameter(LoanApiConstants.disbursementPrincipalParameterName)
.failWithCode(LoanApiConstants.APPROVED_AMOUNT_IS_LESS_THAN_SUM_OF_TRANCHES);
}
final Integer interestType = this.fromApiJsonHelper
.extractIntegerSansLocaleNamed(LoanApiConstants.interestTypeParameterName, element);
baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName).value(interestType).ignoreIfNull()
.integerSameAsNumber(InterestMethod.DECLINING_BALANCE.getValue());
if (loan == null) {
final String transactionProcessingStrategyCode = this.fromApiJsonHelper
.extractStringNamed(LoanApiConstants.transactionProcessingStrategyCodeParameterName, element);
if (transactionProcessingStrategyCode != null) {
final Integer interestType = this.fromApiJsonHelper.extractIntegerNamed(LoanApiConstants.interestTypeParameterName,
element, Locale.getDefault());
String processorCode = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(transactionProcessingStrategyCode).getCode();
boolean isProgressive = "advanced-payment-allocation-strategy".equals(processorCode);
if (isProgressive) {
baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName).value(interestType)
.ignoreIfNull().inMinMaxRange(0, 1);
} else {
baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName).value(interestType)
.ignoreIfNull().integerSameAsNumber(InterestMethod.DECLINING_BALANCE.getValue());
}
}
} else {
if (loan.isCumulativeSchedule()) {
baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName)
.value(loan.getLoanProductRelatedDetail().getInterestMethod()).ignoreIfNull()
.value(InterestMethod.DECLINING_BALANCE);
}
}
}
}
}
@@ -1756,7 +1782,7 @@ public final class LoanApplicationValidator {
final InterestCalculationPeriodMethod interestCalculationPeriodMethod = InterestCalculationPeriodMethod
.fromInt(interestCalculationPeriodType);
boolean considerPartialPeriodUpdates = interestCalculationPeriodMethod.isDaily() ? interestCalculationPeriodMethod.isDaily()
: loanProduct.getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalcualtion();
: loanProduct.getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalculation();
if (this.fromApiJsonHelper.parameterExists(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME,
element)) {
final Boolean considerPartialInterestEnabled = this.fromApiJsonHelper
@@ -1780,7 +1806,8 @@ public final class LoanApplicationValidator {
.failWithCode("not.supported.for.selected.interest.calcualtion.type");
}
if (loanProduct.isMultiDisburseLoan()) {
if (loanProduct.isMultiDisburseLoan()
&& !"advanced-payment-allocation-strategy".equals(loanProduct.getTransactionProcessingStrategyCode())) {
baseDataValidator.reset().parameter(LoanProductConstants.MULTI_DISBURSE_LOAN_PARAMETER_NAME)
.failWithCode("not.supported.for.selected.interest.calcualtion.type");
}
@@ -2045,7 +2072,7 @@ public final class LoanApplicationValidator {
LoanProduct loanProduct = loan.loanProduct();
if (loanProduct.isMultiDisburseLoan()) {
validateLoanMultiDisbursementDate(element, expectedDisbursementDate, principal);
validateLoanMultiDisbursementDate(element, expectedDisbursementDate, principal, loan);
final JsonArray disbursementDataArray = this.fromApiJsonHelper
.extractJsonArrayNamed(LoanApiConstants.disbursementDataParameterName, element);
@@ -201,15 +201,15 @@ public class LoanProductRelatedDetailUpdateUtil {
}
if (command.isChangeInBooleanParameterNamed(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME,
loanRepaymentScheduleDetail.isAllowPartialPeriodInterestCalcualtion())) {
loanRepaymentScheduleDetail.isAllowPartialPeriodInterestCalculation())) {
final boolean newValue = command
.booleanPrimitiveValueOfParameterNamed(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME);
actualChanges.put(LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME, newValue);
loanRepaymentScheduleDetail.setAllowPartialPeriodInterestCalcualtion(newValue);
loanRepaymentScheduleDetail.setAllowPartialPeriodInterestCalculation(newValue);
}
if (loanRepaymentScheduleDetail.getInterestCalculationPeriodMethod().isDaily()) {
loanRepaymentScheduleDetail.setAllowPartialPeriodInterestCalcualtion(false);
loanRepaymentScheduleDetail.setAllowPartialPeriodInterestCalculation(false);
}
final String graceOnPrincipalPaymentParamName = "graceOnPrincipalPayment";
@@ -2232,7 +2232,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
}
private void validateMultiDisbursementData(final JsonCommand command, LocalDate expectedDisbursementDate,
boolean isDisallowExpectedDisbursements) {
boolean isDisallowExpectedDisbursements, Loan loan) {
final String json = command.json();
final JsonElement element = this.fromApiJsonHelper.parse(json);
@@ -2254,7 +2254,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final BigDecimal principal = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("approvedLoanAmount", element);
loanApplicationValidator.validateLoanMultiDisbursementDate(element, baseDataValidator, expectedDisbursementDate, principal);
loanApplicationValidator.validateLoanMultiDisbursementDate(element, baseDataValidator, expectedDisbursementDate, principal, loan);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
@@ -2296,7 +2296,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final String errorMessage = "cannot.modify.tranches.if.loan.is.pendingapproval.closed.overpaid.writtenoff";
throw new LoanMultiDisbursementException(errorMessage);
}
validateMultiDisbursementData(command, expectedDisbursementDate, loan.loanProduct().isDisallowExpectedDisbursements());
validateMultiDisbursementData(command, expectedDisbursementDate, loan.loanProduct().isDisallowExpectedDisbursements(), loan);
this.validateForAddAndDeleteTranche(loan);
@@ -786,7 +786,7 @@ public final class LoanProductDataValidator {
}
}
validateMultiDisburseLoanData(baseDataValidator, element);
validateMultiDisburseLoanData(baseDataValidator, element, null);
validateLoanConfigurableAttributes(baseDataValidator, element);
@@ -1014,7 +1014,8 @@ public final class LoanProductDataValidator {
}
}
private void validateMultiDisburseLoanData(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
private void validateMultiDisburseLoanData(final DataValidatorBuilder baseDataValidator, final JsonElement element,
final LoanProduct loanProduct) {
Boolean multiDisburseLoan = false;
if (this.fromApiJsonHelper.parameterExists(LoanProductConstants.MULTI_DISBURSE_LOAN_PARAMETER_NAME, element)) {
multiDisburseLoan = this.fromApiJsonHelper.extractBooleanNamed(LoanProductConstants.MULTI_DISBURSE_LOAN_PARAMETER_NAME,
@@ -1045,14 +1046,34 @@ public final class LoanProductDataValidator {
.integerGreaterThanZero();
final Integer interestType = this.fromApiJsonHelper.extractIntegerNamed(INTEREST_TYPE, element, Locale.getDefault());
baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull()
.integerSameAsNumber(InterestMethod.DECLINING_BALANCE.getValue());
boolean isProgressive = isProgressive(element, loanProduct);
if (isProgressive) {
baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull().inMinMaxRange(0, 1);
} else {
baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull()
.integerSameAsNumber(InterestMethod.DECLINING_BALANCE.getValue());
}
}
final String overAppliedCalculationType = this.fromApiJsonHelper.extractStringNamed(OVER_APPLIED_CALCULATION_TYPE, element);
baseDataValidator.reset().parameter(OVER_APPLIED_CALCULATION_TYPE).value(overAppliedCalculationType).notExceedingLengthOf(10);
}
private boolean isProgressive(JsonElement element, LoanProduct loanProduct) {
String processorCode = null;
if (loanProduct != null) {
processorCode = loanProduct.getTransactionProcessingStrategyCode();
}
final String transactionProcessingStrategyCode = this.fromApiJsonHelper.extractStringNamed(TRANSACTION_PROCESSING_STRATEGY_CODE,
element);
if (transactionProcessingStrategyCode != null) {
processorCode = loanRepaymentScheduleTransactionProcessorFactory.determineProcessor(transactionProcessingStrategyCode)
.getCode();
}
return "advanced-payment-allocation-strategy".equals(processorCode);
}
private void validateInterestRecalculationParams(final JsonElement element, final DataValidatorBuilder baseDataValidator,
final LoanProduct loanProduct) {
@@ -1868,7 +1889,7 @@ public final class LoanProductDataValidator {
}
}
validateMultiDisburseLoanData(baseDataValidator, element);
validateMultiDisburseLoanData(baseDataValidator, element, loanProduct);
// validateLoanConfigurableAttributes(baseDataValidator,element);
@@ -2549,7 +2570,7 @@ public final class LoanProductDataValidator {
}
} else if (loanProduct != null) {
if (!interestCalculationPeriodMethod.isDaily()) {
considerPartialPeriodUpdates = loanProduct.getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalcualtion();
considerPartialPeriodUpdates = loanProduct.getLoanProductRelatedDetail().isAllowPartialPeriodInterestCalculation();
}
}
@@ -2574,7 +2595,7 @@ public final class LoanProductDataValidator {
} else if (loanProduct != null) {
multiDisburseLoan = loanProduct.isMultiDisburseLoan();
}
if (multiDisburseLoan != null && multiDisburseLoan) {
if (multiDisburseLoan != null && multiDisburseLoan && !isProgressive(element, loanProduct)) {
baseDataValidator.reset().parameter(LoanProductConstants.MULTI_DISBURSE_LOAN_PARAMETER_NAME)
.failWithCode("not.supported.for.selected.interest.calculation.type");
}