diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java index 411e0c71b0..8cd0d88789 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java @@ -274,6 +274,9 @@ public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency .principal(result.getOutstandingBalance()) // .interest(result.getPayableInterest()); + installments.stream().filter(installment -> installment.getDueDate().isBefore(onDate)) + .forEach(installment -> amounts.plusInterest(installment.getInterestOutstanding(currency))); + installments.forEach(installment -> amounts // .plusFeeCharges(installment.getFeeChargesOutstanding(currency)) .plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency))); diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PrepaymentCalculationTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PrepaymentCalculationTest.java new file mode 100644 index 0000000000..1fec1bb926 --- /dev/null +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PrepaymentCalculationTest.java @@ -0,0 +1,146 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.loanschedule.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.PayableDetails; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.RepaymentPeriod; +import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; +import org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +public class PrepaymentCalculationTest { + + private static final MockedStatic moneyHelper = Mockito.mockStatic(MoneyHelper.class); + private static final MathContext mc = new MathContext(12, RoundingMode.HALF_EVEN); + private static final MonetaryCurrency monetaryCurrency = MonetaryCurrency + .fromApplicationCurrency(new ApplicationCurrency("USD", "USD", 2, 1, "USD", "$")); + + @Mock + private LoanProductRelatedDetail loanProductRelatedDetail; + + @Mock + private LoanApplicationTerms loanApplicationTerms; + + @Mock + private AdvancedPaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor; + + @Mock + private EMICalculator emiCalculator; + + @InjectMocks + private ProgressiveLoanScheduleGenerator progressiveLoanScheduleGenerator; + + private Loan loan; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + moneyHelper.when(MoneyHelper::getMathContext).thenReturn(new MathContext(12, RoundingMode.UP)); + moneyHelper.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.UP); + + loan = Mockito.mock(Loan.class); + when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE); + when(loan.getDisbursementDate()).thenReturn(LocalDate.of(2022, 9, 7)); + when(loan.getRepaymentScheduleInstallments()).thenReturn(createRepaymentScheduleInstallments()); + + when(loanApplicationTerms.getPreClosureInterestCalculationStrategy()) + .thenReturn(LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE); + + when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(BigDecimal.valueOf(7.0)); + + List repaymentPeriods = createMockRepaymentPeriods(); + ProgressiveLoanInterestScheduleModel scheduleModel = new ProgressiveLoanInterestScheduleModel(repaymentPeriods, + loanProductRelatedDetail, 100, mc); + + when(loanRepaymentScheduleTransactionProcessor.reprocessProgressiveLoanTransactions(Mockito.any(), Mockito.anyList(), Mockito.any(), + Mockito.anyList(), Mockito.anySet())).thenReturn(org.apache.commons.lang3.tuple.Pair.of(null, scheduleModel)); + + PayableDetails payableDetails = new PayableDetails(Money.of(monetaryCurrency, BigDecimal.valueOf(200)), + Money.of(monetaryCurrency, BigDecimal.valueOf(500)), Money.of(monetaryCurrency, BigDecimal.valueOf(0)), + Money.of(monetaryCurrency, BigDecimal.valueOf(1000))); + + when(emiCalculator.getPayableDetails(Mockito.any(ProgressiveLoanInterestScheduleModel.class), Mockito.any(LocalDate.class), + Mockito.any(LocalDate.class))).thenReturn(payableDetails); + } + + @AfterAll + public static void tearDown() { + moneyHelper.close(); + } + + @Test + public void testCalculatePrepaymentAmount() { + LocalDate prepaymentDate = LocalDate.of(2023, 6, 1); + + OutstandingAmountsDTO result = progressiveLoanScheduleGenerator.calculatePrepaymentAmount(monetaryCurrency, prepaymentDate, + loanApplicationTerms, mc, loan, null, loanRepaymentScheduleTransactionProcessor); + + assertEquals("1000.00", result.principal().getAmount().toString()); + assertEquals("15.00", result.interest().getAmount().toString()); + } + + private List createRepaymentScheduleInstallments() { + LoanRepaymentScheduleInstallment installment1 = new LoanRepaymentScheduleInstallment(loan, 1, LocalDate.of(2022, 10, 1), + LocalDate.of(2022, 11, 1), BigDecimal.valueOf(500), BigDecimal.valueOf(10), BigDecimal.ZERO, BigDecimal.ZERO, false, null); + + LoanRepaymentScheduleInstallment installment2 = new LoanRepaymentScheduleInstallment(loan, 2, LocalDate.of(2022, 11, 2), + LocalDate.of(2022, 12, 1), BigDecimal.valueOf(500), BigDecimal.valueOf(5), BigDecimal.ZERO, BigDecimal.ZERO, false, null); + + return List.of(installment1, installment2); + } + + private List createMockRepaymentPeriods() { + RepaymentPeriod period1 = Mockito.mock(RepaymentPeriod.class); + when(period1.getFromDate()).thenReturn(LocalDate.of(2022, 10, 1)); + when(period1.getDueDate()).thenReturn(LocalDate.of(2022, 11, 1)); + + RepaymentPeriod period2 = Mockito.mock(RepaymentPeriod.class); + when(period2.getFromDate()).thenReturn(LocalDate.of(2022, 11, 2)); + when(period2.getDueDate()).thenReturn(LocalDate.of(2022, 12, 1)); + + return List.of(period1, period2); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java index df133d6884..a7490ce99f 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java @@ -3118,6 +3118,94 @@ public void testRBIPaymentStrategy() { } + @Test + public void testLoanPrePaymentWithMultiplePayments() { + final Integer clientID = ClientHelper.createClient(REQUEST_SPEC, RESPONSE_SPEC); + ClientHelper.verifyClientCreatedOnServer(REQUEST_SPEC, RESPONSE_SPEC, clientID); + + // Create a loan product + Integer loanProductId = createLoanProduct(false, NONE); + Integer collateralId = CollateralManagementHelper.createCollateralProduct(REQUEST_SPEC, RESPONSE_SPEC); + Integer clientCollateralId = CollateralManagementHelper.createClientCollateral(REQUEST_SPEC, RESPONSE_SPEC, + String.valueOf(clientID), collateralId); + List collaterals = List.of(collaterals(clientCollateralId, BigDecimal.ONE)); + + // Apply for a loan + final String disbursementDate = "1 May 2023"; + final String approvalDate = "1 April 2023"; + final String submissionDate = "1 March 2023"; + final String interestRate = "7"; + final Integer loanID = applyForLoanApplication(clientID, loanProductId, disbursementDate, submissionDate, interestRate, null, null, + "1000", collaterals); + Assertions.assertNotNull(loanID); + + // Check loan status + HashMap loanStatusHashMap = LoanStatusChecker.getStatusOfLoan(REQUEST_SPEC, RESPONSE_SPEC, loanID); + LoanStatusChecker.verifyLoanIsPending(loanStatusHashMap); + + // Approve the loan + LOG.info("-----------------------------------APPROVE LOAN-----------------------------------------"); + loanStatusHashMap = LOAN_TRANSACTION_HELPER.approveLoan(approvalDate, loanID); + LoanStatusChecker.verifyLoanIsApproved(loanStatusHashMap); + LoanStatusChecker.verifyLoanIsWaitingForDisbursal(loanStatusHashMap); + + // Disburse the loan + LOG.info("-------------------------------DISBURSE LOAN-------------------------------------------"); + String loanDetails = LOAN_TRANSACTION_HELPER.getLoanDetails(REQUEST_SPEC, RESPONSE_SPEC, loanID); + loanStatusHashMap = LOAN_TRANSACTION_HELPER.disburseLoanWithNetDisbursalAmount(disbursementDate, loanID, + JsonPath.from(loanDetails).get("netDisbursalAmount").toString()); + LoanStatusChecker.verifyLoanIsActive(loanStatusHashMap); + + // Make the first partial repayment + LOG.info("------------------------MAKE FIRST PARTIAL REPAYMENT-----------------------------------"); + Float firstRepaymentAmount = 500.0f; // First partial repayment + String firstRepaymentDate = "1 June 2023"; + LOAN_TRANSACTION_HELPER.makeRepayment(firstRepaymentDate, firstRepaymentAmount, loanID); + + // Verify the prepayment amount after the first partial repayment + LOG.info("------------------------GET PREPAYMENT AMOUNT AFTER FIRST PAYMENT-----------------------"); + HashMap prepayAmount = loanTransactionHelper.getPrepayAmount(REQUEST_SPEC, RESPONSE_SPEC, loanID); + Assertions.assertNotNull(prepayAmount); + + // Extract the principal and interest portions + Float totalPrepayAmount = (Float) prepayAmount.get("amount"); + Float principalAmount = (Float) prepayAmount.get("principalPortion"); + Float interestAmount = (Float) prepayAmount.get("interestPortion"); + + // Expected values after the first partial repayment + Float expectedTotalPrepayAmount = 606.18f; + Float expectedPrincipal = 570.0f; + Float expectedInterest = 36.18f; + + // Validate calculations + validateNumberForEqual(String.valueOf(expectedTotalPrepayAmount), String.valueOf(totalPrepayAmount)); + validateNumberForEqual(String.valueOf(expectedPrincipal), String.valueOf(principalAmount)); + validateNumberForEqual(String.valueOf(expectedInterest), String.valueOf(interestAmount)); + + // Make the second partial repayment + LOG.info("------------------------MAKE SECOND PARTIAL REPAYMENT----------------------------------"); + Float secondRepaymentAmount = 606.18f; + String secondRepaymentDate = "1 July 2023"; + LOAN_TRANSACTION_HELPER.makeRepayment(secondRepaymentDate, secondRepaymentAmount, loanID); + + // Recheck the prepayment amount + LOG.info("------------------------RECHECK PREPAYMENT AMOUNT AFTER FULL REPAYMENT------------------"); + HashMap postPrepayAmount = loanTransactionHelper.getPrepayAmount(REQUEST_SPEC, RESPONSE_SPEC, loanID); + Assertions.assertNotNull(postPrepayAmount); + + // Verify that the principal and interest portions are zero + Float postPrincipalAmount = (Float) postPrepayAmount.get("principalPortion"); + Float postInterestAmount = (Float) postPrepayAmount.get("interestPortion"); + + validateNumberForEqual("0.0", String.valueOf(postPrincipalAmount)); + validateNumberForEqual("0.0", String.valueOf(postInterestAmount)); + + // Check the loan status after repayment + LOG.info("------------------------CHECK LOAN STATUS---------------------------------------------"); + loanStatusHashMap = LoanStatusChecker.getStatusOfLoan(REQUEST_SPEC, RESPONSE_SPEC, loanID); + LoanStatusChecker.verifyLoanAccountIsClosed(loanStatusHashMap); + } + @Test public void testLoanScheduleWithInterestRecalculation_WITH_REST_SAME_AS_REPAYMENT_INTEREST_COMPOUND_NONE_STRATEGY_REDUCE_EMI() { @@ -7217,6 +7305,19 @@ private Integer applyForLoanApplication(final Integer clientID, final Integer lo return LOAN_TRANSACTION_HELPER.getLoanId(loanApplicationJSON); } + private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID, String disbursementDate, + String submissionDate, String interestRate, List charges, final String savingsId, String principal, + List collaterals) { + LOG.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------"); + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(principal).withLoanTermFrequency("2") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("2").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod(interestRate).withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withInterestCalculationPeriodTypeSameAsRepaymentPeriod() + .withExpectedDisbursementDate(disbursementDate).withSubmittedOnDate(submissionDate).withCollaterals(collaterals) + .withCharges(charges).build(clientID.toString(), loanProductID.toString(), savingsId); + return LOAN_TRANSACTION_HELPER.getLoanId(loanApplicationJSON); + } + private Integer applyForLoanApplicationWithExternalId(RequestSpecification requestSpecification, ResponseSpecification responseSpecification, final Integer clientID, final Integer loanProductID, String principal, final String externalId) {