From 7304af57bc673898fbe5a4a21cc2f184dcee1a88 Mon Sep 17 00:00:00 2001 From: Kristof Jozsa Date: Wed, 14 Aug 2024 11:14:48 +0200 Subject: [PATCH] FINERACT-1981: pay-off transaction for progressive loans --- .../loanaccount/data/LoanSummaryData.java | 2 +- .../data/OutstandingAmountsDTO.java | 65 +++++++ .../portfolio/loanaccount/domain/Loan.java | 22 +-- .../LoanRepaymentScheduleInstallment.java | 12 +- ...stractCumulativeLoanScheduleGenerator.java | 15 +- .../domain/LoanScheduleGenerator.java | 6 +- .../ProgressiveLoanScheduleGenerator.java | 82 +++++++- .../ProgressiveLoanScheduleGeneratorTest.java | 176 ++++++++++++++++++ .../service/LoanScheduleAssembler.java | 4 +- ...cheduleCalculationPlatformServiceImpl.java | 9 +- .../service/LoanReadPlatformServiceImpl.java | 19 +- 11 files changed, 370 insertions(+), 42 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/OutstandingAmountsDTO.java create mode 100644 fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGeneratorTest.java diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanSummaryData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanSummaryData.java index 46567bf17bb..de2f68cb061 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanSummaryData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanSummaryData.java @@ -250,7 +250,7 @@ private static BigDecimal computeTotalUnpaidPayableNotDueInterestAmountOnActualP return BigDecimal.ZERO; } - private static BigDecimal computeAccruedInterestTillDay(final LoanSchedulePeriodData period, final long untilDay, + public static BigDecimal computeAccruedInterestTillDay(final LoanSchedulePeriodData period, final long untilDay, final CurrencyData currency) { Integer remainingDays = period.getDaysInPeriod(); BigDecimal totalAccruedInterest = BigDecimal.ZERO; 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 new file mode 100644 index 00000000000..aa1351880b3 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/OutstandingAmountsDTO.java @@ -0,0 +1,65 @@ +/** + * 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.data; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; + +@Data +@Accessors(chain = true, fluent = true) +public class OutstandingAmountsDTO { + + private Money principal; + private Money interest; + private Money feeCharges; + private Money penaltyCharges; + + public OutstandingAmountsDTO(MonetaryCurrency currency) { + this.principal = Money.zero(currency); + this.interest = Money.zero(currency); + this.feeCharges = Money.zero(currency); + this.penaltyCharges = Money.zero(currency); + } + + public Money getTotalOutstanding() { + return principal() // + .plus(interest()) // + .plus(feeCharges()) // + .plus(penaltyCharges()); + } + + public void plusPrincipal(Money principal) { + this.principal = this.principal.plus(principal); + } + + public void plusInterest(Money interest) { + this.interest = this.interest.plus(interest); + } + + public void plusFeeCharges(Money feeCharges) { + this.feeCharges = this.feeCharges.plus(feeCharges); + } + + public void plusPenaltyCharges(Money penaltyCharges) { + this.penaltyCharges = this.penaltyCharges.plus(penaltyCharges); + } + +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 008f9537e04..21f69236d30 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -109,6 +109,7 @@ import org.apache.fineract.portfolio.loanaccount.data.DisbursementData; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; @@ -4368,8 +4369,8 @@ private LoanScheduleDTO getRecalculatedSchedule(final ScheduleGeneratorDTO gener loanRepaymentScheduleTransactionProcessor, generatorDTO.getRecalculateFrom()); } - public LoanRepaymentScheduleInstallment fetchPrepaymentDetail(final ScheduleGeneratorDTO scheduleGeneratorDTO, final LocalDate onDate) { - LoanRepaymentScheduleInstallment installment; + public OutstandingAmountsDTO fetchPrepaymentDetail(final ScheduleGeneratorDTO scheduleGeneratorDTO, final LocalDate onDate) { + OutstandingAmountsDTO outstandingAmounts; if (this.loanRepaymentScheduleDetail.isInterestRecalculationEnabled()) { final MathContext mc = MoneyHelper.getMathContext(); @@ -4381,12 +4382,12 @@ public LoanRepaymentScheduleInstallment fetchPrepaymentDetail(final ScheduleGene .create(loanApplicationTerms.getLoanScheduleType(), interestMethod); final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = this.transactionProcessorFactory .determineProcessor(this.transactionProcessingStrategyCode); - installment = loanScheduleGenerator.calculatePrepaymentAmount(getCurrency(), onDate, loanApplicationTerms, mc, this, + outstandingAmounts = loanScheduleGenerator.calculatePrepaymentAmount(getCurrency(), onDate, loanApplicationTerms, mc, this, scheduleGeneratorDTO.getHolidayDetailDTO(), loanRepaymentScheduleTransactionProcessor); } else { - installment = this.getTotalOutstandingOnLoan(); + outstandingAmounts = this.getTotalOutstandingOnLoan(); } - return installment; + return outstandingAmounts; } public LoanApplicationTerms constructLoanApplicationTerms(final ScheduleGeneratorDTO scheduleGeneratorDTO) { @@ -4460,11 +4461,11 @@ public BigDecimal constructLoanTermVariations(FloatingRateDTO floatingRateDTO, B return annualNominalInterestRate; } - private LoanRepaymentScheduleInstallment getTotalOutstandingOnLoan() { - Money feeCharges = Money.zero(loanCurrency()); - Money penaltyCharges = Money.zero(loanCurrency()); + private OutstandingAmountsDTO getTotalOutstandingOnLoan() { Money totalPrincipal = Money.zero(loanCurrency()); Money totalInterest = Money.zero(loanCurrency()); + Money feeCharges = Money.zero(loanCurrency()); + Money penaltyCharges = Money.zero(loanCurrency()); final Set compoundingDetails = null; List repaymentSchedule = getRepaymentScheduleInstallments(); for (final LoanRepaymentScheduleInstallment scheduledRepayment : repaymentSchedule) { @@ -4473,9 +4474,8 @@ private LoanRepaymentScheduleInstallment getTotalOutstandingOnLoan() { feeCharges = feeCharges.plus(scheduledRepayment.getFeeChargesOutstanding(loanCurrency())); penaltyCharges = penaltyCharges.plus(scheduledRepayment.getPenaltyChargesOutstanding(loanCurrency())); } - LocalDate businessDate = DateUtils.getBusinessLocalDate(); - return new LoanRepaymentScheduleInstallment(null, 0, businessDate, businessDate, totalPrincipal.getAmount(), - totalInterest.getAmount(), feeCharges.getAmount(), penaltyCharges.getAmount(), false, compoundingDetails); + return new OutstandingAmountsDTO(totalPrincipal.getCurrency()).principal(totalPrincipal).interest(totalInterest) + .feeCharges(feeCharges).penaltyCharges(penaltyCharges); } public LocalDate fetchInterestRecalculateFromDate() { 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 7c072bf1b16..34d8baf6b5c 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 @@ -583,20 +583,20 @@ public Money payInterestComponent(final LocalDate transactionDate, final Money t return interestPortionOfTransaction; } - public Money payPrincipalComponent(final LocalDate transactionDate, final Money transactionAmountRemaining) { + public Money payPrincipalComponent(final LocalDate transactionDate, final Money transactionAmount) { - final MonetaryCurrency currency = transactionAmountRemaining.getCurrency(); + final MonetaryCurrency currency = transactionAmount.getCurrency(); Money principalPortionOfTransaction = Money.zero(currency); - if (transactionAmountRemaining.isZero()) { + if (transactionAmount.isZero()) { return principalPortionOfTransaction; } final Money principalDue = getPrincipalOutstanding(currency); - if (transactionAmountRemaining.isGreaterThanOrEqualTo(principalDue)) { + if (transactionAmount.isGreaterThanOrEqualTo(principalDue)) { this.principalCompleted = getPrincipalCompleted(currency).plus(principalDue).getAmount(); principalPortionOfTransaction = principalPortionOfTransaction.plus(principalDue); } else { - this.principalCompleted = getPrincipalCompleted(currency).plus(transactionAmountRemaining).getAmount(); - principalPortionOfTransaction = principalPortionOfTransaction.plus(transactionAmountRemaining); + this.principalCompleted = getPrincipalCompleted(currency).plus(transactionAmount).getAmount(); + principalPortionOfTransaction = principalPortionOfTransaction.plus(transactionAmount); } this.principalCompleted = defaultToNullIfZero(this.principalCompleted); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java index c4d4b884797..b891ace31bf 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java @@ -45,6 +45,7 @@ import org.apache.fineract.portfolio.loanaccount.data.DisbursementData; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalcualtionAdditionalDetails; @@ -2775,7 +2776,7 @@ private LocalDate getNextCompoundScheduleDate(LocalDate startDate, LoanApplicati * Method returns the amount payable to close the loan account as of today. */ @Override - public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(final MonetaryCurrency currency, final LocalDate onDate, + public OutstandingAmountsDTO calculatePrepaymentAmount(final MonetaryCurrency currency, final LocalDate onDate, final LoanApplicationTerms loanApplicationTerms, final MathContext mc, Loan loan, final HolidayDetailDTO holidayDetailDTO, final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) { @@ -2790,10 +2791,10 @@ public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(final Monetary loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(loanApplicationTerms.getExpectedDisbursementDate(), loanTransactions, currency, loanScheduleDTO.getInstallments(), loan.getActiveCharges()); - Money feeCharges = Money.zero(currency); - Money penaltyCharges = Money.zero(currency); Money totalPrincipal = Money.zero(currency); Money totalInterest = Money.zero(currency); + Money feeCharges = Money.zero(currency); + Money penaltyCharges = Money.zero(currency); for (final LoanRepaymentScheduleInstallment currentInstallment : loanScheduleDTO.getInstallments()) { if (currentInstallment.isNotFullyPaidOff()) { totalPrincipal = totalPrincipal.plus(currentInstallment.getPrincipalOutstanding(currency)); @@ -2802,8 +2803,10 @@ public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(final Monetary penaltyCharges = penaltyCharges.plus(currentInstallment.getPenaltyChargesOutstanding(currency)); } } - final Set compoundingDetails = null; - return new LoanRepaymentScheduleInstallment(null, 0, onDate, onDate, totalPrincipal.getAmount(), totalInterest.getAmount(), - feeCharges.getAmount(), penaltyCharges.getAmount(), false, compoundingDetails); + return new OutstandingAmountsDTO(currency) // + .principal(totalPrincipal) // + .interest(totalInterest) // + .feeCharges(feeCharges) // + .penaltyCharges(penaltyCharges); } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGenerator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGenerator.java index def9a53b460..9cb841f5ffc 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGenerator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGenerator.java @@ -23,9 +23,9 @@ import java.util.Set; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; 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.LoanCharge; -import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO; @@ -38,8 +38,8 @@ LoanScheduleDTO rescheduleNextInstallments(MathContext mc, LoanApplicationTerms HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor, LocalDate rescheduleFrom); - LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, - LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO, + OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, LoanApplicationTerms loanApplicationTerms, + MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor); } 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 a53e0b4b79e..e28da69967f 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,22 +18,29 @@ */ package org.apache.fineract.portfolio.loanaccount.loanschedule.domain; +import static java.time.temporal.ChronoUnit.DAYS; + import java.math.BigDecimal; import java.math.MathContext; +import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; 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; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; @@ -48,6 +55,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator { @@ -222,10 +230,80 @@ public LoanScheduleDTO rescheduleNextInstallments(MathContext mc, LoanApplicatio } @Override - public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, + public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) { - return null; + 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 isPayoffAfterInstallmentFrom = installment.getFromDate().isAfter(onDate); + + outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency)); + if (!isPayoffAfterInstallmentFrom) { + 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"); + }; + } + + 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); + } + + payableInterest = payableInterest.minus(installment.getInterestPaid(currency).minus(installment.getInterestWaived(currency))); + + log.debug("Payable interest is {}", payableInterest); + return payableInterest; } // 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 new file mode 100644 index 00000000000..9da9156b15a --- /dev/null +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGeneratorTest.java @@ -0,0 +1,176 @@ +/** + * 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() { + 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()); + } + + @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-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java index 6e8b22d9c3b..7ae511925dd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java @@ -91,6 +91,7 @@ import org.apache.fineract.portfolio.loanaccount.data.DisbursementData; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; @@ -732,7 +733,7 @@ public LoanScheduleModel assembleForInterestRecalculation(final LoanApplicationT loanRepaymentScheduleTransactionProcessor, rescheduleFrom).getLoanScheduleModel(); } - public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, + public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, LoanApplicationTerms loanApplicationTerms, Loan loan, final Long officeId, final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) { final LoanScheduleGenerator loanScheduleGenerator = this.loanScheduleFactory.create(loanApplicationTerms.getLoanScheduleType(), @@ -748,7 +749,6 @@ public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurren return loanScheduleGenerator.calculatePrepaymentAmount(currency, onDate, loanApplicationTerms, mc, loan, holidayDetailDTO, loanRepaymentScheduleTransactionProcessor); - } public void assempleVariableScheduleFrom(final Loan loan, final String json) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleCalculationPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleCalculationPlatformServiceImpl.java index 0f4dd818f60..1edb542bcc2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleCalculationPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleCalculationPlatformServiceImpl.java @@ -31,6 +31,7 @@ import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails; @@ -115,8 +116,12 @@ public void updateFutureSchedule(LoanScheduleData loanScheduleData, final Long l } } LoanApplicationTerms loanApplicationTerms = constructLoanApplicationTerms(loan); - LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment = this.loanScheduleAssembler.calculatePrepaymentAmount(currency, - today, loanApplicationTerms, loan, loan.getOfficeId(), loanRepaymentScheduleTransactionProcessor); + OutstandingAmountsDTO outstandingAmountsDTO = this.loanScheduleAssembler.calculatePrepaymentAmount(currency, today, + loanApplicationTerms, loan, loan.getOfficeId(), loanRepaymentScheduleTransactionProcessor); + LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment = new LoanRepaymentScheduleInstallment(null, 0, today, today, + outstandingAmountsDTO.principal().getAmount(), outstandingAmountsDTO.interest().getAmount(), + outstandingAmountsDTO.feeCharges().getAmount(), outstandingAmountsDTO.penaltyCharges().getAmount(), false, null); + Money totalAmount = totalPrincipal.plus(loanRepaymentScheduleInstallment.getFeeChargesOutstanding(currency)) .plus(loanRepaymentScheduleInstallment.getPenaltyChargesOutstanding(currency)); Money interestDue = Money.zero(currency); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index d662fd3dbaf..cda0e6f407c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -104,6 +104,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionRelationData; +import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; import org.apache.fineract.portfolio.loanaccount.data.PaidInAdvanceData; import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; @@ -473,20 +474,20 @@ public LoanTransactionData retrieveLoanPrePaymentTemplate(final LoanTransactionT final LocalDate earliestUnpaidInstallmentDate = DateUtils.getBusinessLocalDate(); final LocalDate recalculateFrom = null; final ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, recalculateFrom); - final LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment = loan.fetchPrepaymentDetail(scheduleGeneratorDTO, onDate); + final OutstandingAmountsDTO outstandingAmounts = loan.fetchPrepaymentDetail(scheduleGeneratorDTO, onDate); final LoanTransactionEnumData transactionType = LoanEnumerations.transactionType(repaymentTransactionType); final Collection paymentOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); - final BigDecimal outstandingLoanBalance = loanRepaymentScheduleInstallment.getPrincipalOutstanding(currency).getAmount(); + final BigDecimal outstandingLoanBalance = outstandingAmounts.principal().getAmount(); final BigDecimal unrecognizedIncomePortion = null; + BigDecimal adjustedChargeAmount = adjustPrepayInstallmentCharge(loan, onDate); + BigDecimal totalAdjusted = outstandingAmounts.getTotalOutstanding().getAmount().subtract(adjustedChargeAmount); - return new LoanTransactionData(null, null, null, transactionType, null, currencyData, earliestUnpaidInstallmentDate, - loanRepaymentScheduleInstallment.getTotalOutstanding(currency).getAmount().subtract(adjustedChargeAmount), - loan.getNetDisbursalAmount(), loanRepaymentScheduleInstallment.getPrincipalOutstanding(currency).getAmount(), - loanRepaymentScheduleInstallment.getInterestOutstanding(currency).getAmount(), - loanRepaymentScheduleInstallment.getFeeChargesOutstanding(currency).getAmount().subtract(adjustedChargeAmount), - loanRepaymentScheduleInstallment.getPenaltyChargesOutstanding(currency).getAmount(), null, unrecognizedIncomePortion, - paymentOptions, ExternalId.empty(), null, null, outstandingLoanBalance, false, loanId, loan.getExternalId()); + return new LoanTransactionData(null, null, null, transactionType, null, currencyData, earliestUnpaidInstallmentDate, totalAdjusted, + loan.getNetDisbursalAmount(), outstandingAmounts.principal().getAmount(), outstandingAmounts.interest().getAmount(), + outstandingAmounts.feeCharges().getAmount().subtract(adjustedChargeAmount), outstandingAmounts.penaltyCharges().getAmount(), + null, unrecognizedIncomePortion, paymentOptions, ExternalId.empty(), null, null, outstandingLoanBalance, false, loanId, + loan.getExternalId()); } private BigDecimal adjustPrepayInstallmentCharge(Loan loan, final LocalDate onDate) {