diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/OutstandingAmountsDTO.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/OutstandingAmountsDTO.java index aa1351880b3..265cc6a34fd 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/OutstandingAmountsDTO.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/OutstandingAmountsDTO.java @@ -46,20 +46,24 @@ public Money getTotalOutstanding() { .plus(penaltyCharges()); } - public void plusPrincipal(Money principal) { + public OutstandingAmountsDTO plusPrincipal(Money principal) { this.principal = this.principal.plus(principal); + return this; } - public void plusInterest(Money interest) { + public OutstandingAmountsDTO plusInterest(Money interest) { this.interest = this.interest.plus(interest); + return this; } - public void plusFeeCharges(Money feeCharges) { + public OutstandingAmountsDTO plusFeeCharges(Money feeCharges) { this.feeCharges = this.feeCharges.plus(feeCharges); + return this; } - public void plusPenaltyCharges(Money penaltyCharges) { + public OutstandingAmountsDTO plusPenaltyCharges(Money penaltyCharges) { this.penaltyCharges = this.penaltyCharges.plus(penaltyCharges); + return this; } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java index 34d8baf6b5c..85091c5ff4d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.loanaccount.domain; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY; + import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -488,6 +490,10 @@ public void resetChargesCharged() { this.penaltyCharges = null; } + public boolean isCurrentInstallment(LocalDate transactionDate) { + return getFromDate().isBefore(transactionDate) && !getDueDate().isBefore(transactionDate); + } + public interface PaymentFunction { Money accept(LocalDate transactionDate, Money transactionAmountRemaining); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index a9b5773b94e..3e85764dcde 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -55,6 +55,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.tuple.Pair; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; @@ -77,6 +78,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestRepaymentModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; @@ -143,12 +145,13 @@ public Money handleRepaymentSchedule(List transactionsPostDisbu throw new NotImplementedException(); } - @Override - public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List loanTransactions, - MonetaryCurrency currency, List installments, Set charges) { + // only for progressive loans + public Pair reprocessProgressiveLoanTransactions( + LocalDate disbursementDate, List loanTransactions, MonetaryCurrency currency, + List installments, Set charges) { final ChangedTransactionDetail changedTransactionDetail = new ChangedTransactionDetail(); if (loanTransactions.isEmpty()) { - return changedTransactionDetail; + return Pair.of(changedTransactionDetail, null); } if (charges != null) { for (final LoanCharge loanCharge : charges) { @@ -185,10 +188,18 @@ public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursement chargeOrTransaction.getLoanCharge() .ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate)); } - List txs = chargeOrTransactions.stream().map(ChargeOrTransaction::getLoanTransaction).filter(Optional::isPresent) + List txs = chargeOrTransactions.stream() // + .map(ChargeOrTransaction::getLoanTransaction) // + .filter(Optional::isPresent) // .map(Optional::get).toList(); reprocessInstallments(disbursementDate, txs, installments, currency); - return changedTransactionDetail; + return Pair.of(changedTransactionDetail, scheduleModel); + } + + @Override + public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List loanTransactions, + MonetaryCurrency currency, List installments, Set charges) { + return reprocessProgressiveLoanTransactions(disbursementDate, loanTransactions, currency, installments, charges).getLeft(); } @Override @@ -281,7 +292,7 @@ protected LoanTransaction findOriginalTransaction(LoanTransaction loanTransactio } return originalTransaction.get(); } else { // when there is no id, then it might be that the original transaction is changed, so we need to look - // it up from the Ctx. + // it up from the Ctx. Long originalChargebackTransactionId = ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction); Collection updatedTransactions = ctx.getChangedTransactionDetail().getNewTransactionMappings().values(); Optional updatedTransaction = updatedTransactions.stream().filter(tr -> tr.getLoanTransactionRelations() @@ -743,7 +754,7 @@ private void allocateOverpayment(LoanTransaction loanTransaction, TransactionCtx Balances balances = new Balances(zero, zero, zero, zero); if (LoanScheduleProcessingType.HORIZONTAL .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(), + transactionAmountUnprocessed = processPeriodsHorizontally(transactionCtx, loanTransaction, transactionCtx.getCurrency(), transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(), defaultPaymentAllocationRule, transactionMappings, Set.of(), balances); } else if (LoanScheduleProcessingType.VERTICAL @@ -982,6 +993,8 @@ private Money refundTransactionHorizontally(LoanTransaction loanTransaction, Mon break outerLoop; } } + default -> { + } } } } while (installments.stream().anyMatch(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) @@ -1109,7 +1122,7 @@ private void processTransaction(LoanTransaction loanTransaction, TransactionCtx if (LoanScheduleProcessingType.HORIZONTAL .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(), + transactionAmountUnprocessed = processPeriodsHorizontally(transactionCtx, loanTransaction, transactionCtx.getCurrency(), transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings, transactionCtx.getCharges(), balances); } else if (LoanScheduleProcessingType.VERTICAL @@ -1125,7 +1138,7 @@ private void processTransaction(LoanTransaction loanTransaction, TransactionCtx handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx.getOverpaymentHolder()); } - private Money processPeriodsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency, + private Money processPeriodsHorizontally(TransactionCtx transactionCtx, LoanTransaction loanTransaction, MonetaryCurrency currency, List installments, Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule, List transactionMappings, Set charges, Balances balances) { @@ -1134,17 +1147,21 @@ private Money processPeriodsHorizontally(LoanTransaction loanTransaction, Moneta mapping(Function.identity(), toList()))); for (Map.Entry> paymentAllocationsEntry : paymentAllocationsMap.entrySet()) { - transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, currency, installments, + transactionAmountUnprocessed = processAllocationsHorizontally(transactionCtx, loanTransaction, currency, installments, transactionAmountUnprocessed, paymentAllocationsEntry.getValue(), paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings, charges, balances); } return transactionAmountUnprocessed; } - private Money processAllocationsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency, + private Money processAllocationsHorizontally(TransactionCtx transactionCtx, LoanTransaction loanTransaction, MonetaryCurrency currency, List installments, Money transactionAmountUnprocessed, List paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule, List transactionMappings, Set charges, Balances balances) { + if (transactionAmountUnprocessed.isZero()) { + return transactionAmountUnprocessed; + } + Money paidPortion; boolean exit = false; do { @@ -1214,15 +1231,55 @@ private Money processAllocationsHorizontally(LoanTransaction loanTransaction, Mo for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment, firstNormalInstallmentNumber); - // Adjust the portion for the last installment - if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { - evenPortion = evenPortion.add(balanceAdjustment); - } + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( transactionMappings, loanTransaction, inAdvanceInstallment, currency); - paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, - evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances, - LoanRepaymentScheduleInstallment.PaymentAction.PAY); + + Loan loan = loanTransaction.getLoan(); + if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing() + && loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) { + ProgressiveLoanInterestScheduleModel model = ctx.getModel(); + LocalDate transactionDate = loanTransaction.getTransactionDate(); + LocalDate payDate = inAdvanceInstallment.getFromDate().isAfter(transactionDate) + ? inAdvanceInstallment.getFromDate() + : transactionDate; + ProgressiveLoanInterestRepaymentModel payableDetails = emiCalculator + .getPayableDetails(model, inAdvanceInstallment.getDueDate(), payDate).orElseThrow(); + + switch (paymentAllocationType) { + case IN_ADVANCE_INTEREST -> + inAdvanceInstallment.updateInterestCharged(payableDetails.getInterestDue().getAmount()); + case IN_ADVANCE_PRINCIPAL -> + inAdvanceInstallment.updatePrincipal(payableDetails.getPrincipalDue().getAmount()); + default -> { + } + } + + paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, + transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, + inAdvanceInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); + + switch (paymentAllocationType) { + case IN_ADVANCE_PRINCIPAL -> { + emiCalculator.addBalanceCorrection(model, payDate, + payableDetails.getOutstandingBalance().multipliedBy(-1)); + emiCalculator.addBalanceCorrection(model, payDate, + payableDetails.getPrincipalDue().minus(paidPortion)); + } + case IN_ADVANCE_INTEREST -> emiCalculator.addBalanceCorrection(model, payDate, + payableDetails.getInterestDue().minus(paidPortion)); + default -> { + } + } + } else { + // Adjust the portion for the last installment + if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { + evenPortion = evenPortion.add(balanceAdjustment); + } + paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, + evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances, + LoanRepaymentScheduleInstallment.PaymentAction.PAY); + } transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); } } else { 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 5fd7d21c547..b1250fd457d 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 @@ -18,11 +18,10 @@ */ package org.apache.fineract.portfolio.loanaccount.loanschedule.domain; -import static java.time.temporal.ChronoUnit.DAYS; +import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE; import java.math.BigDecimal; import java.math.MathContext; -import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; @@ -30,14 +29,12 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.MathUtil; 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.DisbursementData; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; @@ -46,6 +43,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams; @@ -247,77 +245,38 @@ public LoanScheduleDTO rescheduleNextInstallments(MathContext mc, LoanApplicatio public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) { - return switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) { - case TILL_PRE_CLOSURE_DATE -> { - log.debug("calculating prepayment amount till pre closure date (Strategy A)"); - OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency); - AtomicBoolean firstAfterPayoff = new AtomicBoolean(true); - loan.getRepaymentScheduleInstallments().forEach(installment -> { - boolean isInstallmentAfterPayoff = installment.getDueDate().isAfter(onDate); - - outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency)); - if (isInstallmentAfterPayoff) { - if (firstAfterPayoff.getAndSet(false)) { - outstandingAmounts.plusInterest(calculatePayableInterest(loan, installment, onDate)); - } else { - log.debug("Installment {} - {} is after payoff, not counting interest", installment.getFromDate(), - installment.getDueDate()); - } - } else { - log.debug("adding interest for {} - {}: {}", installment.getFromDate(), installment.getDueDate(), - installment.getInterestOutstanding(currency)); - outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency)); - } - outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency)); - outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency)); - }); - yield outstandingAmounts; - } - - case TILL_REST_FREQUENCY_DATE -> { - log.debug("calculating prepayment amount till rest frequency date (Strategy B)"); - OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency); - loan.getRepaymentScheduleInstallments().forEach(installment -> { - boolean isPayoffBeforeInstallment = installment.getFromDate().isBefore(onDate); - - outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency)); - if (isPayoffBeforeInstallment) { - outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency)); - } else { - log.debug("Payoff after installment {}, not counting interest", installment.getDueDate()); - } - outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency)); - outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency)); - }); - - yield outstandingAmounts; - } - case NONE -> throw new UnsupportedOperationException("Pre-closure interest calculation strategy not supported"); + List installments = loan.getRepaymentScheduleInstallments(); + + LocalDate transactionDate = switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) { + case TILL_PRE_CLOSURE_DATE -> onDate; + case TILL_REST_FREQUENCY_DATE -> // find due date of current installment + installments.stream().filter(it -> it.getFromDate().isBefore(onDate) && it.getDueDate().isAfter(onDate)).findFirst() + .orElseThrow(() -> new IllegalStateException("No installment found for transaction date: " + onDate)).getDueDate(); + case NONE -> throw new IllegalStateException("Unexpected PreClosureInterestCalculationStrategy: NONE"); }; - } - private Money calculatePayableInterest(Loan loan, LoanRepaymentScheduleInstallment installment, LocalDate onDate) { - RoundingMode roundingMode = MoneyHelper.getRoundingMode(); - MonetaryCurrency currency = loan.getCurrency(); - Money originalInterest = installment.getInterestCharged(currency); - log.debug("calculating interest for {} from {} to {}", originalInterest, installment.getFromDate(), installment.getDueDate()); - - LocalDate start = installment.getFromDate(); - Money payableInterest = Money.zero(currency); - - while (!start.isEqual(onDate)) { - long between = DAYS.between(start, installment.getDueDate()); - Money dailyInterest = originalInterest.minus(payableInterest).dividedBy(between, roundingMode); - log.debug("Daily interest is {}: {} / {}, total: {}", dailyInterest, originalInterest.minus(payableInterest), between, - payableInterest.add(dailyInterest)); - payableInterest = payableInterest.add(dailyInterest); - start = start.plusDays(1); + if (!(loanRepaymentScheduleTransactionProcessor instanceof AdvancedPaymentScheduleTransactionProcessor processor)) { + throw new IllegalStateException("Expected an AdvancedPaymentScheduleTransactionProcessor"); } + ProgressiveLoanInterestScheduleModel model = processor.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(), + loan.retrieveListOfTransactionsForReprocessing(), currency, installments, loan.getActiveCharges()).getRight(); + + LoanRepaymentScheduleInstallment actualInstallment = installments.stream() + .filter(it -> transactionDate.isAfter(it.getFromDate()) && !transactionDate.isAfter(it.getDueDate())).findFirst() + .orElse(installments.get(0)); + + ProgressiveLoanInterestRepaymentModel result = emiCalculator + .getPayableDetails(model, actualInstallment.getDueDate(), transactionDate).orElseThrow(); + + OutstandingAmountsDTO amounts = new OutstandingAmountsDTO(currency) // + .principal(result.getOutstandingBalance()) // + .interest(result.getInterestDue()); - payableInterest = payableInterest.minus(installment.getInterestPaid(currency).minus(installment.getInterestWaived(currency))); + installments.forEach(installment -> amounts // + .plusFeeCharges(installment.getFeeChargesOutstanding(currency)) + .plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency))); - log.debug("Payable interest is {}", payableInterest); - return payableInterest; + return amounts; } // Private, internal methods diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGeneratorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGeneratorTest.java deleted file mode 100644 index 214bff051c9..00000000000 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGeneratorTest.java +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.loanaccount.loanschedule.domain; - -import static java.math.BigDecimal.ZERO; -import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE; -import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_REST_FREQUENCY_DATE; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import java.math.BigDecimal; -import java.math.MathContext; -import java.math.RoundingMode; -import java.time.LocalDate; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -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.HolidayDetailDTO; -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.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.springframework.test.util.ReflectionTestUtils; - -class ProgressiveLoanScheduleGeneratorTest { - - static class TestRow { - - LocalDate fromDate; - LocalDate dueDate; - BigDecimal balance; - BigDecimal principal; - BigDecimal interest; - BigDecimal fee; - BigDecimal penalty; - boolean paid; - - TestRow(LocalDate fromDate, LocalDate dueDate, BigDecimal balance, BigDecimal principal, BigDecimal interest, BigDecimal fee, - BigDecimal penalty, boolean paid) { - this.fromDate = fromDate; - this.dueDate = dueDate; - this.balance = balance; - this.principal = principal; - this.interest = interest; - this.fee = fee; - this.penalty = penalty; - this.paid = paid; - } - } - - private ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(null, null); - private MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); - private HolidayDetailDTO holidays = new HolidayDetailDTO(false, null, null); - LoanRepaymentScheduleTransactionProcessor processor = mock(LoanRepaymentScheduleTransactionProcessor.class); - - static { - ConfigurationDomainService domainService = mock(ConfigurationDomainService.class); - when(domainService.getRoundingMode()).thenReturn(RoundingMode.HALF_UP.ordinal()); - ReflectionTestUtils.setField(MoneyHelper.class, "staticConfigurationDomainService", domainService); - } - - @BeforeAll - public static void beforeAll() { - ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).setLevel(ch.qos.logback.classic.Level.DEBUG); - } - - @AfterAll - public static void afterAll() { - ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).setLevel(Level.INFO); - } - - public List testRows() { - return List.of( - new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(83.57), BigDecimal.valueOf(16.43), - BigDecimal.valueOf(0.58), ZERO, ZERO, true), - new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(67.05), BigDecimal.valueOf(16.52), - BigDecimal.valueOf(0.49), ZERO, ZERO, false), - new TestRow(LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1), BigDecimal.valueOf(50.43), BigDecimal.valueOf(16.62), - BigDecimal.valueOf(0.39), ZERO, ZERO, false), - new TestRow(LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1), BigDecimal.valueOf(33.71), BigDecimal.valueOf(16.72), - BigDecimal.valueOf(0.29), ZERO, ZERO, false), - new TestRow(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1), BigDecimal.valueOf(16.90), BigDecimal.valueOf(16.81), - BigDecimal.valueOf(0.20), ZERO, ZERO, false), - new TestRow(LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1), BigDecimal.valueOf(00.90), BigDecimal.valueOf(16.90), - BigDecimal.valueOf(0.10), ZERO, ZERO, false)); - } - - @Test - public void calculatePrepaymentAmount_TILL_PRE_CLOSURE_DATE() { - LoanApplicationTerms terms = mock(LoanApplicationTerms.class); - when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_PRE_CLOSURE_DATE); - Loan loan = prepareLoanWithInstallments(testRows()); - - OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 2, 15), terms, MathContext.DECIMAL32, - loan, holidays, processor); - assertEquals(BigDecimal.valueOf(83.84), amounts.getTotalOutstanding().getAmount()); - } - - @Test - public void calculatePrepaymentAmount_TILL_REST_FREQUENCY_DATE() { - LoanApplicationTerms terms = mock(LoanApplicationTerms.class); - when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_REST_FREQUENCY_DATE); - Loan loan = prepareLoanWithInstallments(testRows()); - - OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 2, 15), terms, MathContext.DECIMAL32, - loan, holidays, processor); - assertEquals(BigDecimal.valueOf(84.06), amounts.getTotalOutstanding().getAmount()); - } - - @Test - public void calculateSameDayPayoff_TILL_PRE_CLOSURE_DATE() { - LoanApplicationTerms terms = mock(LoanApplicationTerms.class); - when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_PRE_CLOSURE_DATE); - - Loan loan = prepareLoanWithInstallments(List.of( - new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100), - BigDecimal.valueOf(2), ZERO, ZERO, false), - new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100), - BigDecimal.valueOf(2), ZERO, ZERO, false))); - - OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 1, 1), terms, MathContext.DECIMAL32, - loan, holidays, processor); - assertEquals(BigDecimal.valueOf(200.0).longValue(), amounts.getTotalOutstanding().getAmount().longValue()); - } - - @Test - public void calculateSameDayPayoff_TILL_REST_FREQUENCY_DATE() { - LoanApplicationTerms terms = mock(LoanApplicationTerms.class); - when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_REST_FREQUENCY_DATE); - - Loan loan = prepareLoanWithInstallments(List.of( - new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100), - BigDecimal.valueOf(2), ZERO, ZERO, false), - new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100), - BigDecimal.valueOf(2), ZERO, ZERO, false))); - - OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 1, 1), terms, MathContext.DECIMAL32, - loan, holidays, processor); - assertEquals(BigDecimal.valueOf(200.0).longValue(), amounts.getTotalOutstanding().getAmount().longValue()); - } - - @NotNull - private Loan prepareLoanWithInstallments(List rows) { - Loan loan = mock(Loan.class); - List installments = createInstallments(rows, loan, usd); - when(loan.getRepaymentScheduleInstallments()).thenReturn(installments); - when(loan.getCurrency()).thenReturn(usd); - return loan; - } - - private List createInstallments(List rows, Loan loan, MonetaryCurrency usd) { - AtomicInteger count = new AtomicInteger(1); - return rows.stream().map(row -> { - LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, count.incrementAndGet(), row.fromDate, - row.dueDate, row.principal, row.interest, row.fee, row.penalty, true, null, null, row.paid); - if (row.paid) { - installment.payPrincipalComponent(row.fromDate, Money.of(usd, row.principal)); - installment.payInterestComponent(row.fromDate, Money.of(usd, row.interest)); - installment.updateObligationMet(true); - } - return installment; - }).toList(); - } -} 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 ec277fa4a67..c5cb57c9dac 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 @@ -538,86 +538,6 @@ public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repay checkPeriod(interestSchedule, 5, 0, 15.77, 0.005833333333, 0, 15.77, 0.0); } - @Test - public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month_payoff_on0215() { - final MathContext mc = MoneyHelper.getMathContext(); - 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))); - - final BigDecimal interestRate = new BigDecimal("7"); - final Integer installmentAmountInMultiplesOf = null; - - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); - - threadLocalContextUtil.when(ThreadLocalContextUtil::getBusinessDate).thenReturn(LocalDate.of(2024, 2, 15)); - - final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, - loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); - - final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); - emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); - - // partially pay off a period with balance correction - final LocalDate op1stCorrectionPeriodDueDate = LocalDate.of(2024, 3, 1); - final LocalDate op1stCorrectionDate = LocalDate.of(2024, 2, 15); - final Money op1stCorrectionAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(-83.57)); - - // get remaining balance and dues for a date - final ProgressiveLoanInterestRepaymentModel repaymentDetails1st = emiCalculator - .getPayableDetails(interestSchedule, op1stCorrectionPeriodDueDate, op1stCorrectionDate).get(); - Assertions.assertEquals(83.57, toDouble(repaymentDetails1st.getOutstandingBalance().getAmount())); - Assertions.assertEquals(16.77, toDouble(repaymentDetails1st.getPrincipalDue().getAmount())); - Assertions.assertEquals(0.24, toDouble(repaymentDetails1st.getInterestDue().getAmount())); - - ProgressiveLoanInterestRepaymentModel details = null; - // check getPayableDetails forcast - details = emiCalculator.getPayableDetails(interestSchedule, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 1)).get(); - Assertions.assertEquals(83.57, toDouble(details.getOutstandingBalance().getAmount())); - Assertions.assertEquals(83.57, toDouble(details.getCorrectedOutstandingBalance().getAmount())); - Assertions.assertEquals(16.52, toDouble(details.getPrincipalDue().getAmount())); - Assertions.assertEquals(0.49, toDouble(details.getInterestDue().getAmount())); - - // apply balance change and check again - emiCalculator.addBalanceCorrection(interestSchedule, op1stCorrectionDate, op1stCorrectionAmount); - details = emiCalculator.getPayableDetails(interestSchedule, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 1)).get(); - Assertions.assertEquals(83.57, toDouble(details.getOutstandingBalance().getAmount())); - Assertions.assertEquals(0, toDouble(details.getCorrectedOutstandingBalance().getAmount())); - Assertions.assertEquals(16.77, toDouble(details.getPrincipalDue().getAmount())); - Assertions.assertEquals(0.24, toDouble(details.getInterestDue().getAmount())); - - emiCalculator.addBalanceCorrection(interestSchedule, LocalDate.of(2024, 3, 1), Money.of(monetaryCurrency, BigDecimal.valueOf(-66.80))); - emiCalculator.addBalanceCorrection(interestSchedule, LocalDate.of(2024, 4, 1), Money.of(monetaryCurrency, BigDecimal.valueOf(-49.79))); - emiCalculator.addBalanceCorrection(interestSchedule, LocalDate.of(2024, 5, 1), Money.of(monetaryCurrency, BigDecimal.valueOf(-32.78))); - emiCalculator.addBalanceCorrection(interestSchedule, LocalDate.of(2024, 6, 1), Money.of(monetaryCurrency, BigDecimal.valueOf(-15.77))); - - details = emiCalculator.getPayableDetails(interestSchedule, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 1)).get(); - Assertions.assertEquals(15.77, toDouble(details.getOutstandingBalance().getAmount())); - Assertions.assertEquals(0, toDouble(details.getCorrectedOutstandingBalance().getAmount())); - Assertions.assertEquals(15.77, toDouble(details.getPrincipalDue().getAmount())); - Assertions.assertEquals(0.0, toDouble(details.getInterestDue().getAmount())); - - // check periods in model - checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); - checkPeriod(interestSchedule, 0, 0, 17.01, 0.005833333333, 0.58, 16.43, 83.57); - checkPeriod(interestSchedule, 1, 0, 17.01, 0.002816091954, 0.24, 0.24, 16.77, 66.80); - checkPeriod(interestSchedule, 1, 1, 17.01, 0.003017241379, 0.0, 0.24, 16.77, 66.80); - checkPeriod(interestSchedule, 2, 0, 17.01, 0.005833333333, 0, 17.01, 49.79); - checkPeriod(interestSchedule, 3, 0, 17.01, 0.005833333333, 0, 17.01, 32.78); - checkPeriod(interestSchedule, 4, 0, 17.01, 0.005833333333, 0, 17.01, 15.77); - checkPeriod(interestSchedule, 5, 0, 15.77, 0.005833333333, 0, 15.77, 0.0); - } - // @Test // public void testEMICalculation_disbursedAmt100_dayInYearsActual_daysInMonthActual_repayEvery1Month_reschedule() { // final MathContext mc = MoneyHelper.getMathContext();