Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,10 @@ cyclonedxBom {
includeLicenseText = true
}

tasks.withType(org.cyclonedx.gradle.CyclonedxDirectTask).configureEach {
includeMetadataResolution = false
}

tasks.register('printSourceSetInformation') {
doLast {
sourceSets.each { srcSet ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public static ILoanConfigurationDetails map(Loan loan) {

return new LoanConfigurationDetails(currencyData, loanProductRelatedDetail.getNominalInterestRatePerPeriod(),
loanProductRelatedDetail.getAnnualNominalInterestRate(), loanProductRelatedDetail.getGraceOnInterestCharged(),
loanProductRelatedDetail.getGraceOnPrincipalPayment(), loanProductRelatedDetail.getGraceOnPrincipalPayment(),
loanProductRelatedDetail.getGraceOnInterestPayment(), loanProductRelatedDetail.getGraceOnPrincipalPayment(),
loanProductRelatedDetail.getRecurringMoratoriumOnPrincipalPeriods(), loanProductRelatedDetail.getInterestMethod(),
loanProductRelatedDetail.getInterestCalculationPeriodMethod(),
DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ private void calculateEMIValueAndRateFactorsForFlatInterestMethod(final LocalDat
return;
}
calculateEMIOnActualModelWithFlatInterestMethod(relatedRepaymentPeriods, scheduleModel);
applyPrincipalMoratoriumIfRequired(relatedRepaymentPeriods, scheduleModel);
}

/**
Expand Down Expand Up @@ -676,6 +677,7 @@ private void calculateEMIValueAndRateFactorsForDecliningBalanceInterestMethod(fi
} else {
calculateEMIOnNewModelAndMerge(relatedRepaymentPeriods, scheduleModel, operation);
}
applyPrincipalMoratoriumIfRequired(relatedRepaymentPeriods, scheduleModel);
calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel, calculateFromRepaymentPeriodDueDate);
if (onlyOnActualModelShouldApply) {
Expand Down Expand Up @@ -1143,6 +1145,10 @@ private void calculateOutstandingBalance(ProgressiveLoanInterestScheduleModel sc

private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel,
final List<RepaymentPeriod> relatedRepaymentPeriods) {
Integer graceOnInterestPayment = scheduleModel.loanProductRelatedDetail().getGraceOnInterestPayment();
if (graceOnInterestPayment != null && graceOnInterestPayment > 0) {
return;
}
MathContext mc = scheduleModel.mc();
ProgressiveLoanInterestScheduleModel newScheduleModel = null;
int adjustCounter = 1;
Expand Down Expand Up @@ -1566,11 +1572,34 @@ private void calculateEMIOnActualModel(List<RepaymentPeriod> repaymentPeriods, P
}
}

private void applyPrincipalMoratoriumIfRequired(List<RepaymentPeriod> repaymentPeriods,
ProgressiveLoanInterestScheduleModel scheduleModel) {
if (repaymentPeriods.isEmpty()) {
return;
}
Integer graceOnPrincipalPayment = scheduleModel.loanProductRelatedDetail().getGraceOnPrincipalPayment();
if (graceOnPrincipalPayment == null || graceOnPrincipalPayment <= 0) {
return;
}
int gracePeriods = Math.min(graceOnPrincipalPayment, repaymentPeriods.size());
List<RepaymentPeriod> gracePeriodsList = repaymentPeriods.subList(0, gracePeriods);
gracePeriodsList.forEach(period -> {
Money interestOnlyEmi = period.getDueInterest();
period.setEmi(interestOnlyEmi);
period.setOriginalEmi(interestOnlyEmi);
});
if (gracePeriods == repaymentPeriods.size()) {
return;
}
calculateOutstandingBalance(scheduleModel);
calculateEMIOnActualModel(repaymentPeriods.subList(gracePeriods, repaymentPeriods.size()), scheduleModel);
}

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));
final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorPlus1NForEmi(repaymentPeriods, scheduleModel, mc));
final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResultForEmi(repaymentPeriods, scheduleModel, mc));
final RepaymentPeriod startPeriod = repaymentPeriods.getFirst();

final Money outstandingBalance = startPeriod.getInitialBalanceForEmiRecalculation();
Expand Down Expand Up @@ -1679,6 +1708,42 @@ private BigDecimal calculateFnResult(final List<RepaymentPeriod> periods, final
.reduce(BigDecimal.ONE, (previousFnValue, currentRateFactor) -> fnValue(previousFnValue, currentRateFactor, mc));//
}

private BigDecimal calculateRateFactorPlus1NForEmi(final List<RepaymentPeriod> periods,
final ProgressiveLoanInterestScheduleModel scheduleModel, MathContext mc) {
return periods.stream().map(period -> getRateFactorPlus1ForEmi(period, scheduleModel)).reduce(BigDecimal.ONE,
(BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc));
}

private BigDecimal calculateFnResultForEmi(final List<RepaymentPeriod> periods,
final ProgressiveLoanInterestScheduleModel scheduleModel, final MathContext mc) {
return periods.stream()//
.skip(1)//
.map(period -> getRateFactorPlus1ForEmi(period, scheduleModel))//
.reduce(BigDecimal.ONE, (previousFnValue, currentRateFactor) -> fnValue(previousFnValue, currentRateFactor, mc));//
}

private BigDecimal getRateFactorPlus1ForEmi(final RepaymentPeriod period, final ProgressiveLoanInterestScheduleModel scheduleModel) {
return isInterestPaymentGracePeriod(period, scheduleModel) ? BigDecimal.ONE : period.getRateFactorPlus1();
}

private boolean isInterestPaymentGracePeriod(final RepaymentPeriod period, final ProgressiveLoanInterestScheduleModel scheduleModel) {
Integer interestGrace = scheduleModel.loanProductRelatedDetail().getGraceOnInterestPayment();
if (interestGrace == null || interestGrace <= 0) {
return false;
}
return getPeriodNumber(period) <= interestGrace;
}

private int getPeriodNumber(final RepaymentPeriod period) {
int periodNumber = 1;
Optional<RepaymentPeriod> current = period.getPrevious();
while (current.isPresent()) {
periodNumber++;
current = current.get().getPrevious();
}
return periodNumber;
}

/**
* Calculate the EMI (Equal Monthly Installment) value
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ public Money calculateCalculatedDueInterest() {
* @return
*/
public Money getDueInterest() {
if (isInterestPaymentGracePeriod()) {
return getPaidInterest();
}
if (dueInterestCalculation == null) {
// Due interest might be the maximum paid if there is pay-off or early repayment
dueInterestCalculation = Memo.of(
Expand All @@ -273,6 +276,24 @@ public Money getDueInterest() {
return dueInterestCalculation.get();
}

private boolean isInterestPaymentGracePeriod() {
Integer interestGrace = loanProductRelatedDetail.getGraceOnInterestPayment();
if (interestGrace == null || interestGrace <= 0) {
return false;
}
return getPeriodNumber() <= interestGrace;
}

private int getPeriodNumber() {
int periodNumber = 1;
RepaymentPeriod current = this;
while (current.previous != null) {
periodNumber++;
current = current.previous;
}
return periodNumber;
}

/**
* Gives back an EMI amount which includes credited amounts and future unrecognized interest as well
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ public void setupTestDefaults() {
Mockito.when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.DECLINING_BALANCE);
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod()).thenReturn(InterestCalculationPeriodMethod.DAILY);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
Mockito.when(loanProductRelatedDetail.getGraceOnPrincipalPayment()).thenReturn(0);
Mockito.when(loanProductRelatedDetail.getGraceOnInterestPayment()).thenReturn(0);
}

private BigDecimal getRateFactorsByMonth(final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType,
Expand Down Expand Up @@ -5223,6 +5225,70 @@ private ProgressiveLoanInterestScheduleModel copyJson(ProgressiveLoanInterestSch
toCopy.installmentAmountInMultiplesOf());
}

@Test
public void test_principalGraceForProgressiveSchedule() {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));

Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(BigDecimal.valueOf(7.0));
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(6);
Mockito.when(loanProductRelatedDetail.getGraceOnPrincipalPayment()).thenReturn(2);
Mockito.when(loanProductRelatedDetail.getGraceOnInterestPayment()).thenReturn(0);

final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator
.generatePeriodInterestScheduleModel(expectedRepaymentPeriods, loanProductRelatedDetail, null, mc);

emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), toMoney(100.0));

checkPeriod(interestSchedule, 0, 0.58, 0.58, 0.0, 100.0, false);
checkPeriod(interestSchedule, 1, 0.58, 0.58, 0.0, 100.0, false);
checkPeriod(interestSchedule, 2, 25.37, 0.58, 24.79, 75.21, false);
checkPeriod(interestSchedule, 3, 25.37, 0.44, 24.93, 50.28, false);
checkPeriod(interestSchedule, 4, 25.37, 0.29, 25.08, 25.20, false);
checkPeriod(interestSchedule, 5, 25.35, 0.15, 25.20, 0.0, false);
}

@Test
public void test_interestGraceForProgressiveSchedule() {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = new ArrayList<>();
expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));

Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(BigDecimal.valueOf(7.0));
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(6);
Mockito.when(loanProductRelatedDetail.getGraceOnPrincipalPayment()).thenReturn(0);
Mockito.when(loanProductRelatedDetail.getGraceOnInterestPayment()).thenReturn(2);

final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator
.generatePeriodInterestScheduleModel(expectedRepaymentPeriods, loanProductRelatedDetail, null, mc);

emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), toMoney(100.0));

checkPeriod(interestSchedule, 0, 16.83, 0.0, 16.83, 83.17, false);
checkPeriod(interestSchedule, 1, 16.83, 0.0, 16.83, 66.34, false);
checkPeriod(interestSchedule, 2, 16.83, 1.46, 15.37, 50.97, false);
checkPeriod(interestSchedule, 3, 16.83, 0.30, 16.53, 34.44, false);
checkPeriod(interestSchedule, 4, 16.83, 0.20, 16.63, 17.81, false);
checkPeriod(interestSchedule, 5, 17.91, 0.10, 17.81, 0.0, false);
}

@Test
public void test_fullTermTranche_disbursedAmt200_2ndOnDueDate_dayInYears360_daysInMonth30_repayEvery1Month() {
// Create 7 periods (6 original + 1 extension for second tranche)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* 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.integrationtests;

import java.math.BigDecimal;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.junit.jupiter.api.Test;

public class ProgressiveLoanMoratoriumIntegrationTest extends BaseLoanIntegrationTest {

@Test
public void testProgressivePrincipalMoratoriumSchedule() {
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());

runAt("1 January 2024", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper.createLoanProduct(create4IProgressive().principal(100.0)
.minPrincipal(100.0).maxPrincipal(100.0).numberOfRepayments(6).interestRatePerPeriod(7.0));

Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2024", 100.0, 7.0, 6,
request -> request.graceOnPrincipalPayment(2));

disburseLoan(loanId, BigDecimal.valueOf(100.0), "1 January 2024");

verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"),
installment(0.0, 0.58, 0.58, false, "01 February 2024"), installment(0.0, 0.58, 0.58, false, "01 March 2024"), //
installment(24.79, 0.58, 25.37, false, "01 April 2024"), //
installment(24.93, 0.44, 25.37, false, "01 May 2024"), //
installment(25.08, 0.29, 25.37, false, "01 June 2024"), //
installment(25.20, 0.15, 25.35, false, "01 July 2024"));
});
}

@Test
public void testProgressiveInterestMoratoriumSchedule() {
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());

runAt("1 January 2024", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper.createLoanProduct(create4IProgressive().principal(100.0)
.minPrincipal(100.0).maxPrincipal(100.0).numberOfRepayments(6).interestRatePerPeriod(7.0));

Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2024", 100.0, 7.0, 6,
request -> request.graceOnInterestPayment(2));

disburseLoan(loanId, BigDecimal.valueOf(100.0), "1 January 2024");

verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"),
installment(16.83, 0.0, 16.83, false, "01 February 2024"), installment(16.83, 0.0, 16.83, false, "01 March 2024"), //
installment(15.37, 1.46, 16.83, false, "01 April 2024"), //
installment(16.53, 0.30, 16.83, false, "01 May 2024"), //
installment(16.63, 0.20, 16.83, false, "01 June 2024"), //
installment(17.81, 0.10, 17.91, false, "01 July 2024"));
});
}
}