diff --git a/build.gradle b/build.gradle index 56ed56607da..e127a8167f6 100644 --- a/build.gradle +++ b/build.gradle @@ -903,6 +903,10 @@ cyclonedxBom { includeLicenseText = true } +tasks.withType(org.cyclonedx.gradle.CyclonedxDirectTask).configureEach { + includeMetadataResolution = false +} + tasks.register('printSourceSetInformation') { doLast { sourceSets.each { srcSet -> diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java index 487c7069ed1..a341a8a99e4 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java @@ -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()), diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index a39748da338..ee31eb433fd 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -645,6 +645,7 @@ private void calculateEMIValueAndRateFactorsForFlatInterestMethod(final LocalDat return; } calculateEMIOnActualModelWithFlatInterestMethod(relatedRepaymentPeriods, scheduleModel); + applyPrincipalMoratoriumIfRequired(relatedRepaymentPeriods, scheduleModel); } /** @@ -676,6 +677,7 @@ private void calculateEMIValueAndRateFactorsForDecliningBalanceInterestMethod(fi } else { calculateEMIOnNewModelAndMerge(relatedRepaymentPeriods, scheduleModel, operation); } + applyPrincipalMoratoriumIfRequired(relatedRepaymentPeriods, scheduleModel); calculateOutstandingBalance(scheduleModel); calculateLastUnpaidRepaymentPeriodEMI(scheduleModel, calculateFromRepaymentPeriodDueDate); if (onlyOnActualModelShouldApply) { @@ -1143,6 +1145,10 @@ private void calculateOutstandingBalance(ProgressiveLoanInterestScheduleModel sc private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel, final List relatedRepaymentPeriods) { + Integer graceOnInterestPayment = scheduleModel.loanProductRelatedDetail().getGraceOnInterestPayment(); + if (graceOnInterestPayment != null && graceOnInterestPayment > 0) { + return; + } MathContext mc = scheduleModel.mc(); ProgressiveLoanInterestScheduleModel newScheduleModel = null; int adjustCounter = 1; @@ -1566,11 +1572,34 @@ private void calculateEMIOnActualModel(List repaymentPeriods, P } } + private void applyPrincipalMoratoriumIfRequired(List 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 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 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(); @@ -1679,6 +1708,42 @@ private BigDecimal calculateFnResult(final List periods, final .reduce(BigDecimal.ONE, (previousFnValue, currentRateFactor) -> fnValue(previousFnValue, currentRateFactor, mc));// } + private BigDecimal calculateRateFactorPlus1NForEmi(final List 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 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 current = period.getPrevious(); + while (current.isPresent()) { + periodNumber++; + current = current.get().getPrevious(); + } + return periodNumber; + } + /** * Calculate the EMI (Equal Monthly Installment) value */ diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java index 95011fb9558..92cd9765629 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java @@ -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( @@ -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 * diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java index cc2c50ee35e..77b84037bbb 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java @@ -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, @@ -5223,6 +5225,70 @@ private ProgressiveLoanInterestScheduleModel copyJson(ProgressiveLoanInterestSch toCopy.installmentAmountInMultiplesOf()); } + @Test + public void test_principalGraceForProgressiveSchedule() { + final List 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 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) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanMoratoriumIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanMoratoriumIntegrationTest.java new file mode 100644 index 00000000000..a9125650515 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanMoratoriumIntegrationTest.java @@ -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")); + }); + } +}