From 69dd19c74b374136d82ee9049c4cc7fb17b4388c Mon Sep 17 00:00:00 2001 From: Janos Meszaros Date: Wed, 31 Jul 2024 14:34:05 +0200 Subject: [PATCH] FINERACT-1981: Repayment Schedule Interest Model with multi disbursement support, AdvPymnTxProcessor fix --- .../src/test/resources/features/Loan.feature | 8 +- .../features/LoanDownPayment.feature | 4 +- .../portfolio/loanaccount/domain/Loan.java | 18 +- .../LoanRepaymentScheduleInstallment.java | 21 +- ...RepaymentScheduleTransactionProcessor.java | 7 +- ...RepaymentScheduleTransactionProcessor.java | 18 - .../transactionprocessor/TransactionCtx.java | 26 +- ...stractCumulativeLoanScheduleGenerator.java | 5 +- .../LoanScheduleModelDisbursementPeriod.java | 7 - .../LoanScheduleModelRepaymentPeriod.java | 12 +- .../PreGeneratedLoanSchedulePeriod.java | 96 -- .../domain/ScheduledDateGenerator.java | 2 +- ...edPaymentScheduleTransactionProcessor.java | 271 +++-- .../impl/ProgressiveTransactionCtx.java | 45 + ...veLoanInterestRepaymentInterestPeriod.java | 52 + ...ProgressiveLoanInterestRepaymentModel.java | 82 ++ .../ProgressiveLoanInterestScheduleModel.java | 37 + ...tractProgressiveLoanScheduleGenerator.java | 620 ------------ .../ProgressiveLoanScheduleGenerator.java | 344 ++++++- .../loanproduct/calc/EMICalculator.java | 28 +- .../calc/ProgressiveEMICalculator.java | 523 ++++++---- .../calc/RepaymentPeriodModel.java | 24 - ...ssiveLoanInterestRepaymentModelMapper.java | 37 + ...ymentScheduleTransactionProcessorTest.java | 20 +- .../calc/ProgressiveEMICalculatorTest.java | 933 ++++++++++-------- ...ApplyChargeToOverdueLoansBusinessStep.java | 1 - .../domain/DefaultScheduledDateGenerator.java | 30 +- .../LoanChargeWritePlatformServiceImpl.java | 5 +- ...WritePlatformServiceJpaRepositoryImpl.java | 6 +- .../reaging/LoanReAgingServiceImpl.java | 6 +- .../LoanReAmortizationServiceImpl.java | 6 +- .../starter/LoanAccountAutoStarter.java | 7 +- .../DefaultScheduledDateGeneratorTest.java | 2 +- ...ntAllocationLoanRepaymentScheduleTest.java | 245 +++-- 34 files changed, 1915 insertions(+), 1633 deletions(-) rename fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentScheduleModel.java => fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/TransactionCtx.java (50%) delete mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ProgressiveTransactionCtx.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentInterestPeriod.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentModel.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestScheduleModel.java delete mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java delete mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentPeriodModel.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/mapper/ProgressiveLoanInterestRepaymentModelMapper.java diff --git a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature index 60555c9b0..07e2df118 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature @@ -5237,8 +5237,8 @@ Feature: Loan | 2 | 15 | 16 January 2024 | 16 January 2024 | 250.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | | | | 16 January 2024 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | | 3 | 0 | 16 January 2024 | 16 January 2024 | 625.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | - | 4 | 15 | 31 January 2024 | | 312.0 | 313.0 | 0.0 | 0.0 | 0.0 | 313.0 | 125.0 | 125.0 | 0.0 | 188.0 | - | 5 | 15 | 15 February 2024 | | 0.0 | 312.0 | 0.0 | 0.0 | 0.0 | 312.0 | 125.0 | 125.0 | 0.0 | 187.0 | + | 4 | 15 | 31 January 2024 | | 313.0 | 312.0 | 0.0 | 0.0 | 0.0 | 312.0 | 125.0 | 125.0 | 0.0 | 187.0 | + | 5 | 15 | 15 February 2024 | | 0.0 | 313.0 | 0.0 | 0.0 | 0.0 | 313.0 | 125.0 | 125.0 | 0.0 | 188.0 | Then Loan Repayment schedule has the following data in Total row: | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | | 1000.0 | 0 | 0.0 | 0 | 1000.0 | 625.0 | 250.0 | 0 | 375.0 | @@ -5274,8 +5274,8 @@ Feature: Loan | 2 | 15 | 16 January 2024 | 16 January 2024 | 250.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | | | | 16 January 2024 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | | 3 | 0 | 16 January 2024 | 16 January 2024 | 625.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | - | 4 | 15 | 31 January 2024 | | 312.0 | 313.0 | 0.0 | 0.0 | 0.0 | 313.0 | 125.0 | 125.0 | 0.0 | 188.0 | - | 5 | 15 | 15 February 2024 | | 0.0 | 312.0 | 0.0 | 0.0 | 0.0 | 312.0 | 125.0 | 125.0 | 0.0 | 187.0 | + | 4 | 15 | 31 January 2024 | | 313.0 | 312.0 | 0.0 | 0.0 | 0.0 | 312.0 | 125.0 | 125.0 | 0.0 | 187.0 | + | 5 | 15 | 15 February 2024 | | 0.0 | 313.0 | 0.0 | 0.0 | 0.0 | 313.0 | 125.0 | 125.0 | 0.0 | 188.0 | Then Loan Repayment schedule has the following data in Total row: | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | | 1000.0 | 0 | 0.0 | 0 | 1000.0 | 625.0 | 250.0 | 0 | 375.0 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDownPayment.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDownPayment.feature index c66222764..a2f316a2b 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanDownPayment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDownPayment.feature @@ -2446,8 +2446,8 @@ Feature: Loan DownPayment | 2 | 15 | 16 February 2024 | 16 February 2024 | 250.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | | | | 20 February 2024 | | 625.0 | | | 0.0 | | 0.0 | 0.0 | | | | | 3 | 0 | 20 February 2024 | 20 February 2024 | 719.0 | 156.0 | 0.0 | 0.0 | 0.0 | 156.0 | 156.0 | 0.0 | 0.0 | 0.0 | - | 4 | 15 | 02 March 2024 | 20 February 2024 | 360.0 | 359.0 | 0.0 | 0.0 | 0.0 | 359.0 | 359.0 | 359.0 | 0.0 | 0.0 | - | 5 | 15 | 17 March 2024 | 20 February 2024 | 0.0 | 360.0 | 0.0 | 0.0 | 0.0 | 360.0 | 360.0 | 360.0 | 0.0 | 0.0 | + | 4 | 15 | 02 March 2024 | 20 February 2024 | 359.0 | 360.0 | 0.0 | 0.0 | 0.0 | 360.0 | 360.0 | 360.0 | 0.0 | 0.0 | + | 5 | 15 | 17 March 2024 | 20 February 2024 | 0.0 | 359.0 | 0.0 | 0.0 | 0.0 | 359.0 | 359.0 | 359.0 | 0.0 | 0.0 | Then Loan Repayment schedule has the following data in Total row: | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | | 1125.0 | 0.0 | 0.0 | 0.0 | 1125.0 | 1125.0 | 719.0 | 0.0 | 0.0 | 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 e6c59a5d7..17c7281e6 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 @@ -111,8 +111,8 @@ import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; 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.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidRefundDateException; @@ -801,8 +801,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { } final Set loanCharges = new HashSet<>(1); loanCharges.add(charge); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargesPayment, - new TransactionCtx(getCurrency(), chargePaymentInstallments, loanCharges, new MoneyHolder(getTotalOverpaidAsMoney()))); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargesPayment, new TransactionCtx(getCurrency(), + chargePaymentInstallments, loanCharges, new MoneyHolder(getTotalOverpaidAsMoney()), null)); updateLoanSummaryDerivedFields(); doPostLoanTransactionChecks(chargesPayment.getTransactionDate(), loanLifecycleStateMachine); @@ -1887,7 +1887,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { public void updateLoanRepaymentPeriodsDerivedFields(final LocalDate actualDisbursementDate) { List installments = getRepaymentScheduleInstallments(); for (final LoanRepaymentScheduleInstallment repaymentPeriod : installments) { - repaymentPeriod.updateDerivedFields(loanCurrency(), actualDisbursementDate); + repaymentPeriod.updateObligationsMet(loanCurrency(), actualDisbursementDate); } } @@ -2395,7 +2395,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { if (isTransactionChronologicallyLatest && adjustedTransaction == null && (!reprocess || !this.repaymentScheduleDetail().isInterestRecalculationEnabled()) && !isForeclosure()) { loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(getCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()), null)); reprocess = false; if (this.repaymentScheduleDetail().isInterestRecalculationEnabled()) { if (currentInstallment == null || currentInstallment.isNotFullyPaidOff()) { @@ -2843,7 +2843,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { addLoanTransaction(loanTransaction); loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(loanCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()), null)); updateLoanSummaryDerivedFields(); } @@ -2945,7 +2945,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { addLoanTransaction(loanTransaction); loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(loanCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()), null)); updateLoanSummaryDerivedFields(); } else if (totalOutstanding.isGreaterThanZero()) { @@ -4858,7 +4858,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { // If it's a refund if (adjustedTransaction == null) { loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(getCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()), null)); } else { final List allNonContraTransactionsPostDisbursement = retrieveListOfTransactionsForReprocessing(); changedTransactionDetail = loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(getDisbursementDate(), @@ -4888,7 +4888,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { addLoanTransaction(chargebackTransaction); loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargebackTransaction, new TransactionCtx(getCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()), null)); updateLoanSummaryDerivedFields(); if (!doPostLoanTransactionChecks(chargebackTransaction.getTransactionDate(), loanLifecycleStateMachine)) { 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 97c2ea015..7c072bf1b 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 @@ -743,10 +743,13 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa this.penaltyAccrued = defaultToNullIfZero(penalityCharges.getAmount()); } - public void updateDerivedFields(final MonetaryCurrency currency, final LocalDate actualDisbursementDate) { + public void updateObligationsMet(final MonetaryCurrency currency, final LocalDate transactionDate) { if (!this.obligationsMet && getTotalOutstanding(currency).isZero()) { this.obligationsMet = true; - this.obligationsMetOnDate = actualDisbursementDate; + this.obligationsMetOnDate = transactionDate; + } else if (this.obligationsMet && !getTotalOutstanding(currency).isZero()) { + this.obligationsMet = false; + this.obligationsMetOnDate = null; } } @@ -843,6 +846,15 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa checkIfRepaymentPeriodObligationsAreMet(transactionDate, transactionAmount.getCurrency()); } + public void addToInterest(final LocalDate transactionDate, final Money transactionAmount) { + if (this.interestCharged == null) { + this.interestCharged = transactionAmount.getAmount(); + } else { + this.interestCharged = this.interestCharged.add(transactionAmount.getAmount()); + } + checkIfRepaymentPeriodObligationsAreMet(transactionDate, transactionAmount.getCurrency()); + } + public void addToCreditedPrincipal(final BigDecimal amount) { if (this.creditedPrincipal == null) { this.creditedPrincipal = amount; @@ -1051,6 +1063,11 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa resetDerivedComponents(); resetPrincipalDue(); resetChargesCharged(); + resetInterestDue(); + } + + public void resetInterestDue() { + this.interestCharged = null; } public void resetPrincipalDue() { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java index 742f2bb51..e5978412e 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java @@ -83,7 +83,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { currentInstallment.resetDerivedComponents(); - currentInstallment.updateDerivedFields(currency, disbursementDate); + currentInstallment.updateObligationsMet(currency, disbursementDate); } // re-process loan charges over repayment periods (picking up on waived @@ -164,7 +164,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen if (loanTransaction.isRepaymentLikeType() || loanTransaction.isInterestWaiver() || loanTransaction.isRecoveryRepayment()) { // pass through for new transactions if (loanTransaction.getId() == null) { - processLatestTransaction(loanTransaction, new TransactionCtx(currency, installments, charges, overpaymentHolder)); + processLatestTransaction(loanTransaction, new TransactionCtx(currency, installments, charges, overpaymentHolder, null)); loanTransaction.adjustInterestComponent(currency); } else { /** @@ -175,7 +175,8 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen // Reset derived component of new loan transaction and // re-process transaction - processLatestTransaction(newLoanTransaction, new TransactionCtx(currency, installments, charges, overpaymentHolder)); + processLatestTransaction(newLoanTransaction, + new TransactionCtx(currency, installments, charges, overpaymentHolder, null)); newLoanTransaction.adjustInterestComponent(currency); /** * Check if the transaction amounts have changed. If so, reverse the original transaction and update diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java index 7e862ebe1..f49635f62 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java @@ -21,8 +21,6 @@ package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor; import java.time.LocalDate; import java.util.List; import java.util.Set; -import lombok.AllArgsConstructor; -import lombok.Data; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail; @@ -32,22 +30,6 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; public interface LoanRepaymentScheduleTransactionProcessor { - @Data - @AllArgsConstructor - class TransactionCtx { - - private final MonetaryCurrency currency; - private final List installments; - private final Set charges; - private final MoneyHolder overpaymentHolder; - private final ChangedTransactionDetail changedTransactionDetail; - - public TransactionCtx(MonetaryCurrency currency, List installments, Set charges, - MoneyHolder overpaymentHolder) { - this(currency, installments, charges, overpaymentHolder, null); - } - } - String getCode(); String getName(); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentScheduleModel.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/TransactionCtx.java similarity index 50% rename from fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentScheduleModel.java rename to fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/TransactionCtx.java index 14d224921..bda760734 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentScheduleModel.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/TransactionCtx.java @@ -16,18 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.fineract.portfolio.loanproduct.calc; +package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor; -import java.util.ArrayList; import java.util.List; -import lombok.Getter; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; -@Getter -public class RepaymentScheduleModel { +@Data +@AllArgsConstructor +public class TransactionCtx { - List scheduleList = new ArrayList<>(); - - public void addRepaymentPeriodModel(final RepaymentPeriodModel repaymentPeriodModel) { - scheduleList.add(repaymentPeriodModel); - } + private final MonetaryCurrency currency; + private final List installments; + private final Set charges; + private final MoneyHolder overpaymentHolder; + private final ChangedTransactionDetail changedTransactionDetail; } 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 7e46ce5cd..c4d4b8847 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 @@ -2001,8 +2001,9 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch final LoanScheduleParams loanScheduleParams, final BigDecimal chargesDueAtTimeOfDisbursement) { List periods = new ArrayList<>(); if (!loanApplicationTerms.isMultiDisburseLoan()) { - final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod - .disbursement(loanApplicationTerms, chargesDueAtTimeOfDisbursement); + final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod.disbursement( + loanApplicationTerms.getExpectedDisbursementDate(), loanApplicationTerms.getPrincipal(), + chargesDueAtTimeOfDisbursement); periods.add(disbursementPeriod); if (loanApplicationTerms.isDownPaymentEnabled()) { final LoanScheduleModelDownPaymentPeriod downPaymentPeriod = createDownPaymentPeriod(loanApplicationTerms, diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelDisbursementPeriod.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelDisbursementPeriod.java index 3f9894ea9..e5e31e91d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelDisbursementPeriod.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelDisbursementPeriod.java @@ -37,13 +37,6 @@ public final class LoanScheduleModelDisbursementPeriod implements LoanScheduleMo private final BigDecimal chargesDueAtTimeOfDisbursement; private boolean isEMIFixedSpecificToInstallment = false; - public static LoanScheduleModelDisbursementPeriod disbursement(final LoanApplicationTerms loanApplicationTerms, - final BigDecimal chargesDueAtTimeOfDisbursement) { - - return new LoanScheduleModelDisbursementPeriod(null, loanApplicationTerms.getExpectedDisbursementDate(), - loanApplicationTerms.getPrincipal(), chargesDueAtTimeOfDisbursement); - } - public static LoanScheduleModelDisbursementPeriod disbursement(final LocalDate disbursementDate, final Money principalDisbursed, final BigDecimal chargesDueAtTimeOfDisbursement) { return new LoanScheduleModelDisbursementPeriod(null, disbursementDate, principalDisbursed, chargesDueAtTimeOfDisbursement); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelRepaymentPeriod.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelRepaymentPeriod.java index a3db61f20..fd848751e 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelRepaymentPeriod.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleModelRepaymentPeriod.java @@ -33,11 +33,11 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleP @Getter public final class LoanScheduleModelRepaymentPeriod implements LoanScheduleModelPeriod { - private final int periodNumber; + private int periodNumber; private final LocalDate fromDate; private final LocalDate dueDate; private Money principalDue; - private final Money outstandingLoanBalance; + private Money outstandingLoanBalance; private Money interestDue; private Money feeChargesDue; private Money penaltyChargesDue; @@ -166,6 +166,14 @@ public final class LoanScheduleModelRepaymentPeriod implements LoanScheduleModel this.totalDue = this.totalDue.plus(interestDue); } + public void setPeriodNumber(int periodNumber) { + this.periodNumber = periodNumber; + } + + public void setOutstandingLoanBalance(Money outstandingLoanBalance) { + this.outstandingLoanBalance = outstandingLoanBalance; + } + @Override public Set getLoanCompoundingDetails() { return this.loanCompoundingDetails; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java deleted file mode 100644 index 636cf0046..000000000 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/PreGeneratedLoanSchedulePeriod.java +++ /dev/null @@ -1,96 +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 java.math.BigDecimal; -import java.time.LocalDate; -import java.util.Set; -import lombok.Data; -import lombok.experimental.Accessors; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalcualtionAdditionalDetails; -import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePeriodData; - -@Data -@Accessors(fluent = true) -public class PreGeneratedLoanSchedulePeriod implements LoanScheduleModelPeriod { - - private Integer periodNumber; - private boolean isRepaymentPeriod; - private boolean isDownPaymentPeriod; - private LocalDate periodFromDate; - private LocalDate periodDueDate; - private BigDecimal principalDue; - private BigDecimal interestDue; - private BigDecimal feeChargesDue; - private BigDecimal penaltyChargesDue; - private BigDecimal rescheduleInterestPortion; - private boolean isRecalculatedInterestComponent; - private boolean isEMIFixedSpecificToInstallment; - - public PreGeneratedLoanSchedulePeriod(Integer periodNumber, LocalDate periodFromDate, LocalDate periodDueDate) { - this.periodNumber = periodNumber; - this.isRepaymentPeriod = true; - this.isDownPaymentPeriod = false; - this.periodFromDate = periodFromDate; - this.periodDueDate = periodDueDate; - this.principalDue = BigDecimal.ZERO; - this.interestDue = BigDecimal.ZERO; - this.feeChargesDue = BigDecimal.ZERO; - this.penaltyChargesDue = BigDecimal.ZERO; - this.rescheduleInterestPortion = BigDecimal.ZERO; - this.isRecalculatedInterestComponent = false; - this.isEMIFixedSpecificToInstallment = false; - } - - @Override - public LoanSchedulePeriodData toData() { - return null; - } - - @Override - public void addLoanCharges(BigDecimal feeCharge, BigDecimal penaltyCharge) { - throw new UnsupportedOperationException(); - } - - @Override - public void addPrincipalAmount(Money principalDue) { - this.principalDue = this.principalDue.add(principalDue.getAmount()); - } - - @Override - public void addInterestAmount(Money interestDue) { - this.interestDue = this.principalDue.add(interestDue.getAmount()); - } - - @Override - public Set getLoanCompoundingDetails() { - return Set.of(); - } - - @Override - public void setEMIFixedSpecificToInstallmentTrue() { - throw new UnsupportedOperationException(); - } - - @Override - public void setRescheduleInterestPortion(BigDecimal rescheduleInterestPortion) { - throw new UnsupportedOperationException(); - } -} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java index 88271f95c..fd5e824e8 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ScheduledDateGenerator.java @@ -27,7 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; public interface ScheduledDateGenerator { - List generateRepaymentPeriods(LocalDate scheduledDueDate, LoanApplicationTerms loanApplicationTerms, + List generateRepaymentPeriods(LocalDate scheduledDueDate, LoanApplicationTerms loanApplicationTerms, HolidayDetailDTO holidayDetailDTO); LocalDate getLastRepaymentDate(LoanApplicationTerms loanApplicationTerms, HolidayDetailDTO holidayDetailDTO); 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 0dfbfb7a8..a9b5773b9 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 @@ -74,10 +74,12 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationT import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping; import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymentScheduleProcessingWrapper; import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter; -import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; 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.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; +import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; import org.apache.fineract.portfolio.loanproduct.domain.AllocationType; import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType; import org.apache.fineract.portfolio.loanproduct.domain.DueType; @@ -95,7 +97,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep public final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper(); - private final LoanReAgingParameterRepository reAgingParameterRepository; + public final EMICalculator emiCalculator; @Override public String getCode() { @@ -144,6 +146,10 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep @Override public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List loanTransactions, MonetaryCurrency currency, List installments, Set charges) { + final ChangedTransactionDetail changedTransactionDetail = new ChangedTransactionDetail(); + if (loanTransactions.isEmpty()) { + return changedTransactionDetail; + } if (charges != null) { for (final LoanCharge loanCharge : charges) { if (!loanCharge.isDueAtDisbursement()) { @@ -160,16 +166,22 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { currentInstallment.resetBalances(); - currentInstallment.updateDerivedFields(currency, disbursementDate); + currentInstallment.updateObligationsMet(currency, disbursementDate); } List chargeOrTransactions = createSortedChargesAndTransactionsList(loanTransactions, charges); - final ChangedTransactionDetail changedTransactionDetail = new ChangedTransactionDetail(); MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(currency)); + final MathContext mc = MoneyHelper.getMathContext(); + final Loan loan = loanTransactions.get(0).getLoan(); + final Integer installmentAmountInMultiplesOf = loan.getLoanProduct().getInstallmentAmountInMultiplesOf(); + final LoanProductRelatedDetail loanProductRelatedDetail = loan.getLoanRepaymentScheduleDetail(); + ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateModel(loanProductRelatedDetail, + installmentAmountInMultiplesOf, installments, mc); + for (final ChargeOrTransaction chargeOrTransaction : chargeOrTransactions) { chargeOrTransaction.getLoanTransaction().ifPresent(loanTransaction -> processSingleTransaction(loanTransaction, currency, - installments, charges, changedTransactionDetail, overpaymentHolder)); + installments, charges, changedTransactionDetail, overpaymentHolder, scheduleModel)); chargeOrTransaction.getLoanCharge() .ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate)); } @@ -182,7 +194,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep @Override public void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { switch (loanTransaction.getTypeOf()) { - case DISBURSEMENT -> handleDisbursement(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getOverpaymentHolder()); + case DISBURSEMENT -> handleDisbursement(loanTransaction, ctx); case WRITEOFF -> handleWriteOff(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); case REFUND_FOR_ACTIVE_LOAN -> handleRefund(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges()); case CHARGEBACK -> handleChargeback(loanTransaction, ctx); @@ -190,12 +202,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep handleCreditBalanceRefund(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getOverpaymentHolder()); case REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT, DOWN_PAYMENT, WAIVE_INTEREST, RECOVERY_REPAYMENT, INTEREST_PAYMENT_WAIVER -> - handleRepayment(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges(), ctx.getOverpaymentHolder()); - case CHARGE_OFF -> handleChargeOff(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); - case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges(), - ctx.getOverpaymentHolder()); + handleRepayment(loanTransaction, ctx); + case CHARGE_OFF -> handleChargeOff(loanTransaction, ctx); + case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, ctx); case WAIVE_CHARGES -> log.debug("WAIVE_CHARGES transaction will not be processed."); - case REAMORTIZE -> handleReAmortization(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); + case REAMORTIZE -> handleReAmortization(loanTransaction, ctx); case REAGE -> handleReAge(loanTransaction, ctx); case ACCRUAL_ACTIVITY -> calculateAccrualActivity(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); // TODO: Cover rest of the transaction types @@ -205,28 +216,29 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } } - private void handleReAmortization(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments) { + private void handleReAmortization(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { LocalDate transactionDate = loanTransaction.getTransactionDate(); - List previousInstallments = installments.stream() // + List previousInstallments = transactionCtx.getInstallments().stream() // .filter(installment -> !installment.getDueDate().isAfter(transactionDate)) // .toList(); - List futureInstallments = installments.stream() // + List futureInstallments = transactionCtx.getInstallments().stream() // .filter(installment -> installment.getDueDate().isAfter(transactionDate)) // .filter(installment -> !installment.isAdditional() && !installment.isDownPayment() && !installment.isReAged()) // .toList(); BigDecimal overallOverDuePrincipal = ZERO; for (LoanRepaymentScheduleInstallment installment : previousInstallments) { - Money principalCompleted = installment.getPrincipalCompleted(currency); - overallOverDuePrincipal = overallOverDuePrincipal.add(installment.getPrincipal(currency).minus(principalCompleted).getAmount()); - installment.updatePrincipal(installment.getPrincipalCompleted(currency).getAmount()); - installment.updateDerivedFields(currency, transactionDate); + Money principalCompleted = installment.getPrincipalCompleted(transactionCtx.getCurrency()); + overallOverDuePrincipal = overallOverDuePrincipal + .add(installment.getPrincipal(transactionCtx.getCurrency()).minus(principalCompleted).getAmount()); + installment.updatePrincipal(installment.getPrincipalCompleted(transactionCtx.getCurrency()).getAmount()); + installment.updateObligationsMet(transactionCtx.getCurrency(), transactionDate); } loanTransaction.resetDerivedComponents(); - loanTransaction.updateComponentsAndTotal(Money.of(currency, overallOverDuePrincipal), Money.zero(currency), Money.zero(currency), - Money.zero(currency)); + loanTransaction.updateComponentsAndTotal(Money.of(transactionCtx.getCurrency(), overallOverDuePrincipal), + Money.zero(transactionCtx.getCurrency()), Money.zero(transactionCtx.getCurrency()), + Money.zero(transactionCtx.getCurrency())); LoanRepaymentScheduleInstallment lastFutureInstallment = futureInstallments.stream() .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate)).get(); @@ -236,13 +248,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep for (LoanRepaymentScheduleInstallment installment : futureInstallments) { if (lastFutureInstallment.equals(installment)) { - installment.addToPrincipal(transactionDate, Money.of(currency, overallOverDuePrincipal)); + installment.addToPrincipal(transactionDate, Money.of(transactionCtx.getCurrency(), overallOverDuePrincipal)); } else { if (installmentAmountInMultiplesOf != null) { reAmortizationAmountPerInstallment = Money.roundToMultiplesOf(reAmortizationAmountPerInstallment, installmentAmountInMultiplesOf); } - installment.addToPrincipal(transactionDate, Money.of(currency, reAmortizationAmountPerInstallment)); + installment.addToPrincipal(transactionDate, Money.of(transactionCtx.getCurrency(), reAmortizationAmountPerInstallment)); overallOverDuePrincipal = overallOverDuePrincipal.subtract(reAmortizationAmountPerInstallment); } } @@ -361,8 +373,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep loan.addLoanRepaymentScheduleInstallment(installment); } } - allocateOverpayment(loanTransaction, ctx.getCurrency(), loanTransaction.getLoan().getRepaymentScheduleInstallments(), - ctx.getOverpaymentHolder()); + allocateOverpayment(loanTransaction, ctx); } else { throw new RuntimeException("Unsupported transaction " + loanTransaction.getTypeOf().name()); } @@ -544,8 +555,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep private void processSingleTransaction(LoanTransaction loanTransaction, MonetaryCurrency currency, List installments, Set charges, ChangedTransactionDetail changedTransactionDetail, - MoneyHolder overpaymentHolder) { - TransactionCtx ctx = new TransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail); + MoneyHolder overpaymentHolder, ProgressiveLoanInterestScheduleModel scheduleModel) { + TransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail, + scheduleModel); if (loanTransaction.getId() == null) { processLatestTransaction(loanTransaction, ctx); if (loanTransaction.isInterestWaiver()) { @@ -613,63 +625,113 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep return chargeOrTransactions; } - private void handleDisbursement(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, MoneyHolder overpaymentHolder) { - updateLoanSchedule(loanTransaction, currency, installments, overpaymentHolder); - } - - private void updateLoanSchedule(LoanTransaction disbursementTransaction, MonetaryCurrency currency, - List installments, MoneyHolder overpaymentHolder) { + private void handleDisbursementWithEMICalculator(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) { + ProgressiveTransactionCtx progressiveTransactionCtx = (ProgressiveTransactionCtx) transactionCtx; + if (progressiveTransactionCtx.getModel() == null) { + throw new IllegalStateException("TransactionCtx has no model"); + } disbursementTransaction.resetDerivedComponents(); final MathContext mc = MoneyHelper.getMathContext(); - List candidateRepaymentInstallments = installments.stream().filter( - i -> i.getDueDate().isAfter(disbursementTransaction.getTransactionDate()) && !i.isDownPayment() && !i.isAdditional()) - .toList(); - int noCandidateRepaymentInstallments = candidateRepaymentInstallments.size(); LoanProductRelatedDetail loanProductRelatedDetail = disbursementTransaction.getLoan().getLoanRepaymentScheduleDetail(); Integer installmentAmountInMultiplesOf = disbursementTransaction.getLoan().getLoanProduct().getInstallmentAmountInMultiplesOf(); - Money downPaymentAmount = Money.zero(currency); + Money downPaymentAmount = Money.zero(progressiveTransactionCtx.getCurrency()); if (loanProductRelatedDetail.isEnableDownPayment()) { - LoanRepaymentScheduleInstallment downPaymentInstallment = installments.stream() - .filter(i -> i.isDownPayment() && i.getPrincipal(currency).isZero()).findFirst().orElseThrow(); + LoanRepaymentScheduleInstallment downPaymentInstallment = progressiveTransactionCtx.getInstallments().stream() + .filter(i -> i.isDownPayment() && i.getPrincipal(progressiveTransactionCtx.getCurrency()).isZero()).findFirst() + .orElseThrow(); BigDecimal downPaymentAmt = MathUtil.percentageOf(disbursementTransaction.getAmount(), loanProductRelatedDetail.getDisbursedAmountPercentageForDownPayment(), mc); if (installmentAmountInMultiplesOf != null) { downPaymentAmt = Money.roundToMultiplesOf(downPaymentAmt, installmentAmountInMultiplesOf); } - downPaymentAmount = Money.of(currency, downPaymentAmt); + downPaymentAmount = Money.of(transactionCtx.getCurrency(), downPaymentAmt); downPaymentInstallment.addToPrincipal(disbursementTransaction.getTransactionDate(), downPaymentAmount); } - Money amortizableAmount = disbursementTransaction.getAmount(currency).minus(downPaymentAmount); + Money amortizableAmount = disbursementTransaction.getAmount(transactionCtx.getCurrency()).minus(downPaymentAmount); + + emiCalculator.addDisbursement(progressiveTransactionCtx.getModel(), disbursementTransaction.getTransactionDate(), + amortizableAmount); + + if (amortizableAmount.isGreaterThanZero()) { + progressiveTransactionCtx.getModel().repayments().forEach(rm -> { + LoanRepaymentScheduleInstallment installment = transactionCtx.getInstallments().stream() + .filter(ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment() + && !ri.getDueDate().isBefore(disbursementTransaction.getTransactionDate())) + .findFirst().orElse(null); + if (installment != null) { + installment.updatePrincipal(rm.getPrincipalDue().getAmount()); + installment.updateInterestCharged(rm.getInterestDue().getAmount()); + installment.updateObligationsMet(progressiveTransactionCtx.getCurrency(), disbursementTransaction.getTransactionDate()); + } + }); + } + + allocateOverpayment(disbursementTransaction, transactionCtx); + } + + private void handleDisbursement(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) { + // TODO: Fix this and enhance EMICalculator to support reamortization and reaging + if (disbursementTransaction.getLoan().isInterestBearing()) { + handleDisbursementWithEMICalculator(disbursementTransaction, transactionCtx); + } else { + handleDisbursementWithoutEMICalculator(disbursementTransaction, transactionCtx); + } + } + + private void handleDisbursementWithoutEMICalculator(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) { + disbursementTransaction.resetDerivedComponents(); + final MathContext mc = MoneyHelper.getMathContext(); + List candidateRepaymentInstallments = transactionCtx.getInstallments().stream().filter( + i -> i.getDueDate().isAfter(disbursementTransaction.getTransactionDate()) && !i.isDownPayment() && !i.isAdditional()) + .toList(); + int noCandidateRepaymentInstallments = candidateRepaymentInstallments.size(); + LoanProductRelatedDetail loanProductRelatedDetail = disbursementTransaction.getLoan().getLoanRepaymentScheduleDetail(); + Integer installmentAmountInMultiplesOf = disbursementTransaction.getLoan().getLoanProduct().getInstallmentAmountInMultiplesOf(); + Money downPaymentAmount = Money.zero(transactionCtx.getCurrency()); + if (loanProductRelatedDetail.isEnableDownPayment()) { + LoanRepaymentScheduleInstallment downPaymentInstallment = transactionCtx.getInstallments().stream() + .filter(i -> i.isDownPayment() && i.getPrincipal(transactionCtx.getCurrency()).isZero()).findFirst().orElseThrow(); + BigDecimal downPaymentAmt = MathUtil.percentageOf(disbursementTransaction.getAmount(), + loanProductRelatedDetail.getDisbursedAmountPercentageForDownPayment(), mc); + if (installmentAmountInMultiplesOf != null) { + downPaymentAmt = Money.roundToMultiplesOf(downPaymentAmt, installmentAmountInMultiplesOf); + } + downPaymentAmount = Money.of(transactionCtx.getCurrency(), downPaymentAmt); + downPaymentInstallment.addToPrincipal(disbursementTransaction.getTransactionDate(), downPaymentAmount); + } + + Money amortizableAmount = disbursementTransaction.getAmount(transactionCtx.getCurrency()).minus(downPaymentAmount); if (amortizableAmount.isGreaterThanZero()) { Money increasePrincipalBy = amortizableAmount.dividedBy(noCandidateRepaymentInstallments, mc.getRoundingMode()); - if (installmentAmountInMultiplesOf != null) { - increasePrincipalBy = Money.roundToMultiplesOf(increasePrincipalBy, installmentAmountInMultiplesOf); - } + MoneyHolder moneyHolder = new MoneyHolder(amortizableAmount); - Money remainingAmount = amortizableAmount - .minus(increasePrincipalBy.multiplyRetainScale(noCandidateRepaymentInstallments, mc.getRoundingMode())); - - Money finalIncreasePrincipalBy = increasePrincipalBy; - candidateRepaymentInstallments - .forEach(i -> i.addToPrincipal(disbursementTransaction.getTransactionDate(), finalIncreasePrincipalBy)); + candidateRepaymentInstallments.forEach(i -> { + Money previousPrincipal = i.getPrincipal(transactionCtx.getCurrency()); + Money newPrincipal = previousPrincipal.add(increasePrincipalBy); + if (installmentAmountInMultiplesOf != null) { + newPrincipal = Money.roundToMultiplesOf(newPrincipal, installmentAmountInMultiplesOf); + } + i.updatePrincipal(newPrincipal.getAmount()); + moneyHolder.setMoneyObject(moneyHolder.getMoneyObject().minus(newPrincipal).plus(previousPrincipal)); + i.updateObligationsMet(transactionCtx.getCurrency(), disbursementTransaction.getTransactionDate()); + }); // Hence the rounding, we might need to amend the last installment amount candidateRepaymentInstallments.get(noCandidateRepaymentInstallments - 1) - .addToPrincipal(disbursementTransaction.getTransactionDate(), remainingAmount); + .addToPrincipal(disbursementTransaction.getTransactionDate(), moneyHolder.getMoneyObject()); } - allocateOverpayment(disbursementTransaction, currency, installments, overpaymentHolder); + allocateOverpayment(disbursementTransaction, transactionCtx); } - private void allocateOverpayment(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, MoneyHolder overpaymentHolder) { - if (overpaymentHolder.getMoneyObject().isGreaterThanZero()) { - if (overpaymentHolder.getMoneyObject().isGreaterThan(loanTransaction.getAmount(currency))) { - loanTransaction.setOverPayments(loanTransaction.getAmount(currency)); + private void allocateOverpayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { + if (transactionCtx.getOverpaymentHolder().getMoneyObject().isGreaterThanZero()) { + if (transactionCtx.getOverpaymentHolder().getMoneyObject() + .isGreaterThan(loanTransaction.getAmount(transactionCtx.getCurrency()))) { + loanTransaction.setOverPayments(loanTransaction.getAmount(transactionCtx.getCurrency())); } else { - loanTransaction.setOverPayments(overpaymentHolder.getMoneyObject()); + loanTransaction.setOverPayments(transactionCtx.getOverpaymentHolder().getMoneyObject()); } List transactionMappings = new ArrayList<>(); List paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules(); @@ -677,29 +739,30 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep .filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow(); Money transactionAmountUnprocessed = null; - Money zero = Money.zero(currency); + Money zero = Money.zero(transactionCtx.getCurrency()); Balances balances = new Balances(zero, zero, zero, zero); if (LoanScheduleProcessingType.HORIZONTAL .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, currency, installments, - overpaymentHolder.getMoneyObject(), defaultPaymentAllocationRule, transactionMappings, Set.of(), balances); + transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(), + transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(), + defaultPaymentAllocationRule, transactionMappings, Set.of(), balances); } else if (LoanScheduleProcessingType.VERTICAL .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, currency, installments, - overpaymentHolder.getMoneyObject(), defaultPaymentAllocationRule, transactionMappings, Set.of(), balances); + transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(), + transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(), + defaultPaymentAllocationRule, transactionMappings, Set.of(), balances); } - overpaymentHolder.setMoneyObject(transactionAmountUnprocessed); + transactionCtx.getOverpaymentHolder().setMoneyObject(transactionAmountUnprocessed); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); } } - private void handleRepayment(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, Set charges, MoneyHolder overpaymentHolder) { + private void handleRepayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { if (loanTransaction.isRepaymentLikeType() || loanTransaction.isInterestWaiver() || loanTransaction.isRecoveryRepayment()) { loanTransaction.resetDerivedComponents(); } - Money transactionAmountUnprocessed = loanTransaction.getAmount(currency); - processTransaction(loanTransaction, currency, installments, transactionAmountUnprocessed, charges, overpaymentHolder); + Money transactionAmountUnprocessed = loanTransaction.getAmount(transactionCtx.getCurrency()); + processTransaction(loanTransaction, transactionCtx, transactionAmountUnprocessed); } private LoanTransactionToRepaymentScheduleMapping getTransactionMapping( @@ -781,44 +844,44 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } } - private void handleChargeOff(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments) { + private void handleChargeOff(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { loanTransaction.resetDerivedComponents(); // determine how much is outstanding total and breakdown for principal, interest and charges - Money principalPortion = Money.zero(currency); - Money interestPortion = Money.zero(currency); - Money feeChargesPortion = Money.zero(currency); - Money penaltychargesPortion = Money.zero(currency); - for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { + Money principalPortion = Money.zero(transactionCtx.getCurrency()); + Money interestPortion = Money.zero(transactionCtx.getCurrency()); + Money feeChargesPortion = Money.zero(transactionCtx.getCurrency()); + Money penaltychargesPortion = Money.zero(transactionCtx.getCurrency()); + for (final LoanRepaymentScheduleInstallment currentInstallment : transactionCtx.getInstallments()) { if (currentInstallment.isNotFullyPaidOff()) { - principalPortion = principalPortion.plus(currentInstallment.getPrincipalOutstanding(currency)); - interestPortion = interestPortion.plus(currentInstallment.getInterestOutstanding(currency)); - feeChargesPortion = feeChargesPortion.plus(currentInstallment.getFeeChargesOutstanding(currency)); - penaltychargesPortion = penaltychargesPortion.plus(currentInstallment.getPenaltyChargesOutstanding(currency)); + principalPortion = principalPortion.plus(currentInstallment.getPrincipalOutstanding(transactionCtx.getCurrency())); + interestPortion = interestPortion.plus(currentInstallment.getInterestOutstanding(transactionCtx.getCurrency())); + feeChargesPortion = feeChargesPortion.plus(currentInstallment.getFeeChargesOutstanding(transactionCtx.getCurrency())); + penaltychargesPortion = penaltychargesPortion + .plus(currentInstallment.getPenaltyChargesOutstanding(transactionCtx.getCurrency())); } } loanTransaction.updateComponentsAndTotal(principalPortion, interestPortion, feeChargesPortion, penaltychargesPortion); } - private void handleChargePayment(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, Set charges, MoneyHolder overpaymentHolder) { - Money zero = Money.zero(currency); + private void handleChargePayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { + Money zero = Money.zero(transactionCtx.getCurrency()); Money feeChargesPortion = zero; Money penaltyChargesPortion = zero; List transactionMappings = new ArrayList<>(); LoanChargePaidBy loanChargePaidBy = loanTransaction.getLoanChargesPaid().stream().findFirst().get(); LoanCharge loanCharge = loanChargePaidBy.getLoanCharge(); - Money amountToBePaid = Money.of(currency, loanTransaction.getAmount()); - if (loanCharge.getAmountOutstanding(currency).isLessThan(amountToBePaid)) { - amountToBePaid = loanCharge.getAmountOutstanding(currency); + Money amountToBePaid = Money.of(transactionCtx.getCurrency(), loanTransaction.getAmount()); + if (loanCharge.getAmountOutstanding(transactionCtx.getCurrency()).isLessThan(amountToBePaid)) { + amountToBePaid = loanCharge.getAmountOutstanding(transactionCtx.getCurrency()); } LocalDate startDate = loanTransaction.getLoan().getDisbursementDate(); - Money unprocessed = loanTransaction.getAmount(currency); - int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments); - for (final LoanRepaymentScheduleInstallment installment : installments) { + Money unprocessed = loanTransaction.getAmount(transactionCtx.getCurrency()); + int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper + .fetchFirstNormalInstallmentNumber(transactionCtx.getInstallments()); + for (final LoanRepaymentScheduleInstallment installment : transactionCtx.getInstallments()) { boolean isDue = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber) ? loanCharge.isDueForCollectionFromIncludingAndUpToAndIncluding(startDate, installment.getDueDate()) : loanCharge.isDueForCollectionFromAndUpToAndIncluding(startDate, installment.getDueDate()); @@ -827,7 +890,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep Money paidAmount = loanCharge.updatePaidAmountBy(amountToBePaid, installmentNumber, zero); LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, installment, currency); + transactionMappings, loanTransaction, installment, transactionCtx.getCurrency()); if (loanTransaction.isPenaltyPayment()) { penaltyChargesPortion = installment.payPenaltyChargesComponent(loanTransaction.getTransactionDate(), paidAmount); @@ -842,13 +905,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } loanTransaction.updateComponents(zero, zero, feeChargesPortion, penaltyChargesPortion); - unprocessed = loanTransaction.getAmount(currency).minus(paidAmount); + unprocessed = loanTransaction.getAmount(transactionCtx.getCurrency()).minus(paidAmount); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); } } if (unprocessed.isGreaterThanZero()) { - processTransaction(loanTransaction, currency, installments, unprocessed, charges, overpaymentHolder); + processTransaction(loanTransaction, transactionCtx, unprocessed); } } @@ -1032,10 +1095,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep return inAdvanceInstallments; } - private void processTransaction(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, Money transactionAmountUnprocessed, Set charges, - MoneyHolder overpaymentHolder) { - Money zero = Money.zero(currency); + private void processTransaction(LoanTransaction loanTransaction, TransactionCtx transactionCtx, Money transactionAmountUnprocessed) { + Money zero = Money.zero(transactionCtx.getCurrency()); List transactionMappings = new ArrayList<>(); List paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules(); @@ -1048,18 +1109,20 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep if (LoanScheduleProcessingType.HORIZONTAL .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, currency, installments, transactionAmountUnprocessed, - paymentAllocationRule, transactionMappings, charges, balances); + transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(), + transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings, + transactionCtx.getCharges(), balances); } else if (LoanScheduleProcessingType.VERTICAL .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, currency, installments, transactionAmountUnprocessed, - paymentAllocationRule, transactionMappings, charges, balances); + transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(), + transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings, + transactionCtx.getCharges(), balances); } loanTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(), balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion()); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); - handleOverpayment(transactionAmountUnprocessed, loanTransaction, overpaymentHolder); + handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx.getOverpaymentHolder()); } private Money processPeriodsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency, @@ -1339,7 +1402,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep lastNormalInstallment.getLoan(), lastNormalInstallment.getInstallmentNumber() + 1, lastNormalInstallment.getDueDate(), loanTransaction.getLoanReAgeParameter().getStartDate(), calculatedPrincipal.getAmount()); installments.add(reAgedInstallment); - reAgedInstallment.updateDerivedFields(currency, loanTransaction.getTransactionDate()); + reAgedInstallment.updateObligationsMet(currency, loanTransaction.getTransactionDate()); for (int i = 1; i < loanTransaction.getLoanReAgeParameter().getNumberOfInstallments(); i++) { LocalDate calculatedDueDate = calculateReAgedInstallmentDueDate(loanTransaction.getLoanReAgeParameter(), @@ -1348,7 +1411,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep reAgedInstallment.getInstallmentNumber() + 1, reAgedInstallment.getDueDate(), calculatedDueDate, calculatedPrincipal.getAmount()); installments.add(reAgedInstallment); - reAgedInstallment.updateDerivedFields(currency, loanTransaction.getTransactionDate()); + reAgedInstallment.updateObligationsMet(currency, loanTransaction.getTransactionDate()); } reAgedInstallment.addToPrincipal(loanTransaction.getTransactionDate(), adjustCalculatedPrincipal); reprocessInstallmentsOrder(installments); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ProgressiveTransactionCtx.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ProgressiveTransactionCtx.java new file mode 100644 index 000000000..c1348f004 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ProgressiveTransactionCtx.java @@ -0,0 +1,45 @@ +/** + * 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.domain.transactionprocessor.impl; + +import java.util.List; +import java.util.Set; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +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.ProgressiveLoanInterestScheduleModel; + +public class ProgressiveTransactionCtx extends TransactionCtx { + + private final ProgressiveLoanInterestScheduleModel model; + + public ProgressiveTransactionCtx(MonetaryCurrency currency, List installments, + Set charges, MoneyHolder overpaymentHolder, ChangedTransactionDetail changedTransactionDetail, + ProgressiveLoanInterestScheduleModel model) { + super(currency, installments, charges, overpaymentHolder, changedTransactionDetail); + this.model = model; + } + + public ProgressiveLoanInterestScheduleModel getModel() { + return model; + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentInterestPeriod.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentInterestPeriod.java new file mode 100644 index 000000000..2a5a04ecb --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentInterestPeriod.java @@ -0,0 +1,52 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.jetbrains.annotations.NotNull; + +@AllArgsConstructor +@Data +public class ProgressiveLoanInterestRepaymentInterestPeriod implements Comparable { + + private LocalDate fromDate; + private LocalDate dueDate; + + private BigDecimal rateFactorMinus1; + + private Money disbursedAmount; + private Money interestDue; + + public ProgressiveLoanInterestRepaymentInterestPeriod(final ProgressiveLoanInterestRepaymentInterestPeriod period) { + this(period.fromDate, period.dueDate, period.rateFactorMinus1, period.disbursedAmount, period.interestDue); + } + + @Override + public int compareTo(@NotNull ProgressiveLoanInterestRepaymentInterestPeriod o) { + return dueDate.compareTo(o.dueDate); + } + + public void addDisbursedAmount(final Money outstandingBalance) { + this.disbursedAmount = this.disbursedAmount.add(outstandingBalance); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentModel.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentModel.java new file mode 100644 index 000000000..f39f808f3 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestRepaymentModel.java @@ -0,0 +1,82 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.LinkedList; +import lombok.Data; +import org.apache.fineract.organisation.monetary.domain.Money; + +@Data +public class ProgressiveLoanInterestRepaymentModel { + + private final LocalDate fromDate; + private final LocalDate dueDate; + + private LinkedList interestPeriods; + + private Money equalMonthlyInstallment; + private Money principalDue; + private Money remainingBalance; + + public ProgressiveLoanInterestRepaymentModel(final LocalDate fromDate, final LocalDate dueDate, final Money equalMonthlyInstallment) { + this.fromDate = fromDate; + this.dueDate = dueDate; + this.equalMonthlyInstallment = equalMonthlyInstallment; + + final Money zeroAmount = Money.zero(equalMonthlyInstallment.getCurrency()); + this.remainingBalance = zeroAmount; + this.principalDue = zeroAmount; + this.interestPeriods = new LinkedList<>(); + this.interestPeriods + .add(new ProgressiveLoanInterestRepaymentInterestPeriod(fromDate, dueDate, BigDecimal.ZERO, zeroAmount, zeroAmount)); + } + + public ProgressiveLoanInterestRepaymentModel(ProgressiveLoanInterestRepaymentModel repaymentModel) { + this.fromDate = repaymentModel.fromDate; + this.dueDate = repaymentModel.dueDate; + this.equalMonthlyInstallment = repaymentModel.equalMonthlyInstallment; + this.remainingBalance = repaymentModel.remainingBalance; + this.principalDue = repaymentModel.principalDue; + this.interestPeriods = new LinkedList<>(); + for (final ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod : repaymentModel.interestPeriods) { + this.interestPeriods.add(new ProgressiveLoanInterestRepaymentInterestPeriod(interestPeriod)); + } + } + + public BigDecimal getRateFactor() { + return interestPeriods.stream().map(ProgressiveLoanInterestRepaymentInterestPeriod::getRateFactorMinus1).reduce(BigDecimal.ONE, + BigDecimal::add); + } + + public Money getDisbursedAmountInPeriod() { + return interestPeriods.stream().map(ProgressiveLoanInterestRepaymentInterestPeriod::getDisbursedAmount) + .reduce(Money.zero(equalMonthlyInstallment.getCurrency()), Money::plus); + } + + public Money getInterestDue() { + return interestPeriods.stream().map(ProgressiveLoanInterestRepaymentInterestPeriod::getInterestDue) + .reduce(Money.zero(equalMonthlyInstallment.getCurrency()), Money::plus); + } + + public Money getOutstandingBalance() { + return remainingBalance.plus(principalDue); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestScheduleModel.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestScheduleModel.java new file mode 100644 index 000000000..6755f230d --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/ProgressiveLoanInterestScheduleModel.java @@ -0,0 +1,37 @@ +/** + * 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.data; + +import java.math.MathContext; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; + +public record ProgressiveLoanInterestScheduleModel(List repayments, + LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc) { + + public int getLoanTermInDays() { + if (repayments.isEmpty()) { + return 0; + } + final var firstPeriod = repayments.get(0); + final var lastPeriod = repayments.size() > 1 ? repayments.get(repayments.size() - 1) : firstPeriod; + return Math.toIntExact(ChronoUnit.DAYS.between(firstPeriod.getFromDate(), lastPeriod.getDueDate())); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java deleted file mode 100644 index 54761f4ea..000000000 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractProgressiveLoanScheduleGenerator.java +++ /dev/null @@ -1,620 +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 java.math.BigDecimal; -import java.math.MathContext; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import org.apache.fineract.infrastructure.core.service.DateUtils; -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.workingdays.data.AdjustedDateDetailsDTO; -import org.apache.fineract.portfolio.calendar.domain.CalendarInstance; -import org.apache.fineract.portfolio.calendar.service.CalendarUtils; -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.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; -import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams; -import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.MultiDisbursementOutstandingAmoutException; -import org.apache.fineract.portfolio.loanproduct.calc.EMICalculationResult; -import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; -import org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType; - -public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanScheduleGenerator { - - @Override - public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTerms loanApplicationTerms, - final Set loanCharges, final HolidayDetailDTO holidayDetailDTO) { - // TODO: handle interest calculation - final ApplicationCurrency applicationCurrency = loanApplicationTerms.getApplicationCurrency(); - // generate list of proposed schedule due dates - LocalDate loanEndDate = getScheduledDateGenerator().getLastRepaymentDate(loanApplicationTerms, holidayDetailDTO); - LoanTermVariationsData lastDueDateVariation = loanApplicationTerms.getLoanTermVariations() - .fetchLoanTermDueDateVariationsData(loanEndDate); - if (lastDueDateVariation != null) { - loanEndDate = lastDueDateVariation.getDateValue(); - } - loanApplicationTerms.updateLoanEndDate(loanEndDate); - - // determine the total charges due at time of disbursement - final BigDecimal chargesDueAtTimeOfDisbursement = deriveTotalChargesDueAtTimeOfDisbursement(loanCharges); - - // setup variables for tracking important facts required for loan - // schedule generation. - - final MonetaryCurrency currency = loanApplicationTerms.getCurrency(); - LocalDate periodStartDate = RepaymentStartDateType.DISBURSEMENT_DATE.equals(loanApplicationTerms.getRepaymentStartDateType()) - ? loanApplicationTerms.getExpectedDisbursementDate() - : loanApplicationTerms.getSubmittedOnDate(); - - LoanScheduleParams scheduleParams = LoanScheduleParams.createLoanScheduleParams(currency, - Money.of(currency, chargesDueAtTimeOfDisbursement), periodStartDate, Money.zero(currency)); - - List periods = createNewLoanScheduleListWithDisbursementDetails(loanApplicationTerms, scheduleParams, - chargesDueAtTimeOfDisbursement); - - EMICalculationResult emiCalculationResult = null; - boolean isFirstRepayment = true; - - // charges which depends on total loan interest will be added to this - // set and handled separately after all installments generated - final Set nonCompoundingCharges = separateTotalCompoundingPercentageCharges(loanCharges); - boolean isNextRepaymentAvailable = true; - - boolean thereIsDisbursementBeforeOrOnLoanEndDate = scheduleParams.getDisburseDetailMap().entrySet().stream() - .anyMatch(d -> !d.getKey().isAfter(loanApplicationTerms.getLoanEndDate())); - while (!scheduleParams.getOutstandingBalance().isZero() || thereIsDisbursementBeforeOrOnLoanEndDate) { - scheduleParams.setActualRepaymentDate(getScheduledDateGenerator().generateNextRepaymentDate( - scheduleParams.getActualRepaymentDate(), loanApplicationTerms, isFirstRepayment, scheduleParams.getPeriodNumber())); - AdjustedDateDetailsDTO adjustedDateDetailsDTO = getScheduledDateGenerator() - .adjustRepaymentDate(scheduleParams.getActualRepaymentDate(), loanApplicationTerms, holidayDetailDTO); - scheduleParams.setActualRepaymentDate(adjustedDateDetailsDTO.getChangedActualRepaymentDate()); - LocalDate scheduledDueDate = adjustedDateDetailsDTO.getChangedScheduleDate(); - - // Loan Schedule Exceptions that need to be applied for Loan Account - LoanTermVariationParams termVariationParams = applyLoanTermVariations(loanApplicationTerms, scheduleParams, scheduledDueDate); - scheduledDueDate = termVariationParams.scheduledDueDate(); - - // Updates total days in term - scheduleParams - .addLoanTermInDays(Math.toIntExact(ChronoUnit.DAYS.between(scheduleParams.getPeriodStartDate(), scheduledDueDate))); - - ScheduleCurrentPeriodParams currentPeriodParams = new ScheduleCurrentPeriodParams(currency, BigDecimal.ZERO); - final boolean hasAnyProcessedDisbursement = processDisbursements(loanApplicationTerms, chargesDueAtTimeOfDisbursement, - scheduleParams, periods, scheduledDueDate); - if (isFirstRepayment || hasAnyProcessedDisbursement) { - final LocalDate startDate = isFirstRepayment ? loanApplicationTerms.getRepaymentStartDate() - : scheduleParams.getPeriodStartDate(); - List expectedRepaymentPeriods = getScheduledDateGenerator() - .generateRepaymentPeriods(startDate, loanApplicationTerms, holidayDetailDTO); - emiCalculationResult = getEMICalculator().calculateEMIValueAndRateFactors(scheduleParams.getOutstandingBalanceAsPerRest(), - loanApplicationTerms.toLoanProductRelatedDetail(), expectedRepaymentPeriods, scheduleParams.getPeriodNumber(), - loanApplicationTerms.getNumberOfRepayments(), mc); - } - - // 5 determine principal,interest of repayment period - PrincipalInterest principalInterestForThisPeriod = getEMICalculator().calculatePrincipalInterestComponentsForPeriod( - emiCalculationResult, scheduleParams.getOutstandingBalanceAsPerRest(), - loanApplicationTerms.getInstallmentAmountInMultiplesOf(), scheduleParams.getPeriodNumber(), - loanApplicationTerms.getActualNoOfRepaymnets(), mc); - - // update cumulative fields for principal - currentPeriodParams.setPrincipalForThisPeriod(principalInterestForThisPeriod.principal()); - currentPeriodParams.setInterestForThisPeriod(principalInterestForThisPeriod.interest()); - updateOutstandingBalance(scheduleParams, currentPeriodParams); - - if (scheduleParams.getOutstandingBalance().isLessThanZero() || !isNextRepaymentAvailable) { - currentPeriodParams.plusPrincipalForThisPeriod(scheduleParams.getOutstandingBalance()); - scheduleParams.setOutstandingBalance(Money.zero(currency)); - } - - if (!isNextRepaymentAvailable) { - scheduleParams.getDisburseDetailMap().clear(); - } - - // applies charges for the period - applyChargesForCurrentPeriod(loanCharges, currency, scheduleParams, scheduledDueDate, currentPeriodParams, mc); - - // sum up real totalInstallmentDue from components - final Money totalInstallmentDue = currentPeriodParams.fetchTotalAmountForPeriod(); - - // if previous installment is last then add interest to same - // installment - if (currentPeriodParams.getLastInstallment() != null && currentPeriodParams.getPrincipalForThisPeriod().isZero()) { - currentPeriodParams.getLastInstallment().addInterestAmount(currentPeriodParams.getInterestForThisPeriod()); - continue; - } - - // create repayment period from parts - LoanScheduleModelPeriod installment = LoanScheduleModelRepaymentPeriod.repayment(scheduleParams.getInstalmentNumber(), - scheduleParams.getPeriodStartDate(), scheduledDueDate, currentPeriodParams.getPrincipalForThisPeriod(), - scheduleParams.getOutstandingBalance(), currentPeriodParams.getInterestForThisPeriod(), - currentPeriodParams.getFeeChargesForInstallment(), currentPeriodParams.getPenaltyChargesForInstallment(), - totalInstallmentDue, false); - if (principalInterestForThisPeriod.getRescheduleInterestPortion() != null) { - installment.setRescheduleInterestPortion(principalInterestForThisPeriod.getRescheduleInterestPortion().getAmount()); - } - addLoanRepaymentScheduleInstallment(scheduleParams.getInstallments(), installment); - - if (loanApplicationTerms.getCurrentPeriodFixedEmiAmount() != null) { - installment.setEMIFixedSpecificToInstallmentTrue(); - } - - periods.add(installment); - - // Updates principal paid map with effective date for reducing - // the amount from outstanding balance(interest calculation) - updateAmountsWithEffectiveDate(loanApplicationTerms, holidayDetailDTO, scheduleParams, scheduledDueDate, currentPeriodParams, - installment); - - // handle cumulative fields - - scheduleParams.addTotalCumulativePrincipal(currentPeriodParams.getPrincipalForThisPeriod()); - scheduleParams.addTotalRepaymentExpected(totalInstallmentDue); - scheduleParams.addTotalCumulativeInterest(currentPeriodParams.getInterestForThisPeriod()); - scheduleParams.setPeriodStartDate(scheduledDueDate); - scheduleParams.incrementInstalmentNumber(); - scheduleParams.incrementPeriodNumber(); - // if (termVariationParams.isRecalculateAmounts()) { - // loanApplicationTerms.setCurrentPeriodFixedEmiAmount(null); - // loanApplicationTerms.setCurrentPeriodFixedPrincipalAmount(null); - // adjustInstallmentOrPrincipalAmount(loanApplicationTerms, scheduleParams.getTotalCumulativePrincipal(), - // scheduleParams.getPeriodNumber(), mc); - // } - thereIsDisbursementBeforeOrOnLoanEndDate = scheduleParams.getDisburseDetailMap().entrySet().stream() - .anyMatch(d -> !d.getKey().isAfter(loanApplicationTerms.getLoanEndDate())); - isFirstRepayment = false; - } - - // If the disbursement happened after maturity date - if (loanApplicationTerms.isMultiDisburseLoan()) { - processDisbursements(loanApplicationTerms, chargesDueAtTimeOfDisbursement, scheduleParams, periods, - DateUtils.getBusinessLocalDate().plusDays(1)); - } - - // determine fees and penalties for charges which depends on total - // loan interest - updatePeriodsWithCharges(currency, scheduleParams, periods, nonCompoundingCharges, mc); - - final BigDecimal totalPrincipalPaid = BigDecimal.ZERO; - final BigDecimal totalOutstanding = BigDecimal.ZERO; - - return LoanScheduleModel.from(periods, applicationCurrency, scheduleParams.getLoanTermInDays(), - scheduleParams.getPrincipalToBeScheduled().plus(loanApplicationTerms.getDownPaymentAmount()), - scheduleParams.getTotalCumulativePrincipal().plus(loanApplicationTerms.getDownPaymentAmount()).getAmount(), - totalPrincipalPaid, scheduleParams.getTotalCumulativeInterest().getAmount(), - scheduleParams.getTotalFeeChargesCharged().getAmount(), scheduleParams.getTotalPenaltyChargesCharged().getAmount(), - scheduleParams.getTotalRepaymentExpected().getAmount(), totalOutstanding); - } - - @Override - public LoanScheduleDTO rescheduleNextInstallments(MathContext mc, LoanApplicationTerms loanApplicationTerms, Loan loan, - HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor, - LocalDate rescheduleFrom) { - - LoanScheduleModel model = generate(mc, loanApplicationTerms, loan.getActiveCharges(), holidayDetailDTO); - return LoanScheduleDTO.from(null, model); - } - - @Override - public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, - LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO, - LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) { - return null; - } - - // Abstract methods - public abstract ScheduledDateGenerator getScheduledDateGenerator(); - - public abstract PaymentPeriodsInOneYearCalculator getPaymentPeriodsInOneYearCalculator(); - - protected abstract EMICalculator getEMICalculator(); - - // Private, internal methods - private BigDecimal deriveTotalChargesDueAtTimeOfDisbursement(final Set loanCharges) { - BigDecimal chargesDueAtTimeOfDisbursement = BigDecimal.ZERO; - for (final LoanCharge loanCharge : loanCharges) { - if (loanCharge.isDueAtDisbursement()) { - chargesDueAtTimeOfDisbursement = chargesDueAtTimeOfDisbursement.add(loanCharge.amount()); - } - } - return chargesDueAtTimeOfDisbursement; - } - - private List createNewLoanScheduleListWithDisbursementDetails(final LoanApplicationTerms loanApplicationTerms, - final LoanScheduleParams loanScheduleParams, final BigDecimal chargesDueAtTimeOfDisbursement) { - List periods = new ArrayList<>(); - - // In case of `disallowExpectedDisbursementDetails = true`, anyway at least 1 disbursement details must exist - if (loanApplicationTerms.getDisbursementDatas().isEmpty()) { - loanApplicationTerms.getDisbursementDatas() - .add(new DisbursementData(1L, loanApplicationTerms.getExpectedDisbursementDate(), - loanApplicationTerms.getExpectedDisbursementDate(), loanApplicationTerms.getPrincipal().getAmount(), null, null, - null, null)); - } - for (DisbursementData disbursementData : loanApplicationTerms.getDisbursementDatas()) { - Money principalMoney = Money.of(loanApplicationTerms.getCurrency(), disbursementData.getPrincipal()); - if (disbursementData.disbursementDate().equals(loanScheduleParams.getPeriodStartDate())) { - final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod.disbursement( - disbursementData.disbursementDate(), Money.of(loanScheduleParams.getCurrency(), disbursementData.getPrincipal()), - chargesDueAtTimeOfDisbursement); - periods.add(disbursementPeriod); - loanScheduleParams.addOutstandingBalance(principalMoney); - loanScheduleParams.addOutstandingBalanceAsPerRest(principalMoney); - loanScheduleParams.addPrincipalToBeScheduled(principalMoney); - loanApplicationTerms.resetFixedEmiAmount(); - if (loanApplicationTerms.isDownPaymentEnabled()) { - final LoanScheduleModelDownPaymentPeriod downPaymentPeriod = createDownPaymentPeriod(loanApplicationTerms, - loanScheduleParams, loanApplicationTerms.getExpectedDisbursementDate(), disbursementData.getPrincipal()); - periods.add(downPaymentPeriod); - } - } else { - Money disbursedAmount = loanScheduleParams.getDisburseDetailMap().getOrDefault(disbursementData.disbursementDate(), - Money.zero(loanApplicationTerms.getCurrency())); - loanScheduleParams.getDisburseDetailMap().put(disbursementData.disbursementDate(), disbursedAmount.add(principalMoney)); - } - } - - return periods; - } - - private LoanScheduleModelDownPaymentPeriod createDownPaymentPeriod(LoanApplicationTerms loanApplicationTerms, - LoanScheduleParams scheduleParams, LocalDate date, BigDecimal periodBaseAmount) { - Money downPaymentAmount = Money.of(loanApplicationTerms.getCurrency(), - MathUtil.percentageOf(periodBaseAmount, loanApplicationTerms.getDisbursedAmountPercentageForDownPayment(), 19)); - if (loanApplicationTerms.getInstallmentAmountInMultiplesOf() != null) { - downPaymentAmount = Money.roundToMultiplesOf(downPaymentAmount, loanApplicationTerms.getInstallmentAmountInMultiplesOf()); - } - LoanScheduleModelDownPaymentPeriod installment = LoanScheduleModelDownPaymentPeriod.downPayment( - scheduleParams.getInstalmentNumber(), date, downPaymentAmount, - scheduleParams.getOutstandingBalance().minus(downPaymentAmount)); - - addLoanRepaymentScheduleInstallment(scheduleParams.getInstallments(), installment); - - scheduleParams.incrementInstalmentNumber(); - scheduleParams.addTotalRepaymentExpected(downPaymentAmount); - scheduleParams.reduceOutstandingBalance(downPaymentAmount); - scheduleParams.reduceOutstandingBalanceAsPerRest(downPaymentAmount); - scheduleParams.setPrincipalToBeScheduled(scheduleParams.getPrincipalToBeScheduled().minus(downPaymentAmount)); - loanApplicationTerms.setPrincipal(loanApplicationTerms.getPrincipal().minus(downPaymentAmount)); - loanApplicationTerms.resetFixedEmiAmount(); - return installment; - } - - private LoanRepaymentScheduleInstallment addLoanRepaymentScheduleInstallment(final List installments, - final LoanScheduleModelPeriod scheduledLoanInstallment) { - LoanRepaymentScheduleInstallment installment = null; - if (scheduledLoanInstallment.isRepaymentPeriod() || scheduledLoanInstallment.isDownPaymentPeriod()) { - installment = new LoanRepaymentScheduleInstallment(null, scheduledLoanInstallment.periodNumber(), - scheduledLoanInstallment.periodFromDate(), scheduledLoanInstallment.periodDueDate(), - scheduledLoanInstallment.principalDue(), scheduledLoanInstallment.interestDue(), - scheduledLoanInstallment.feeChargesDue(), scheduledLoanInstallment.penaltyChargesDue(), - scheduledLoanInstallment.isRecalculatedInterestComponent(), scheduledLoanInstallment.getLoanCompoundingDetails(), - scheduledLoanInstallment.rescheduleInterestPortion(), scheduledLoanInstallment.isDownPaymentPeriod()); - installments.add(installment); - } - return installment; - } - - /** - * Method add extra disbursement periods (if applicable) and update the schedule params - */ - private boolean processDisbursements(final LoanApplicationTerms loanApplicationTerms, final BigDecimal chargesDueAtTimeOfDisbursement, - LoanScheduleParams scheduleParams, final Collection periods, final LocalDate scheduledDueDate) { - Iterator> iter = scheduleParams.getDisburseDetailMap().entrySet().iterator(); - boolean hasProcessedDisbursement = false; - while (iter.hasNext()) { - Map.Entry disburseDetail = iter.next(); - if ((disburseDetail.getKey().isEqual(scheduleParams.getPeriodStartDate()) - || disburseDetail.getKey().isAfter(scheduleParams.getPeriodStartDate())) - && disburseDetail.getKey().isBefore(scheduledDueDate)) { - // validation check for amount not exceeds specified max - // amount as per the configuration - if (loanApplicationTerms.isMultiDisburseLoan() && loanApplicationTerms.getMaxOutstandingBalance() != null) { - Money maxOutstandingBalance = loanApplicationTerms.getMaxOutstandingBalanceMoney(); - if (scheduleParams.getOutstandingBalance().plus(disburseDetail.getValue()).isGreaterThan(maxOutstandingBalance)) { - String errorMsg = "Outstanding balance must not exceed the amount: " + maxOutstandingBalance; - throw new MultiDisbursementOutstandingAmoutException(errorMsg, loanApplicationTerms.getMaxOutstandingBalance(), - disburseDetail.getValue()); - } - } - - // creates and add disbursement detail to the repayments - // period - final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod - .disbursement(disburseDetail.getKey(), disburseDetail.getValue(), chargesDueAtTimeOfDisbursement); - periods.add(disbursementPeriod); - - BigDecimal downPaymentAmt = BigDecimal.ZERO; - // updates actual outstanding balance with new - // disbursement detail - scheduleParams.addOutstandingBalance(disburseDetail.getValue()); - scheduleParams.addOutstandingBalanceAsPerRest(disburseDetail.getValue()); - scheduleParams.addPrincipalToBeScheduled(disburseDetail.getValue()); - loanApplicationTerms.setPrincipal(loanApplicationTerms.getPrincipal().plus(disburseDetail.getValue())); - loanApplicationTerms.resetFixedEmiAmount(); - if (loanApplicationTerms.isDownPaymentEnabled()) { - // get list of disbursements done on same day and create down payment periods - List disbursementsOnSameDate = loanApplicationTerms.getDisbursementDatas().stream() - .filter(disbursementData -> DateUtils.isEqual(disbursementData.disbursementDate(), disburseDetail.getKey())) - .toList(); - for (DisbursementData disbursementData : disbursementsOnSameDate) { - final LoanScheduleModelDownPaymentPeriod downPaymentPeriod = createDownPaymentPeriod(loanApplicationTerms, - scheduleParams, disbursementData.disbursementDate(), disbursementData.getPrincipal()); - periods.add(downPaymentPeriod); - downPaymentAmt = downPaymentAmt.add(downPaymentPeriod.principalDue()); - } - } - iter.remove(); - hasProcessedDisbursement = true; - } - } - return hasProcessedDisbursement; - } - - private void applyChargesForCurrentPeriod(final Set loanCharges, final MonetaryCurrency currency, - LoanScheduleParams scheduleParams, LocalDate scheduledDueDate, ScheduleCurrentPeriodParams currentPeriodParams, - final MathContext mc) { - PrincipalInterest principalInterest = new PrincipalInterest(currentPeriodParams.getPrincipalForThisPeriod(), - currentPeriodParams.getInterestForThisPeriod(), null); - currentPeriodParams.setFeeChargesForInstallment(cumulativeFeeChargesDueWithin(scheduleParams.getPeriodStartDate(), scheduledDueDate, - loanCharges, currency, principalInterest, scheduleParams.getPrincipalToBeScheduled(), - scheduleParams.getTotalCumulativeInterest(), true, scheduleParams.isFirstPeriod(), mc)); - currentPeriodParams.setPenaltyChargesForInstallment(cumulativePenaltyChargesDueWithin(scheduleParams.getPeriodStartDate(), - scheduledDueDate, loanCharges, currency, principalInterest, scheduleParams.getPrincipalToBeScheduled(), - scheduleParams.getTotalCumulativeInterest(), true, scheduleParams.isFirstPeriod(), mc)); - scheduleParams.addTotalFeeChargesCharged(currentPeriodParams.getFeeChargesForInstallment()); - scheduleParams.addTotalPenaltyChargesCharged(currentPeriodParams.getPenaltyChargesForInstallment()); - } - - private Money cumulativeFeeChargesDueWithin(final LocalDate periodStart, final LocalDate periodEnd, final Set loanCharges, - final MonetaryCurrency monetaryCurrency, final PrincipalInterest principalInterestForThisPeriod, final Money principalDisbursed, - final Money totalInterestChargedForFullLoanTerm, boolean isInstallmentChargeApplicable, final boolean isFirstPeriod, - final MathContext mc) { - Money cumulative = Money.zero(monetaryCurrency); - for (final LoanCharge loanCharge : loanCharges) { - if (!loanCharge.isDueAtDisbursement() && loanCharge.isFeeCharge()) { - cumulative = getCumulativeAmountOfCharge(periodStart, periodEnd, principalInterestForThisPeriod, principalDisbursed, - totalInterestChargedForFullLoanTerm, isInstallmentChargeApplicable, isFirstPeriod, loanCharge, cumulative, mc); - } - } - return cumulative; - } - - private Money getCumulativeAmountOfCharge(LocalDate periodStart, LocalDate periodEnd, PrincipalInterest principalInterestForThisPeriod, - Money principalDisbursed, Money totalInterestChargedForFullLoanTerm, boolean isInstallmentChargeApplicable, - boolean isFirstPeriod, LoanCharge loanCharge, Money cumulative, MathContext mc) { - boolean isDue = isFirstPeriod ? loanCharge.isDueForCollectionFromIncludingAndUpToAndIncluding(periodStart, periodEnd) - : loanCharge.isDueForCollectionFromAndUpToAndIncluding(periodStart, periodEnd); - if (loanCharge.isInstalmentFee() && isInstallmentChargeApplicable) { - cumulative = calculateInstallmentCharge(principalInterestForThisPeriod, cumulative, loanCharge, mc); - } else if (loanCharge.isOverdueInstallmentCharge() && isDue && loanCharge.getChargeCalculation().isPercentageBased()) { - cumulative = cumulative.plus(loanCharge.chargeAmount()); - } else if (isDue && loanCharge.getChargeCalculation().isPercentageBased()) { - cumulative = calculateSpecificDueDateChargeWithPercentage(principalDisbursed, totalInterestChargedForFullLoanTerm, cumulative, - loanCharge, mc); - } else if (isDue) { - cumulative = cumulative.plus(loanCharge.amount()); - } - return cumulative; - } - - private Money cumulativePenaltyChargesDueWithin(final LocalDate periodStart, final LocalDate periodEnd, - final Set loanCharges, final MonetaryCurrency monetaryCurrency, - final PrincipalInterest principalInterestForThisPeriod, final Money principalDisbursed, - final Money totalInterestChargedForFullLoanTerm, boolean isInstallmentChargeApplicable, final boolean isFirstPeriod, - final MathContext mc) { - Money cumulative = Money.zero(monetaryCurrency); - for (final LoanCharge loanCharge : loanCharges) { - if (loanCharge.isPenaltyCharge()) { - cumulative = getCumulativeAmountOfCharge(periodStart, periodEnd, principalInterestForThisPeriod, principalDisbursed, - totalInterestChargedForFullLoanTerm, isInstallmentChargeApplicable, isFirstPeriod, loanCharge, cumulative, mc); - } - } - return cumulative; - } - - private Money calculateInstallmentCharge(final PrincipalInterest principalInterestForThisPeriod, Money cumulative, - final LoanCharge loanCharge, final MathContext mc) { - if (loanCharge.getChargeCalculation().isPercentageBased()) { - BigDecimal amount = BigDecimal.ZERO; - if (loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest()) { - amount = amount.add(principalInterestForThisPeriod.principal().getAmount()) - .add(principalInterestForThisPeriod.interest().getAmount()); - } else if (loanCharge.getChargeCalculation().isPercentageOfInterest()) { - amount = amount.add(principalInterestForThisPeriod.interest().getAmount()); - } else { - amount = amount.add(principalInterestForThisPeriod.principal().getAmount()); - } - BigDecimal loanChargeAmt = amount.multiply(loanCharge.getPercentage()).divide(BigDecimal.valueOf(100), mc); - cumulative = cumulative.plus(loanChargeAmt); - } else { - cumulative = cumulative.plus(loanCharge.amountOrPercentage()); - } - return cumulative; - } - - private Money calculateSpecificDueDateChargeWithPercentage(final Money principalDisbursed, - final Money totalInterestChargedForFullLoanTerm, Money cumulative, final LoanCharge loanCharge, final MathContext mc) { - BigDecimal amount = BigDecimal.ZERO; - if (loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest()) { - amount = amount.add(principalDisbursed.getAmount()).add(totalInterestChargedForFullLoanTerm.getAmount()); - } else if (loanCharge.getChargeCalculation().isPercentageOfInterest()) { - amount = amount.add(totalInterestChargedForFullLoanTerm.getAmount()); - } else { - amount = amount.add(principalDisbursed.getAmount()); - } - BigDecimal loanChargeAmt = amount.multiply(loanCharge.getPercentage()).divide(BigDecimal.valueOf(100), mc); - cumulative = cumulative.plus(loanChargeAmt); - return cumulative; - } - - private void updateAmountsWithEffectiveDate(final LoanApplicationTerms loanApplicationTerms, final HolidayDetailDTO holidayDetailDTO, - LoanScheduleParams scheduleParams, LocalDate scheduledDueDate, ScheduleCurrentPeriodParams currentPeriodParams, - LoanScheduleModelPeriod installment) { - LocalDate amountApplicableDate = installment.periodDueDate(); - if (loanApplicationTerms.isInterestRecalculationEnabled()) { - amountApplicableDate = getNextRestScheduleDate(installment.periodDueDate().minusDays(1), loanApplicationTerms, - holidayDetailDTO); - } - updateMapWithAmount(scheduleParams.getPrincipalPortionMap(), - currentPeriodParams.getPrincipalForThisPeriod().minus(currentPeriodParams.getReducedBalance()), amountApplicableDate); - - // update outstanding balance for interest calculation - updateOutstandingBalanceAsPerRest(scheduleParams, scheduledDueDate); - } - - private LocalDate getNextRestScheduleDate(LocalDate startDate, LoanApplicationTerms loanApplicationTerms, - final HolidayDetailDTO holidayDetailDTO) { - LocalDate nextScheduleDate; - if (loanApplicationTerms.getRecalculationFrequencyType().isSameAsRepayment()) { - nextScheduleDate = getScheduledDateGenerator().generateNextScheduleDateStartingFromDisburseDateOrRescheduleDate(startDate, - loanApplicationTerms, holidayDetailDTO); - } else { - CalendarInstance calendarInstance = loanApplicationTerms.getRestCalendarInstance(); - nextScheduleDate = CalendarUtils.getNextScheduleDate(calendarInstance.getCalendar(), startDate); - } - - return nextScheduleDate; - } - - private void updateMapWithAmount(final Map map, final Money amount, final LocalDate amountApplicableDate) { - Money principalPaid = amount; - if (map.containsKey(amountApplicableDate)) { - principalPaid = map.get(amountApplicableDate).plus(principalPaid); - } - map.put(amountApplicableDate, principalPaid); - - } - - private void updateOutstandingBalanceAsPerRest(final LoanScheduleParams scheduleParams, final LocalDate scheduledDueDate) { - scheduleParams.setOutstandingBalanceAsPerRest(updateBalanceForInterestCalculation(scheduleParams.getPrincipalPortionMap(), - scheduledDueDate, scheduleParams.getOutstandingBalanceAsPerRest(), false)); - } - - /** - * Identifies all the past date principal changes and apply them on outstanding balance for future calculations - */ - private Money updateBalanceForInterestCalculation(final Map principalPortionMap, final LocalDate scheduledDueDate, - final Money outstandingBalanceAsPerRest, boolean addMapDetails) { - List removeFromPrincipalPortionMap = new ArrayList<>(); - Money outstandingBalance = outstandingBalanceAsPerRest; - for (Map.Entry principal : principalPortionMap.entrySet()) { - if (!principal.getKey().isAfter(scheduledDueDate)) { - if (addMapDetails) { - outstandingBalance = outstandingBalance.plus(principal.getValue()); - } else { - outstandingBalance = outstandingBalance.minus(principal.getValue()); - } - removeFromPrincipalPortionMap.add(principal.getKey()); - } - } - for (LocalDate date : removeFromPrincipalPortionMap) { - principalPortionMap.remove(date); - } - return outstandingBalance; - } - - private void updatePeriodsWithCharges(final MonetaryCurrency currency, LoanScheduleParams scheduleParams, - final Collection periods, final Set nonCompoundingCharges, MathContext mc) { - for (LoanScheduleModelPeriod loanScheduleModelPeriod : periods) { - if (loanScheduleModelPeriod.isRepaymentPeriod()) { - PrincipalInterest principalInterest = new PrincipalInterest(Money.of(currency, loanScheduleModelPeriod.principalDue()), - Money.of(currency, loanScheduleModelPeriod.interestDue()), null); - Money feeChargesForInstallment = cumulativeFeeChargesDueWithin(loanScheduleModelPeriod.periodFromDate(), - loanScheduleModelPeriod.periodDueDate(), nonCompoundingCharges, currency, principalInterest, - scheduleParams.getPrincipalToBeScheduled(), scheduleParams.getTotalCumulativeInterest(), - !loanScheduleModelPeriod.isRecalculatedInterestComponent(), scheduleParams.isFirstPeriod(), mc); - Money penaltyChargesForInstallment = cumulativePenaltyChargesDueWithin(loanScheduleModelPeriod.periodFromDate(), - loanScheduleModelPeriod.periodDueDate(), nonCompoundingCharges, currency, principalInterest, - scheduleParams.getPrincipalToBeScheduled(), scheduleParams.getTotalCumulativeInterest(), - !loanScheduleModelPeriod.isRecalculatedInterestComponent(), scheduleParams.isFirstPeriod(), mc); - scheduleParams.addTotalFeeChargesCharged(feeChargesForInstallment); - scheduleParams.addTotalPenaltyChargesCharged(penaltyChargesForInstallment); - scheduleParams.addTotalRepaymentExpected(feeChargesForInstallment.plus(penaltyChargesForInstallment)); - loanScheduleModelPeriod.addLoanCharges(feeChargesForInstallment.getAmount(), penaltyChargesForInstallment.getAmount()); - } - } - } - - private Set separateTotalCompoundingPercentageCharges(final Set loanCharges) { - Set interestCharges = new HashSet<>(); - for (final LoanCharge loanCharge : loanCharges) { - if (loanCharge.isSpecifiedDueDate() && (loanCharge.getChargeCalculation().isPercentageOfInterest() - || loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest())) { - interestCharges.add(loanCharge); - } - } - loanCharges.removeAll(interestCharges); - return interestCharges; - } - - private void updateOutstandingBalance(LoanScheduleParams scheduleParams, ScheduleCurrentPeriodParams currentPeriodParams) { - // update outstandingLoanBlance using current period - // 'principalDue' - scheduleParams - .reduceOutstandingBalance(currentPeriodParams.getPrincipalForThisPeriod().minus(currentPeriodParams.getReducedBalance())); - } - - /** - * @param loanApplicationTerms - * @param scheduleParams - * @param scheduledDueDate - * @return - */ - private LoanTermVariationParams applyLoanTermVariations(final LoanApplicationTerms loanApplicationTerms, - final LoanScheduleParams scheduleParams, final LocalDate scheduledDueDate) { - boolean skipPeriod = false; - boolean recalculateAmounts = false; - LocalDate modifiedScheduledDueDate = scheduledDueDate; - ArrayList variationsData = null; - - // due date changes should be applied only for that dueDate - if (loanApplicationTerms.getLoanTermVariations().hasDueDateVariation(scheduledDueDate)) { - LoanTermVariationsData loanTermVariationsData = loanApplicationTerms.getLoanTermVariations().nextDueDateVariation(); - if (DateUtils.isEqual(modifiedScheduledDueDate, loanTermVariationsData.getTermVariationApplicableFrom())) { - modifiedScheduledDueDate = loanTermVariationsData.getDateValue(); - loanApplicationTerms.updateVariationDays(DateUtils.getDifferenceInDays(scheduledDueDate, modifiedScheduledDueDate)); - if (!loanTermVariationsData.isSpecificToInstallment()) { - scheduleParams.setActualRepaymentDate(modifiedScheduledDueDate); - loanApplicationTerms.setNewScheduledDueDateStart(modifiedScheduledDueDate); - } - loanTermVariationsData.setProcessed(true); - } - } - - return new LoanTermVariationParams(skipPeriod, recalculateAmounts, modifiedScheduledDueDate, variationsData); - } -} 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 4bafc7561..a53e0b4b7 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,30 +18,358 @@ */ package org.apache.fineract.portfolio.loanaccount.loanschedule.domain; +import java.math.BigDecimal; +import java.math.MathContext; +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 lombok.RequiredArgsConstructor; +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.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.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; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams; +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.exception.MultiDisbursementOutstandingAmoutException; import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; +import org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor -public class ProgressiveLoanScheduleGenerator extends AbstractProgressiveLoanScheduleGenerator { +public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator { private final ScheduledDateGenerator scheduledDateGenerator; - private final PaymentPeriodsInOneYearCalculator paymentPeriodsInOneYearCalculator; private final EMICalculator emiCalculator; @Override - public ScheduledDateGenerator getScheduledDateGenerator() { - return scheduledDateGenerator; + public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTerms loanApplicationTerms, + final Set loanCharges, final HolidayDetailDTO holidayDetailDTO) { + + final ApplicationCurrency applicationCurrency = loanApplicationTerms.getApplicationCurrency(); + // generate list of proposed schedule due dates + LocalDate loanEndDate = scheduledDateGenerator.getLastRepaymentDate(loanApplicationTerms, holidayDetailDTO); + LoanTermVariationsData lastDueDateVariation = loanApplicationTerms.getLoanTermVariations() + .fetchLoanTermDueDateVariationsData(loanEndDate); + if (lastDueDateVariation != null) { + loanEndDate = lastDueDateVariation.getDateValue(); + } + loanApplicationTerms.updateLoanEndDate(loanEndDate); + + // determine the total charges due at time of disbursement + final BigDecimal chargesDueAtTimeOfDisbursement = deriveTotalChargesDueAtTimeOfDisbursement(loanCharges); + + // setup variables for tracking important facts required for loan + // schedule generation. + + final MonetaryCurrency currency = loanApplicationTerms.getCurrency(); + LocalDate periodStartDate = RepaymentStartDateType.DISBURSEMENT_DATE.equals(loanApplicationTerms.getRepaymentStartDateType()) + ? loanApplicationTerms.getExpectedDisbursementDate() + : loanApplicationTerms.getSubmittedOnDate(); + + final LoanScheduleParams scheduleParams = LoanScheduleParams.createLoanScheduleParams(currency, + Money.of(currency, chargesDueAtTimeOfDisbursement), periodStartDate, Money.zero(currency)); + + // charges which depends on total loan interest will be added to this + // set and handled separately after all installments generated + final Set nonCompoundingCharges = separateTotalCompoundingPercentageCharges(loanCharges); + + final List expectedRepaymentPeriods = scheduledDateGenerator + .generateRepaymentPeriods(periodStartDate, loanApplicationTerms, holidayDetailDTO); + final ProgressiveLoanInterestScheduleModel interestScheduleModel = emiCalculator.generateInterestScheduleModel( + expectedRepaymentPeriods, loanApplicationTerms.toLoanProductRelatedDetail(), + loanApplicationTerms.getInstallmentAmountInMultiplesOf(), mc); + final List periods = new ArrayList<>(expectedRepaymentPeriods.size() + 2); + + prepareDisbursementsOnLoanApplicationTerms(loanApplicationTerms); + + for (LoanScheduleModelRepaymentPeriod repaymentPeriod : expectedRepaymentPeriods) { + scheduleParams.setPeriodStartDate(repaymentPeriod.getFromDate()); + scheduleParams.setActualRepaymentDate(repaymentPeriod.getDueDate()); + + processDisbursements(loanApplicationTerms, scheduleParams, interestScheduleModel, periods, chargesDueAtTimeOfDisbursement); + repaymentPeriod.setPeriodNumber(scheduleParams.getInstalmentNumber()); + + emiCalculator.findInterestRepaymentPeriod(interestScheduleModel, repaymentPeriod.getDueDate()) + .ifPresent(interestRepaymentPeriod -> { + final Money principalDue = interestRepaymentPeriod.getPrincipalDue(); + final Money interestDue = interestRepaymentPeriod.getInterestDue(); + + repaymentPeriod.addPrincipalAmount(principalDue); + repaymentPeriod.addInterestAmount(interestDue); + repaymentPeriod.setOutstandingLoanBalance(interestRepaymentPeriod.getRemainingBalance()); + + scheduleParams.addTotalCumulativePrincipal(principalDue); + scheduleParams.addTotalCumulativeInterest(interestDue); + // add everything + scheduleParams.addTotalRepaymentExpected(principalDue.plus(interestDue)); + }); + + applyChargesForCurrentPeriod(repaymentPeriod, loanCharges, scheduleParams, currency, mc); + periods.add(repaymentPeriod); + + scheduleParams.incrementInstalmentNumber(); + scheduleParams.incrementPeriodNumber(); + } + + if (loanApplicationTerms.isMultiDisburseLoan()) { + processDisbursements(loanApplicationTerms, scheduleParams, null, periods, chargesDueAtTimeOfDisbursement); + } + + // determine fees and penalties for charges which depends on total + // loan interest + updatePeriodsWithCharges(currency, scheduleParams, periods, nonCompoundingCharges, mc); + + final BigDecimal totalPrincipalPaid = BigDecimal.ZERO; + final BigDecimal totalOutstanding = BigDecimal.ZERO; + + return LoanScheduleModel.from(periods, applicationCurrency, interestScheduleModel.getLoanTermInDays(), + scheduleParams.getPrincipalToBeScheduled().plus(loanApplicationTerms.getDownPaymentAmount()), + scheduleParams.getTotalCumulativePrincipal().plus(loanApplicationTerms.getDownPaymentAmount()).getAmount(), + totalPrincipalPaid, scheduleParams.getTotalCumulativeInterest().getAmount(), + scheduleParams.getTotalFeeChargesCharged().getAmount(), scheduleParams.getTotalPenaltyChargesCharged().getAmount(), + scheduleParams.getTotalRepaymentExpected().getAmount(), totalOutstanding); + } + + private void prepareDisbursementsOnLoanApplicationTerms(final LoanApplicationTerms loanApplicationTerms) { + if (loanApplicationTerms.getDisbursementDatas().isEmpty()) { + loanApplicationTerms.getDisbursementDatas() + .add(new DisbursementData(1L, loanApplicationTerms.getExpectedDisbursementDate(), + loanApplicationTerms.getExpectedDisbursementDate(), loanApplicationTerms.getPrincipal().getAmount(), null, null, + null, null)); + } + } + + private void processDisbursements(final LoanApplicationTerms loanApplicationTerms, final LoanScheduleParams scheduleParams, + final ProgressiveLoanInterestScheduleModel interestScheduleModel, final List periods, + final BigDecimal chargesDueAtTimeOfDisbursement) { + + for (DisbursementData disbursementData : loanApplicationTerms.getDisbursementDatas()) { + final LocalDate disbursementDate = disbursementData.disbursementDate(); + final LocalDate periodFromDate = scheduleParams.getPeriodStartDate(); + final LocalDate periodDueDate = scheduleParams.getActualRepaymentDate(); + + boolean hasDisbursementAfterLastRepaymentPeriod = interestScheduleModel == null && !disbursementDate.isBefore(periodDueDate); + boolean hasDisbursementInCurrentRepaymentPeriod = interestScheduleModel != null && !disbursementDate.isBefore(periodFromDate) + && disbursementDate.isBefore(periodDueDate); + if (!hasDisbursementAfterLastRepaymentPeriod && !hasDisbursementInCurrentRepaymentPeriod) { + continue; + } + + Money outstandingBalance = emiCalculator.findInterestRepaymentPeriod(interestScheduleModel, periodDueDate) + .map(ProgressiveLoanInterestRepaymentModel::getOutstandingBalance) + .orElse(Money.zero(loanApplicationTerms.getCurrency())); + + final Money disbursedAmount = Money.of(loanApplicationTerms.getCurrency(), disbursementData.getPrincipal()); + final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod + .disbursement(disbursementData.disbursementDate(), disbursedAmount, chargesDueAtTimeOfDisbursement); + periods.add(disbursementPeriod); + + // validation check for amount not exceeds specified max + // amount as per the configuration + if (loanApplicationTerms.isMultiDisburseLoan() && loanApplicationTerms.getMaxOutstandingBalance() != null) { + Money maxOutstandingBalance = loanApplicationTerms.getMaxOutstandingBalanceMoney(); + if (outstandingBalance.plus(disbursedAmount).isGreaterThan(maxOutstandingBalance)) { + String errorMsg = "Outstanding balance must not exceed the amount: " + maxOutstandingBalance; + throw new MultiDisbursementOutstandingAmoutException(errorMsg, loanApplicationTerms.getMaxOutstandingBalance(), + disbursedAmount); + } + } + + Money downPaymentAmount = Money.zero(loanApplicationTerms.getCurrency()); + if (loanApplicationTerms.isDownPaymentEnabled()) { + downPaymentAmount = Money.of(loanApplicationTerms.getCurrency(), MathUtil.percentageOf(disbursedAmount.getAmount(), + loanApplicationTerms.getDisbursedAmountPercentageForDownPayment(), 19)); + if (loanApplicationTerms.getInstallmentAmountInMultiplesOf() != null) { + downPaymentAmount = Money.roundToMultiplesOf(downPaymentAmount, + loanApplicationTerms.getInstallmentAmountInMultiplesOf()); + } + + LoanScheduleModelDownPaymentPeriod downPaymentPeriod = LoanScheduleModelDownPaymentPeriod.downPayment( + scheduleParams.getInstalmentNumber(), disbursementDate, downPaymentAmount, + outstandingBalance.plus(disbursedAmount).minus(downPaymentAmount)); + periods.add(downPaymentPeriod); + + scheduleParams.addTotalRepaymentExpected(downPaymentAmount); + scheduleParams.incrementInstalmentNumber(); + } + + final Money disbursementRemainingBalance = disbursedAmount.minus(downPaymentAmount); + scheduleParams.addPrincipalToBeScheduled(disbursementRemainingBalance); + emiCalculator.addDisbursement(interestScheduleModel, disbursementDate, disbursementRemainingBalance); + } } @Override - public PaymentPeriodsInOneYearCalculator getPaymentPeriodsInOneYearCalculator() { - return paymentPeriodsInOneYearCalculator; + public LoanScheduleDTO rescheduleNextInstallments(MathContext mc, LoanApplicationTerms loanApplicationTerms, Loan loan, + HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor, + LocalDate rescheduleFrom) { + LoanScheduleModel model = generate(mc, loanApplicationTerms, loan.getActiveCharges(), holidayDetailDTO); + return LoanScheduleDTO.from(null, model); } @Override - protected EMICalculator getEMICalculator() { - return emiCalculator; + public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, + LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO, + LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) { + return null; } + + // Private, internal methods + private BigDecimal deriveTotalChargesDueAtTimeOfDisbursement(final Set loanCharges) { + BigDecimal chargesDueAtTimeOfDisbursement = BigDecimal.ZERO; + for (final LoanCharge loanCharge : loanCharges) { + if (loanCharge.isDueAtDisbursement()) { + chargesDueAtTimeOfDisbursement = chargesDueAtTimeOfDisbursement.add(loanCharge.amount()); + } + } + return chargesDueAtTimeOfDisbursement; + } + + private void applyChargesForCurrentPeriod(final LoanScheduleModelRepaymentPeriod repaymentPeriod, final Set loanCharges, + final LoanScheduleParams scheduleParams, final MonetaryCurrency currency, final MathContext mc) { + final PrincipalInterest principalInterest = new PrincipalInterest(repaymentPeriod.getPrincipalDue(), + repaymentPeriod.getInterestDue(), null); + + final Money fees = cumulativeFeeChargesDueWithin(repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate(), loanCharges, currency, + principalInterest, scheduleParams.getPrincipalToBeScheduled(), scheduleParams.getTotalCumulativeInterest(), true, + scheduleParams.isFirstPeriod(), mc); + final Money penalties = cumulativePenaltyChargesDueWithin(repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate(), loanCharges, + currency, principalInterest, scheduleParams.getPrincipalToBeScheduled(), scheduleParams.getTotalCumulativeInterest(), true, + scheduleParams.isFirstPeriod(), mc); + + repaymentPeriod.addLoanCharges(fees.getAmount(), penalties.getAmount()); + scheduleParams.addTotalFeeChargesCharged(fees); + scheduleParams.addTotalPenaltyChargesCharged(penalties); + } + + private Money cumulativeFeeChargesDueWithin(final LocalDate periodStart, final LocalDate periodEnd, final Set loanCharges, + final MonetaryCurrency monetaryCurrency, final PrincipalInterest principalInterestForThisPeriod, final Money principalDisbursed, + final Money totalInterestChargedForFullLoanTerm, boolean isInstallmentChargeApplicable, final boolean isFirstPeriod, + final MathContext mc) { + Money cumulative = Money.zero(monetaryCurrency); + for (final LoanCharge loanCharge : loanCharges) { + if (!loanCharge.isDueAtDisbursement() && loanCharge.isFeeCharge()) { + cumulative = getCumulativeAmountOfCharge(periodStart, periodEnd, principalInterestForThisPeriod, principalDisbursed, + totalInterestChargedForFullLoanTerm, isInstallmentChargeApplicable, isFirstPeriod, loanCharge, cumulative, mc); + } + } + return cumulative; + } + + private Money getCumulativeAmountOfCharge(LocalDate periodStart, LocalDate periodEnd, PrincipalInterest principalInterestForThisPeriod, + Money principalDisbursed, Money totalInterestChargedForFullLoanTerm, boolean isInstallmentChargeApplicable, + boolean isFirstPeriod, LoanCharge loanCharge, Money cumulative, MathContext mc) { + boolean isDue = isFirstPeriod ? loanCharge.isDueForCollectionFromIncludingAndUpToAndIncluding(periodStart, periodEnd) + : loanCharge.isDueForCollectionFromAndUpToAndIncluding(periodStart, periodEnd); + if (loanCharge.isInstalmentFee() && isInstallmentChargeApplicable) { + cumulative = calculateInstallmentCharge(principalInterestForThisPeriod, cumulative, loanCharge, mc); + } else if (loanCharge.isOverdueInstallmentCharge() && isDue && loanCharge.getChargeCalculation().isPercentageBased()) { + cumulative = cumulative.plus(loanCharge.chargeAmount()); + } else if (isDue && loanCharge.getChargeCalculation().isPercentageBased()) { + cumulative = calculateSpecificDueDateChargeWithPercentage(principalDisbursed, totalInterestChargedForFullLoanTerm, cumulative, + loanCharge, mc); + } else if (isDue) { + cumulative = cumulative.plus(loanCharge.amount()); + } + return cumulative; + } + + private Money cumulativePenaltyChargesDueWithin(final LocalDate periodStart, final LocalDate periodEnd, + final Set loanCharges, final MonetaryCurrency monetaryCurrency, + final PrincipalInterest principalInterestForThisPeriod, final Money principalDisbursed, + final Money totalInterestChargedForFullLoanTerm, boolean isInstallmentChargeApplicable, final boolean isFirstPeriod, + final MathContext mc) { + Money cumulative = Money.zero(monetaryCurrency); + for (final LoanCharge loanCharge : loanCharges) { + if (loanCharge.isPenaltyCharge()) { + cumulative = getCumulativeAmountOfCharge(periodStart, periodEnd, principalInterestForThisPeriod, principalDisbursed, + totalInterestChargedForFullLoanTerm, isInstallmentChargeApplicable, isFirstPeriod, loanCharge, cumulative, mc); + } + } + return cumulative; + } + + private Money calculateInstallmentCharge(final PrincipalInterest principalInterestForThisPeriod, Money cumulative, + final LoanCharge loanCharge, final MathContext mc) { + if (loanCharge.getChargeCalculation().isPercentageBased()) { + BigDecimal amount = BigDecimal.ZERO; + if (loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest()) { + amount = amount.add(principalInterestForThisPeriod.principal().getAmount()) + .add(principalInterestForThisPeriod.interest().getAmount()); + } else if (loanCharge.getChargeCalculation().isPercentageOfInterest()) { + amount = amount.add(principalInterestForThisPeriod.interest().getAmount()); + } else { + amount = amount.add(principalInterestForThisPeriod.principal().getAmount()); + } + BigDecimal loanChargeAmt = amount.multiply(loanCharge.getPercentage()).divide(BigDecimal.valueOf(100), mc); + cumulative = cumulative.plus(loanChargeAmt); + } else { + cumulative = cumulative.plus(loanCharge.amountOrPercentage()); + } + return cumulative; + } + + private Money calculateSpecificDueDateChargeWithPercentage(final Money principalDisbursed, + final Money totalInterestChargedForFullLoanTerm, Money cumulative, final LoanCharge loanCharge, final MathContext mc) { + BigDecimal amount = BigDecimal.ZERO; + if (loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest()) { + amount = amount.add(principalDisbursed.getAmount()).add(totalInterestChargedForFullLoanTerm.getAmount()); + } else if (loanCharge.getChargeCalculation().isPercentageOfInterest()) { + amount = amount.add(totalInterestChargedForFullLoanTerm.getAmount()); + } else { + amount = amount.add(principalDisbursed.getAmount()); + } + BigDecimal loanChargeAmt = amount.multiply(loanCharge.getPercentage()).divide(BigDecimal.valueOf(100), mc); + cumulative = cumulative.plus(loanChargeAmt); + return cumulative; + } + + private void updatePeriodsWithCharges(final MonetaryCurrency currency, LoanScheduleParams scheduleParams, + final Collection periods, final Set nonCompoundingCharges, MathContext mc) { + for (LoanScheduleModelPeriod loanScheduleModelPeriod : periods) { + if (loanScheduleModelPeriod.isRepaymentPeriod()) { + PrincipalInterest principalInterest = new PrincipalInterest(Money.of(currency, loanScheduleModelPeriod.principalDue()), + Money.of(currency, loanScheduleModelPeriod.interestDue()), null); + Money feeChargesForInstallment = cumulativeFeeChargesDueWithin(loanScheduleModelPeriod.periodFromDate(), + loanScheduleModelPeriod.periodDueDate(), nonCompoundingCharges, currency, principalInterest, + scheduleParams.getPrincipalToBeScheduled(), scheduleParams.getTotalCumulativeInterest(), + !loanScheduleModelPeriod.isRecalculatedInterestComponent(), scheduleParams.isFirstPeriod(), mc); + Money penaltyChargesForInstallment = cumulativePenaltyChargesDueWithin(loanScheduleModelPeriod.periodFromDate(), + loanScheduleModelPeriod.periodDueDate(), nonCompoundingCharges, currency, principalInterest, + scheduleParams.getPrincipalToBeScheduled(), scheduleParams.getTotalCumulativeInterest(), + !loanScheduleModelPeriod.isRecalculatedInterestComponent(), scheduleParams.isFirstPeriod(), mc); + scheduleParams.addTotalFeeChargesCharged(feeChargesForInstallment); + scheduleParams.addTotalPenaltyChargesCharged(penaltyChargesForInstallment); + scheduleParams.addTotalRepaymentExpected(feeChargesForInstallment.plus(penaltyChargesForInstallment)); + loanScheduleModelPeriod.addLoanCharges(feeChargesForInstallment.getAmount(), penaltyChargesForInstallment.getAmount()); + } + } + } + + private Set separateTotalCompoundingPercentageCharges(final Set loanCharges) { + Set interestCharges = new HashSet<>(); + for (final LoanCharge loanCharge : loanCharges) { + if (loanCharge.isSpecifiedDueDate() && (loanCharge.getChargeCalculation().isPercentageOfInterest() + || loanCharge.getChargeCalculation().isPercentageOfAmountAndInterest())) { + interestCharges.add(loanCharge); + } + } + loanCharges.removeAll(interestCharges); + return interestCharges; + } + } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java index c59e1ef07..f21fe4def 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/EMICalculator.java @@ -19,19 +19,31 @@ package org.apache.fineract.portfolio.loanproduct.calc; import java.math.MathContext; +import java.time.LocalDate; import java.util.List; +import java.util.Optional; import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.PrincipalInterest; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +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.LoanScheduleModelRepaymentPeriod; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; public interface EMICalculator { - EMICalculationResult calculateEMIValueAndRateFactors(Money outstandingBalanceAsPerRest, - LoanProductRelatedDetail loanProductRelatedDetail, List expectedRepaymentPeriods, - Integer actualPeriodNumber, Integer numberOfRepayments, MathContext mc); + ProgressiveLoanInterestScheduleModel generateInterestScheduleModel(List periods, + LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc); - PrincipalInterest calculatePrincipalInterestComponentsForPeriod(EMICalculationResult emiCalculationResult, - Money outstandingBalanceAsPerRest, Integer installmentAmountInMultiplesOf, Integer actualPeriodNumber, - Integer actualNoOfRepayments, MathContext mc); + ProgressiveLoanInterestScheduleModel generateModel(LoanProductRelatedDetail loanProductRelatedDetail, + Integer installmentAmountInMultiplesOf, List repaymentPeriods, MathContext mc); + + Optional findInterestRepaymentPeriod(ProgressiveLoanInterestScheduleModel scheduleModel, + LocalDate dueDate); + + void addDisbursement(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate disbursementDueDate, Money disbursedAmount); + + ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(ProgressiveLoanInterestScheduleModel scheduleModel); + + ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(ProgressiveLoanInterestScheduleModel scheduleModel, + LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index 564cdfe2e..abdf61a7e 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -22,43 +22,208 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; import java.time.Year; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; import java.util.List; -import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.common.domain.DaysInMonthType; import org.apache.fineract.portfolio.common.domain.DaysInYearType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.PrincipalInterest; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestRepaymentInterestPeriod; +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.LoanScheduleModelRepaymentPeriod; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; +import org.apache.fineract.portfolio.loanproduct.mapper.ProgressiveLoanInterestRepaymentModelMapper; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public final class ProgressiveEMICalculator implements EMICalculator { + private final ProgressiveLoanInterestRepaymentModelMapper progressiveLoanInterestRepaymentModelMapper; + private static final BigDecimal DIVISOR_100 = new BigDecimal("100"); private static final BigDecimal ONE_WEEK_IN_DAYS = BigDecimal.valueOf(7); + @Override + public ProgressiveLoanInterestScheduleModel generateInterestScheduleModel(final List periods, + final LoanProductRelatedDetail loanProductRelatedDetail, final Integer installmentAmountInMultiplesOf, final MathContext mc) { + final Money zeroAmount = Money.zero(loanProductRelatedDetail.getCurrency()); + final ArrayList interestRepaymentModelList = new ArrayList<>(periods.size()); + for (final LoanScheduleModelRepaymentPeriod period : periods) { + interestRepaymentModelList + .add(new ProgressiveLoanInterestRepaymentModel(period.periodFromDate(), period.periodDueDate(), zeroAmount)); + } + return new ProgressiveLoanInterestScheduleModel(interestRepaymentModelList, loanProductRelatedDetail, + installmentAmountInMultiplesOf, mc); + } + + @Override + public Optional findInterestRepaymentPeriod( + final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate repaymentPeriodDueDate) { + if (scheduleModel == null || repaymentPeriodDueDate == null) { + return Optional.empty(); + } + return scheduleModel.repayments().stream()// + .filter(interestRepaymentPeriodItem -> interestRepaymentPeriodItem.getDueDate().isEqual(repaymentPeriodDueDate))// + .findFirst(); + } + + Optional findInterestRepaymentPeriodForDisbursement( + final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate disbursementDueDate) { + if (scheduleModel == null || disbursementDueDate == null) { + return Optional.empty(); + } + return scheduleModel.repayments().stream()// + .filter(repaymentPeriod -> !disbursementDueDate.isBefore(repaymentPeriod.getFromDate()) + && disbursementDueDate.isBefore(repaymentPeriod.getDueDate()))// + .findFirst(); + } + + Optional findInterestPeriodForDisbursement( + final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate disbursementDueDate) { + if (repaymentPeriod == null || disbursementDueDate == null) { + return Optional.empty(); + } + return repaymentPeriod.getInterestPeriods().stream()// + .filter(interestPeriod -> disbursementDueDate.isEqual(interestPeriod.getFromDate()))// + .findFirst(); + } + + /** + * Add disbursement to Interest Period + */ + @Override + public void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate disbursementDueDate, + final Money disbursedAmount) { + final ProgressiveLoanInterestRepaymentModel repaymentPeriod = findInterestRepaymentPeriodForDisbursement(scheduleModel, + disbursementDueDate).orElse(null); + if (repaymentPeriod == null) { + return; + } + + var interestPeriodOptional = findInterestPeriodForDisbursement(repaymentPeriod, disbursementDueDate); + if (interestPeriodOptional.isPresent()) { + interestPeriodOptional.get().addDisbursedAmount(disbursedAmount); + } else { + insertInterestPeriod(repaymentPeriod, disbursementDueDate, disbursedAmount); + } + + calculateEMIValueAndRateFactors(repaymentPeriod.getDueDate(), scheduleModel); + } + + void insertInterestPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate disbursementDueDate, + final Money disbursedAmount) { + // disbursementDueDate is after disb.date because this case when disbursement date is different than interest + // period start date + final ProgressiveLoanInterestRepaymentInterestPeriod previousInterestPeriod = repaymentPeriod.getInterestPeriods().stream() + .filter(interestPeriod -> disbursementDueDate.isAfter(interestPeriod.getFromDate()) + && disbursementDueDate.isBefore(interestPeriod.getDueDate()))// + .findFirst()// + .get();// + + final var interestPeriod = new ProgressiveLoanInterestRepaymentInterestPeriod(disbursementDueDate, + previousInterestPeriod.getDueDate(), BigDecimal.ZERO, disbursedAmount, Money.zero(disbursedAmount.getCurrency())); + + previousInterestPeriod.setDueDate(disbursementDueDate); + + repaymentPeriod.getInterestPeriods().add(interestPeriod); + Collections.sort(repaymentPeriod.getInterestPeriods()); + } + + @Override + public ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(final ProgressiveLoanInterestScheduleModel scheduleModel) { + return makeScheduleModelDeepCopy(scheduleModel, scheduleModel.loanProductRelatedDetail(), + scheduleModel.installmentAmountInMultiplesOf(), scheduleModel.mc()); + } + + @Override + public ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(final ProgressiveLoanInterestScheduleModel scheduleModel, + final LoanProductRelatedDetail loanProductRelatedDetail, final Integer installmentAmountInMultiplesOf, final MathContext mc) { + List repayments = new ArrayList<>(scheduleModel.repayments().size()); + for (var repaymentModel : scheduleModel.repayments()) { + repayments.add(new ProgressiveLoanInterestRepaymentModel(repaymentModel)); + } + return new ProgressiveLoanInterestScheduleModel(repayments, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + } + /** * Calculate Equal Monthly Installment value and Rate Factor -1 values for calculate Interest */ - @Override - public EMICalculationResult calculateEMIValueAndRateFactors(final Money outstandingBalanceAsPerRest, - final LoanProductRelatedDetail loanProductRelatedDetail, final List expectedRepaymentPeriods, - final Integer actualPeriodNumber, final Integer numberOfRepayments, final MathContext mc) { - final BigDecimal nominalInterestRatePerPeriod = calcNominalInterestRatePerPeriod( - loanProductRelatedDetail.getNominalInterestRatePerPeriod(), mc); - final DaysInYearType daysInYearType = DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType()); - final DaysInMonthType daysInMonthType = DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType()); - final PeriodFrequencyType repaymentFrequency = loanProductRelatedDetail.getRepaymentPeriodFrequencyType(); - final BigDecimal repaymentEvery = BigDecimal.valueOf(loanProductRelatedDetail.getRepayEvery()); + void calculateEMIValueAndRateFactors(final LocalDate calculateFromRepaymentPeriodDueDate, + final ProgressiveLoanInterestScheduleModel scheduleModel) { + final List relatedRepaymentPeriods = getRelatedRepaymentPeriods( + calculateFromRepaymentPeriodDueDate, scheduleModel); + calculateRateFactorMinus1ForPeriods(relatedRepaymentPeriods, scheduleModel); + calculateEMIOnPeriods(relatedRepaymentPeriods, scheduleModel); + calculatePrincipalInterestComponentsForPeriods(scheduleModel); + checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(scheduleModel, relatedRepaymentPeriods); + } - final List rateFactorList = getRateFactorList(expectedRepaymentPeriods, nominalInterestRatePerPeriod, daysInYearType, - daysInMonthType, repaymentFrequency, repaymentEvery, mc); + private static List getRelatedRepaymentPeriods(LocalDate calculateFromRepaymentPeriodDueDate, + ProgressiveLoanInterestScheduleModel scheduleModel) { + return calculateFromRepaymentPeriodDueDate == null ? scheduleModel.repayments() + : scheduleModel.repayments().stream().filter(period -> !period.getDueDate().isBefore(calculateFromRepaymentPeriodDueDate)) + .toList(); + } - return calculateEMI(rateFactorList, actualPeriodNumber, numberOfRepayments, outstandingBalanceAsPerRest, mc); + private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel, + final List relatedRepaymentPeriods) { + final Money emiDifference = getDifferenceBetweenLastTwoPeriod(relatedRepaymentPeriods, scheduleModel); + final int numberOfRelatedPeriods = relatedRepaymentPeriods.size(); + double lowerHalfOfRelatedPeriods = Math.floor(numberOfRelatedPeriods / 2.0); + if (emiDifference.isZero() || lowerHalfOfRelatedPeriods == 0.0) { + return; + } + final Money originalEmi = relatedRepaymentPeriods.get(numberOfRelatedPeriods - 2).getEqualMonthlyInstallment(); + boolean shouldBeAdjusted = emiDifference.abs().multipliedBy(100) + .isGreaterThan(Money.of(originalEmi.getCurrency(), BigDecimal.valueOf(lowerHalfOfRelatedPeriods))); + + final MathContext mc = scheduleModel.mc(); + if (shouldBeAdjusted) { + Money adjustment = emiDifference.dividedBy(numberOfRelatedPeriods, mc.getRoundingMode()); + Money adjustedEqualMonthlyInstallmentValue = applyInstallmentAmountInMultiplesOf(scheduleModel, originalEmi.plus(adjustment)); + if (adjustedEqualMonthlyInstallmentValue.compareTo(originalEmi) == 0) { + return; + } + final LocalDate relatedPeriodsFirstDueDate = relatedRepaymentPeriods.get(0).getDueDate(); + final var newScheduleModel = makeScheduleModelDeepCopy(scheduleModel); + newScheduleModel.repayments().forEach(period -> { + if (!period.getDueDate().isBefore(relatedPeriodsFirstDueDate)) { + period.setEqualMonthlyInstallment(adjustedEqualMonthlyInstallmentValue); + } + }); + calculatePrincipalInterestComponentsForPeriods(newScheduleModel); + final Money newEmiDifference = getDifferenceBetweenLastTwoPeriod(newScheduleModel.repayments(), scheduleModel); + final boolean newEmiHasLessDifference = newEmiDifference.abs().compareTo(emiDifference.abs()) < 0; + if (!newEmiHasLessDifference) { + return; + } + + final Iterator relatedPeriodFromNewModelIterator = newScheduleModel// + .repayments().stream()// + .filter(period -> !period.getDueDate().isBefore(relatedPeriodsFirstDueDate))// + .toList().iterator();// + + relatedRepaymentPeriods.forEach(relatedRepaymentPeriod -> { + if (!relatedPeriodFromNewModelIterator.hasNext()) { + return; + } + final ProgressiveLoanInterestRepaymentModel newRepaymentPeriod = relatedPeriodFromNewModelIterator.next(); + relatedRepaymentPeriod.setEqualMonthlyInstallment(newRepaymentPeriod.getEqualMonthlyInstallment()); + relatedRepaymentPeriod.setPrincipalDue(newRepaymentPeriod.getInterestDue()); + relatedRepaymentPeriod.setPrincipalDue(newRepaymentPeriod.getPrincipalDue()); + relatedRepaymentPeriod.setRemainingBalance(newRepaymentPeriod.getRemainingBalance()); + relatedRepaymentPeriod.setInterestPeriods(newRepaymentPeriod.getInterestPeriods()); + }); + } } /** @@ -71,83 +236,73 @@ public final class ProgressiveEMICalculator implements EMICalculator { * * @return Rate Interest Rate in fraction format */ - BigDecimal calcNominalInterestRatePerPeriod(final BigDecimal interestRate, MathContext mc) { + BigDecimal calcNominalInterestRatePercentage(final BigDecimal interestRate, MathContext mc) { return MathUtil.nullToZero(interestRate).divide(DIVISOR_100, mc); } /** * * Calculate rate factors from ONLY repayment periods - * - * @param expectedRepaymentPeriods - * @param interestRate - * @param daysInYearType - * @param daysInMonthType - * @param repaymentFrequency - * @param repaymentEvery - * @param mc - * @return */ - List getRateFactorList(final List expectedRepaymentPeriods, - final BigDecimal interestRate, final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType, - final PeriodFrequencyType repaymentFrequency, final BigDecimal repaymentEvery, final MathContext mc) { - return expectedRepaymentPeriods.stream().filter(LoanScheduleModelPeriod::isRepaymentPeriod) - .map(period -> MathUtil.stripTrailingZeros(calculateRateFactorPerPeriod(period, interestRate, daysInYearType, - daysInMonthType, repaymentFrequency, repaymentEvery, mc))) - .toList(); + void calculateRateFactorMinus1ForPeriods(final List repaymentPeriods, + final ProgressiveLoanInterestScheduleModel scheduleModel) { + for (var repaymentPeriod : repaymentPeriods) { + for (var interestPeriod : repaymentPeriod.getInterestPeriods()) { + interestPeriod.setRateFactorMinus1(calculateRateFactorMinus1PerPeriod(repaymentPeriod, interestPeriod, scheduleModel)); + } + } } /** - * Calculate Rate Factor for an exact Period - * - * @param repaymentPeriod - * @param interestRate - * @param daysInYearType - * @param daysInMonthType - * @param repaymentFrequency - * @param repaymentEvery - * @param mc - * @return + * Calculate Rate Factor-1 for an exact Period */ - BigDecimal calculateRateFactorPerPeriod(final LoanScheduleModelPeriod repaymentPeriod, final BigDecimal interestRate, - final DaysInYearType daysInYearType, final DaysInMonthType daysInMonthType, final PeriodFrequencyType repaymentFrequency, - final BigDecimal repaymentEvery, final MathContext mc) { - BigDecimal daysInMonth = BigDecimal.valueOf(daysInMonthType.getNumberOfDays(repaymentPeriod.periodFromDate())); - BigDecimal daysInYear = BigDecimal.valueOf(daysInYearType.getNumberOfDays(repaymentPeriod.periodFromDate())); + BigDecimal calculateRateFactorMinus1PerPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, + final ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod, final ProgressiveLoanInterestScheduleModel scheduleModel) { + final MathContext mc = scheduleModel.mc(); + final LoanProductRelatedDetail loanProductRelatedDetail = scheduleModel.loanProductRelatedDetail(); + final BigDecimal interestRate = calcNominalInterestRatePercentage(loanProductRelatedDetail.getNominalInterestRatePerPeriod(), mc); + final DaysInYearType daysInYearType = DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType()); + final DaysInMonthType daysInMonthType = DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType()); + final PeriodFrequencyType repaymentFrequency = loanProductRelatedDetail.getRepaymentPeriodFrequencyType(); + final BigDecimal repaymentEvery = BigDecimal.valueOf(loanProductRelatedDetail.getRepayEvery()); + + final BigDecimal daysInMonth = BigDecimal.valueOf(daysInMonthType.getNumberOfDays(interestPeriod.getFromDate())); + final BigDecimal daysInYear = BigDecimal.valueOf(daysInYearType.getNumberOfDays(interestPeriod.getFromDate())); final BigDecimal actualDaysInPeriod = BigDecimal - .valueOf(DateUtils.getDifferenceInDays(repaymentPeriod.periodFromDate(), repaymentPeriod.periodDueDate())); + .valueOf(DateUtils.getDifferenceInDays(interestPeriod.getFromDate(), interestPeriod.getDueDate())); final BigDecimal calculatedDaysInPeriod = BigDecimal - .valueOf(DateUtils.getDifferenceInDays(repaymentPeriod.periodFromDate(), repaymentPeriod.periodDueDate())); - final int numberOfYearsDifferenceInPeriod = repaymentPeriod.periodDueDate().getYear() - repaymentPeriod.periodFromDate().getYear(); + .valueOf(DateUtils.getDifferenceInDays(repaymentPeriod.getFromDate(), repaymentPeriod.getDueDate())); + final int numberOfYearsDifferenceInPeriod = interestPeriod.getDueDate().getYear() - interestPeriod.getFromDate().getYear(); final boolean partialPeriodCalculationNeeded = daysInYearType == DaysInYearType.ACTUAL && numberOfYearsDifferenceInPeriod > 0; // TODO check: loanApplicationTerms.calculatePeriodsBetweenDates(startDate, endDate); // calculate period data // TODO review: (repayment frequency: days, weeks, years; validation day is month fix 30) + // TODO refactor this logic to represent in interest period if (partialPeriodCalculationNeeded) { - final BigDecimal cumulatedPeriodFractions = calculatePeriodFractions(repaymentPeriod, mc); + final BigDecimal cumulatedPeriodFractions = calculatePeriodFractions(interestPeriod, mc); return rateFactorByRepaymentPartialPeriod(interestRate, repaymentEvery, cumulatedPeriodFractions, BigDecimal.ONE, - BigDecimal.ONE, mc); + BigDecimal.ONE, mc).setScale(mc.getPrecision(), mc.getRoundingMode()); } - return calculateRateFactorPerPeriodBasedOnRepaymentFrequency(interestRate, repaymentFrequency, repaymentEvery, daysInMonth, - daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); + return calculateRateFactorMinus1PerPeriodBasedOnRepaymentFrequency(interestRate, repaymentFrequency, repaymentEvery, daysInMonth, + daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc).setScale(mc.getPrecision(), mc.getRoundingMode()); } /** * Calculate Period fractions part based on how much year has in the period * - * @param repaymentPeriod + * @param interestPeriod * @param mc * @return */ - BigDecimal calculatePeriodFractions(LoanScheduleModelPeriod repaymentPeriod, MathContext mc) { + BigDecimal calculatePeriodFractions(ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod, MathContext mc) { BigDecimal cumulatedRateFactor = BigDecimal.ZERO; - int actualYear = repaymentPeriod.periodFromDate().getYear(); - int endYear = repaymentPeriod.periodDueDate().getYear(); - LocalDate actualDate = repaymentPeriod.periodFromDate(); + int actualYear = interestPeriod.getFromDate().getYear(); + int endYear = interestPeriod.getDueDate().getYear(); + LocalDate actualDate = interestPeriod.getFromDate(); LocalDate endOfActualYear; while (actualYear <= endYear) { - endOfActualYear = actualYear == endYear ? repaymentPeriod.periodDueDate() : LocalDate.of(actualYear, 12, 31); + endOfActualYear = actualYear == endYear ? interestPeriod.getDueDate() : LocalDate.of(actualYear, 12, 31); BigDecimal numberOfDaysInYear = BigDecimal.valueOf(Year.of(actualYear).length()); BigDecimal calculatedDaysInActualYear = BigDecimal.valueOf(DateUtils.getDifferenceInDays(actualDate, endOfActualYear)); cumulatedRateFactor = cumulatedRateFactor.add(calculatedDaysInActualYear.divide(numberOfDaysInYear, mc), mc); @@ -170,127 +325,81 @@ public final class ProgressiveEMICalculator implements EMICalculator { * @param mc * @return */ - BigDecimal calculateRateFactorPerPeriodBasedOnRepaymentFrequency(final BigDecimal interestRate, + BigDecimal calculateRateFactorMinus1PerPeriodBasedOnRepaymentFrequency(final BigDecimal interestRate, final PeriodFrequencyType repaymentFrequency, final BigDecimal repaymentEvery, final BigDecimal daysInMonth, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) { return switch (repaymentFrequency) { - case DAYS -> - rateFactorByRepaymentEveryDay(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); - case WEEKS -> - rateFactorByRepaymentEveryWeek(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); - case MONTHS -> rateFactorByRepaymentEveryMonth(interestRate, repaymentEvery, daysInMonth, daysInYear, actualDaysInPeriod, + case DAYS -> rateFactorMinus1ByRepaymentEveryDay(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, + calculatedDaysInPeriod, mc); + case WEEKS -> rateFactorMinus1ByRepaymentEveryWeek(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, + calculatedDaysInPeriod, mc); + case MONTHS -> rateFactorMinus1ByRepaymentEveryMonth(interestRate, repaymentEvery, daysInMonth, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); default -> throw new UnsupportedOperationException("Invalid repayment frequency"); // not supported yet }; } - /** - * Calculate EMI parts and return an EMI calculation result object with repayment installment rate factors - */ - EMICalculationResult calculateEMI(final List rateFactorList, final Integer actualPeriodNumber, - final Integer numberOfRepayments, final Money outstandingBalanceForRest, final MathContext mc) { - final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorN(rateFactorList, mc)); - final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(rateFactorList, mc)); + void calculateEMIOnPeriods(final List repaymentPeriods, + final ProgressiveLoanInterestScheduleModel scheduleModel) { + if (repaymentPeriods.isEmpty()) { + return; + } + final MathContext mc = scheduleModel.mc(); + final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorN(repaymentPeriods, mc)); + final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(repaymentPeriods, mc)); + final var startPeriod = repaymentPeriods.get(0); + final Money remainingBalanceFromPreviousPeriod = getRemainingBalanceFromPreviousPeriod(scheduleModel, startPeriod); + final Money outstandingBalance = remainingBalanceFromPreviousPeriod.add(startPeriod.getDisbursedAmountInPeriod()); - final Money emiValue = Money.of(outstandingBalanceForRest.getCurrency(), - calculateEMIValue(rateFactorN, outstandingBalanceForRest.getAmount(), fnResult, mc)); - final List rateFactorMinus1List = getRateFactorMinus1List(rateFactorList, mc); + final Money equalMonthlyInstallment = Money.of(outstandingBalance.getCurrency(), + calculateEMIValue(rateFactorN, outstandingBalance.getAmount(), fnResult, mc)); + final Money finalEqualMonthlyInstallment = applyInstallmentAmountInMultiplesOf(scheduleModel, equalMonthlyInstallment); - final Money adjustedEqualMonthlyInstallmentValue = adjustEMIForMoreStreamlinedRepaymentSchedule(actualPeriodNumber, - numberOfRepayments, outstandingBalanceForRest, emiValue, rateFactorMinus1List, mc); - - return new EMICalculationResult(adjustedEqualMonthlyInstallmentValue, rateFactorMinus1List); + repaymentPeriods.forEach(period -> period.setEqualMonthlyInstallment(finalEqualMonthlyInstallment)); } - /** - * Due to rounding or unequal installments, the first calculated EMI might not be the best one! Reiterate with - * adjusted EMI to get a better streamlined repayment schedule (less difference between calculated EMI and last - * installment EMI). - */ - Money adjustEMIForMoreStreamlinedRepaymentSchedule(final Integer actualPeriodNumber, final Integer numberOfRepayments, - final Money outstandingBalanceAsPerRest, final Money equalMonthlyInstallmentValue, List rateFactorMinus1List, - final MathContext mc) { - int numberOfUpcomingPeriods = numberOfRepayments - actualPeriodNumber + 1; + Money getRemainingBalanceFromPreviousPeriod(ProgressiveLoanInterestScheduleModel scheduleModel, + ProgressiveLoanInterestRepaymentModel startPeriod) { + return scheduleModel.repayments().stream().filter(period -> period.getDueDate().isEqual(startPeriod.getFromDate())) + .map(ProgressiveLoanInterestRepaymentModel::getRemainingBalance).findFirst() + .orElse(Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency())); + } + + Money applyInstallmentAmountInMultiplesOf(final ProgressiveLoanInterestScheduleModel scheduleModel, + final Money equalMonthlyInstallment) { + return scheduleModel.installmentAmountInMultiplesOf() != null + ? Money.roundToMultiplesOf(equalMonthlyInstallment, scheduleModel.installmentAmountInMultiplesOf()) + : equalMonthlyInstallment; + } + + Money getDifferenceBetweenLastTwoPeriod(final List repaymentPeriods, + final ProgressiveLoanInterestScheduleModel scheduleModel) { + int numberOfUpcomingPeriods = repaymentPeriods.size(); if (numberOfUpcomingPeriods < 2) { - return equalMonthlyInstallmentValue; + return Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()); } - - RepaymentScheduleModel repaymentScheduleModel = generateRepaymentScheduleModel(numberOfRepayments, outstandingBalanceAsPerRest, - equalMonthlyInstallmentValue, rateFactorMinus1List); - Money calculatedLastEMI = repaymentScheduleModel.getScheduleList().get(repaymentScheduleModel.getScheduleList().size() - 1).emi(); - Money originalDifference = calculatedLastEMI.minus(equalMonthlyInstallmentValue); - if (originalDifference.isZero()) { - return equalMonthlyInstallmentValue; - } - double lowerHalfOfUpcomingPeriods = Math.floor((double) numberOfUpcomingPeriods / 2); - if (lowerHalfOfUpcomingPeriods == 0.0) { - return equalMonthlyInstallmentValue; - } - boolean shouldBeAdjusted = originalDifference.abs().multipliedBy(100) - .isGreaterThan(Money.of(equalMonthlyInstallmentValue.getCurrency(), BigDecimal.valueOf(lowerHalfOfUpcomingPeriods))); - // Reiterate only when needed - if (shouldBeAdjusted) { - Money adjustment = originalDifference.dividedBy(numberOfUpcomingPeriods, mc.getRoundingMode()); - - Money adjustedEqualMonthlyInstallmentValue = equalMonthlyInstallmentValue.plus(adjustment); - RepaymentScheduleModel repaymentScheduleModelWithAdjustedEMI = generateRepaymentScheduleModel(numberOfRepayments, - outstandingBalanceAsPerRest, adjustedEqualMonthlyInstallmentValue, rateFactorMinus1List); - Money calculatedLastEMIAfterAdjustment = repaymentScheduleModelWithAdjustedEMI.getScheduleList() - .get(repaymentScheduleModelWithAdjustedEMI.getScheduleList().size() - 1).emi(); - Money differenceAfterEMIAdjustment = calculatedLastEMIAfterAdjustment.minus(adjustedEqualMonthlyInstallmentValue); - // Use the adjusted EMI only if it is better than the original one - return differenceAfterEMIAdjustment.abs().isLessThan(originalDifference.abs()) ? adjustedEqualMonthlyInstallmentValue - : equalMonthlyInstallmentValue; - } else { - return equalMonthlyInstallmentValue; - } - } - - RepaymentScheduleModel generateRepaymentScheduleModel(final Integer numberOfRepayments, final Money outstandingBalanceAsPerRest, - Money equalMonthlyInstallmentValue, List rateFactorMinus1List) { - RepaymentScheduleModel repaymentScheduleModel = new RepaymentScheduleModel(); - Money balanceOfLoan = outstandingBalanceAsPerRest; - for (int i = 0; i < numberOfRepayments; i++) { - final Money calculatedInterest = balanceOfLoan.multipliedBy(rateFactorMinus1List.get(i)); - // WE need to calculate EMI differently for last installment (decided by number of repayments or when - // schedule got shorter then planned) - if (balanceOfLoan.isLessThan(equalMonthlyInstallmentValue.minus(calculatedInterest)) || i == numberOfRepayments - 1) { - equalMonthlyInstallmentValue = balanceOfLoan.plus(calculatedInterest); - } - final Money calculatedPrincipal = equalMonthlyInstallmentValue.minus(calculatedInterest); - repaymentScheduleModel.addRepaymentPeriodModel( - new RepaymentPeriodModel(balanceOfLoan, equalMonthlyInstallmentValue, calculatedInterest, calculatedPrincipal)); - balanceOfLoan = balanceOfLoan.minus(calculatedPrincipal); - // We can stop processing if there is no outstanding principal - if (balanceOfLoan.isZero()) { - break; - } - } - return repaymentScheduleModel; - } - - /** - * Rate factor -1 values - * - */ - List getRateFactorMinus1List(final List rateFactors, final MathContext mc) { - return rateFactors.stream().map(it -> it.subtract(BigDecimal.ONE, mc)).toList(); + final var lastPeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 1); + final var penultimatePeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 2); + return lastPeriod.getEqualMonthlyInstallment().minus(penultimatePeriod.getEqualMonthlyInstallment()); } /** * Calculate Rate Factor Product from rate factors */ - BigDecimal calculateRateFactorN(final List rateFactors, final MathContext mc) { - return rateFactors.stream().reduce(BigDecimal.ONE, (BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc)); + BigDecimal calculateRateFactorN(final List periods, final MathContext mc) { + return periods.stream().map(ProgressiveLoanInterestRepaymentModel::getRateFactor).reduce(BigDecimal.ONE, + (BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc)); } /** * Summarize Fn values */ - BigDecimal calculateFnResult(final List rateFactors, final MathContext mc) { - return rateFactors.stream().skip(1).reduce(BigDecimal.ONE, - (BigDecimal previousValue, BigDecimal rateFactor) -> fnValue(previousValue, rateFactor, mc)); + BigDecimal calculateFnResult(final List periods, final MathContext mc) { + return periods.stream()// + .skip(1)// + .map(ProgressiveLoanInterestRepaymentModel::getRateFactor)// + .reduce(BigDecimal.ONE, (BigDecimal previousValue, BigDecimal rateFactor) -> fnValue(previousValue, rateFactor, mc));// } /** @@ -326,9 +435,10 @@ public final class ProgressiveEMICalculator implements EMICalculator { * @param mc * @return Rate Factor for period */ - BigDecimal rateFactorByRepaymentEveryDay(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInYear, - final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) { - return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, daysInYear, actualDaysInPeriod, + BigDecimal rateFactorMinus1ByRepaymentEveryDay(final BigDecimal interestRate, final BigDecimal repaymentEvery, + final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, + final MathContext mc) { + return rateFactorMinus1ByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); } @@ -357,9 +467,10 @@ public final class ProgressiveEMICalculator implements EMICalculator { * @param mc * @return Rate Factor for period */ - BigDecimal rateFactorByRepaymentEveryWeek(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInYear, - final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) { - return rateFactorByRepaymentPeriod(interestRate, ONE_WEEK_IN_DAYS, repaymentEvery, daysInYear, actualDaysInPeriod, + BigDecimal rateFactorMinus1ByRepaymentEveryWeek(final BigDecimal interestRate, final BigDecimal repaymentEvery, + final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, + final MathContext mc) { + return rateFactorMinus1ByRepaymentPeriod(interestRate, ONE_WEEK_IN_DAYS, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); } @@ -391,10 +502,10 @@ public final class ProgressiveEMICalculator implements EMICalculator { * @param mc * @return Rate Factor for period */ - BigDecimal rateFactorByRepaymentEveryMonth(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInMonth, - final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, - final MathContext mc) { - return rateFactorByRepaymentPeriod(interestRate, daysInMonth, repaymentEvery, daysInYear, actualDaysInPeriod, + BigDecimal rateFactorMinus1ByRepaymentEveryMonth(final BigDecimal interestRate, final BigDecimal repaymentEvery, + final BigDecimal daysInMonth, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, + final BigDecimal calculatedDaysInPeriod, final MathContext mc) { + return rateFactorMinus1ByRepaymentPeriod(interestRate, daysInMonth, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc); } @@ -426,13 +537,14 @@ public final class ProgressiveEMICalculator implements EMICalculator { * @param mc * @return Rate Factor for period */ - BigDecimal rateFactorByRepaymentPeriod(final BigDecimal interestRate, final BigDecimal repaymentPeriodMultiplierInDays, + BigDecimal rateFactorMinus1ByRepaymentPeriod(final BigDecimal interestRate, final BigDecimal repaymentPeriodMultiplierInDays, final BigDecimal repaymentEvery, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) { final BigDecimal interestFractionPerPeriod = repaymentPeriodMultiplierInDays.multiply(repaymentEvery, mc).divide(daysInYear, mc); - return BigDecimal.ONE.add( - interestRate.multiply(interestFractionPerPeriod, mc).multiply(actualDaysInPeriod, mc).divide(calculatedDaysInPeriod, mc), - mc); + return interestRate// + .multiply(interestFractionPerPeriod, mc)// + .multiply(actualDaysInPeriod, mc)// + .divide(calculatedDaysInPeriod, mc);// } /** @@ -442,10 +554,11 @@ public final class ProgressiveEMICalculator implements EMICalculator { BigDecimal rateFactorByRepaymentPartialPeriod(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal cumulatedPeriodRatio, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final MathContext mc) { - final BigDecimal interestFractionPerPeriod = repaymentEvery.multiply(cumulatedPeriodRatio); - return BigDecimal.ONE.add( - interestRate.multiply(interestFractionPerPeriod, mc).multiply(actualDaysInPeriod, mc).divide(calculatedDaysInPeriod, mc), - mc); + final BigDecimal interestFractionPerPeriod = repaymentEvery.multiply(cumulatedPeriodRatio, mc); + return interestRate// + .multiply(interestFractionPerPeriod, mc)// + .multiply(actualDaysInPeriod, mc)// + .divide(calculatedDaysInPeriod, mc);// } /** @@ -464,30 +577,46 @@ public final class ProgressiveEMICalculator implements EMICalculator { return BigDecimal.ONE.add(previousFnValue.multiply(currentRateFactor, mc), mc); } - @Override - public PrincipalInterest calculatePrincipalInterestComponentsForPeriod(final EMICalculationResult emiCalculationResult, - final Money outstandingBalanceAsPerRest, final Integer installmentAmountInMultiplesOf, final Integer actualPeriodNumber, - final Integer actualNoOfRepayments, final MathContext mc) { - - final Money equalMonthlyInstallmentValue = installmentAmountInMultiplesOf != null - ? Money.roundToMultiplesOf(emiCalculationResult.getEqualMonthlyInstallmentValue(), installmentAmountInMultiplesOf) - : emiCalculationResult.getEqualMonthlyInstallmentValue(); - final BigDecimal rateFactorMinus1 = emiCalculationResult.getNextRepaymentPeriodRateFactorMinus1(); - final Money calculatedInterest = outstandingBalanceAsPerRest.multipliedBy(rateFactorMinus1); - final Money calculatedPrincipal = equalMonthlyInstallmentValue.minus(calculatedInterest); - return new PrincipalInterest( - adjustCalculatedPrincipalWithRemainingBalanceInLastPeriod(calculatedPrincipal, outstandingBalanceAsPerRest, - actualPeriodNumber, actualNoOfRepayments), - calculatedInterest, Money.zero(equalMonthlyInstallmentValue.getCurrency())); + void calculatePrincipalInterestComponentsForPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel) { + Money outstandingBalance = Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()); + int numberOfPeriods = scheduleModel.repayments().size(); + int period = 0; + for (var repaymentPeriod : scheduleModel.repayments()) { + final boolean isLastPeriod = ++period == numberOfPeriods; + outstandingBalance = calculatePrincipalInterestComponentsForPeriod(repaymentPeriod, outstandingBalance, isLastPeriod); + } } - Money adjustCalculatedPrincipalWithRemainingBalanceInLastPeriod(final Money calculatedPrincipal, - final Money outstandingBalanceAsPerRest, final Integer actualPeriodNumber, final Integer actualNoOfRepayments) { - final boolean isLastRepaymentPeriod = Objects.equals(actualPeriodNumber, actualNoOfRepayments); - if (isLastRepaymentPeriod) { - final Money remainingAmount = outstandingBalanceAsPerRest.minus(calculatedPrincipal); - return calculatedPrincipal.plus(remainingAmount); + Money calculatePrincipalInterestComponentsForPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, + final Money initBalance, final boolean isLastPeriod) { + Money outstandingBalance = initBalance; + for (ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod : repaymentPeriod.getInterestPeriods()) { + outstandingBalance = outstandingBalance.plus(interestPeriod.getDisbursedAmount()); + final Money calculatedInterest = outstandingBalance.multipliedBy(interestPeriod.getRateFactorMinus1()); + interestPeriod.setInterestDue(calculatedInterest); } - return calculatedPrincipal; + + final Money cumulatedInterest = repaymentPeriod.getInterestDue(); + final Money calculatedPrincipal = isLastPeriod ? outstandingBalance + : repaymentPeriod.getEqualMonthlyInstallment().minus(cumulatedInterest); + + if (isLastPeriod) { + repaymentPeriod.setEqualMonthlyInstallment(calculatedPrincipal.add(cumulatedInterest)); + } + + repaymentPeriod.setPrincipalDue(calculatedPrincipal); + repaymentPeriod.setRemainingBalance(outstandingBalance.minus(calculatedPrincipal)); + + return repaymentPeriod.getRemainingBalance(); + } + + @Override + public ProgressiveLoanInterestScheduleModel generateModel(LoanProductRelatedDetail loanProductRelatedDetail, + Integer installmentAmountInMultiplesOf, List repaymentPeriods, MathContext mc) { + List repaymentModelsWithoutDownPayment = repaymentPeriods.stream() + .filter(period -> !period.isDownPayment() && !period.isAdditional()).toList(); + List repaymentModels = progressiveLoanInterestRepaymentModelMapper + .map(repaymentModelsWithoutDownPayment); + return new ProgressiveLoanInterestScheduleModel(repaymentModels, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentPeriodModel.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentPeriodModel.java deleted file mode 100644 index de99d69c0..000000000 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/RepaymentPeriodModel.java +++ /dev/null @@ -1,24 +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.loanproduct.calc; - -import org.apache.fineract.organisation.monetary.domain.Money; - -public record RepaymentPeriodModel(Money balanceOfLoan, Money emi, Money principal, Money interest) { -} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/mapper/ProgressiveLoanInterestRepaymentModelMapper.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/mapper/ProgressiveLoanInterestRepaymentModelMapper.java new file mode 100644 index 000000000..a234197a3 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/mapper/ProgressiveLoanInterestRepaymentModelMapper.java @@ -0,0 +1,37 @@ +/** + * 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.loanproduct.mapper; + +import java.util.List; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestRepaymentModel; +import org.mapstruct.Mapper; + +@Mapper(config = MapstructMapperConfig.class) +public interface ProgressiveLoanInterestRepaymentModelMapper { + + List map(List repaymentScheduleInstallments); + + default ProgressiveLoanInterestRepaymentModel map(LoanRepaymentScheduleInstallment repaymentScheduleInstallment) { + return new ProgressiveLoanInterestRepaymentModel(repaymentScheduleInstallment.getFromDate(), + repaymentScheduleInstallment.getDueDate(), Money.zero(repaymentScheduleInstallment.getLoan().getCurrency())); + } +} diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java index 551ae9cbd..96afc9934 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java @@ -61,10 +61,10 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; -import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; -import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; 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.domain.LoanScheduleProcessingType; +import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; import org.apache.fineract.portfolio.loanproduct.domain.AllocationType; import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; @@ -90,7 +90,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { private static final MonetaryCurrency MONETARY_CURRENCY = new MonetaryCurrency("USD", 2, 1); private static final MockedStatic MONEY_HELPER = mockStatic(MoneyHelper.class); private AdvancedPaymentScheduleTransactionProcessor underTest; - private LoanReAgingParameterRepository reAgingParameterRepository = Mockito.mock(LoanReAgingParameterRepository.class); + private final EMICalculator emiCalculator = Mockito.mock(EMICalculator.class); @BeforeAll public static void init() { @@ -104,7 +104,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { @BeforeEach public void setUp() { - underTest = new AdvancedPaymentScheduleTransactionProcessor(reAgingParameterRepository); + underTest = new AdvancedPaymentScheduleTransactionProcessor(emiCalculator); ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); @@ -148,7 +148,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { when(loanTransaction.isPenaltyPayment()).thenReturn(false); underTest.processLatestTransaction(loanTransaction, - new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount))); + new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount), null)); Mockito.verify(installment, times(1)).payFeeChargesComponent(eq(transactionDate), eq(chargeAmountMoney)); Mockito.verify(loanTransaction, times(1)).updateComponents(refEq(zero), refEq(zero), refEq(chargeAmountMoney), refEq(zero)); @@ -192,7 +192,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { when(loanTransaction.isPenaltyPayment()).thenReturn(false); underTest.processLatestTransaction(loanTransaction, - new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount))); + new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount), null)); Mockito.verify(installment, times(1)).payFeeChargesComponent(eq(transactionDate), eq(transactionAmountMoney)); Mockito.verify(loanTransaction, times(1)).updateComponents(refEq(zero), refEq(zero), refEq(transactionAmountMoney), refEq(zero)); @@ -243,7 +243,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { when(loanTransaction.isOn(eq(transactionDate))).thenReturn(true); underTest.processLatestTransaction(loanTransaction, - new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount))); + new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount), null)); Mockito.verify(installment, times(1)).payFeeChargesComponent(eq(transactionDate), eq(chargeAmountMoney)); Mockito.verify(loanTransaction, times(1)).updateComponents(refEq(zero), refEq(zero), refEq(chargeAmountMoney), refEq(zero)); @@ -271,7 +271,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { installments.add(installment); // when - TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder); + TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder, null); underTest.processCreditTransaction(chargeBackTransaction, ctx); // verify principal @@ -328,7 +328,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { installments.add(installment); // when - TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder); + TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder, null); underTest.processCreditTransaction(chargeBackTransaction, ctx); // verify charges on installment @@ -380,7 +380,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { installments.add(installment2); // when - TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder); + TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder, null); underTest.processCreditTransaction(chargeBackTransaction, ctx); // verify principal 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 aebfdcf88..a837ef533 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 @@ -33,10 +33,8 @@ import org.apache.fineract.portfolio.common.domain.DaysInMonthType; import org.apache.fineract.portfolio.common.domain.DaysInYearType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; -import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelDisbursementPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.PreGeneratedLoanSchedulePeriod; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; @@ -50,7 +48,7 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class ProgressiveEMICalculatorTest { - private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator(); + private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator(null); private static MockedStatic moneyHelper = Mockito.mockStatic(MoneyHelper.class); private static LoanProductRelatedDetail loanProductRelatedDetail = Mockito.mock(LoanProductRelatedDetail.class); @@ -82,7 +80,7 @@ class ProgressiveEMICalculatorTest { final BigDecimal daysInPeriod = BigDecimal.valueOf(DateUtils.getDifferenceInDays(period.getFromDate(), period.getDueDate())); final BigDecimal daysInYear = BigDecimal.valueOf(daysInYearType.getNumberOfDays(period.getFromDate())); final BigDecimal daysInMonth = BigDecimal.valueOf(daysInMonthType.getNumberOfDays(period.getFromDate())); - return emiCalculator.rateFactorByRepaymentEveryMonth(interestRate, BigDecimal.ONE, daysInMonth, daysInYear, daysInPeriod, + return emiCalculator.rateFactorMinus1ByRepaymentEveryMonth(interestRate, BigDecimal.ONE, daysInMonth, daysInYear, daysInPeriod, daysInPeriod, MoneyHelper.getMathContext()); } @@ -91,12 +89,13 @@ class ProgressiveEMICalculatorTest { // Given final DaysInYearType daysInYearType = DaysInYearType.DAYS_365; final DaysInMonthType daysInMonthType = DaysInMonthType.ACTUAL; + final MathContext mc = MoneyHelper.getMathContext(); final String[] expectedValues = new String[] { "1.00805337534", "1.00753380274", "1.00805337534", "1.00779358904", "1.00805337534", "1.00779358904" }; // Then for (LoanRepaymentScheduleInstallment period : periods) { - BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period); + BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period).add(BigDecimal.ONE, mc); Assertions.assertEquals(expectedValues[period.getInstallmentNumber() - 1], rateFactor.toString()); } } @@ -106,12 +105,13 @@ class ProgressiveEMICalculatorTest { // Given final DaysInYearType daysInYearType = DaysInYearType.ACTUAL; final DaysInMonthType daysInMonthType = DaysInMonthType.ACTUAL; + final MathContext mc = MoneyHelper.getMathContext(); final String[] expectedValues = new String[] { "1.00803137158", "1.00751321858", "1.00803137158", "1.00777229508", "1.00803137158", "1.00777229508" }; // Then for (LoanRepaymentScheduleInstallment period : periods) { - BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period); + BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period).add(BigDecimal.ONE, mc); Assertions.assertEquals(expectedValues[period.getInstallmentNumber() - 1], rateFactor.toString()); } } @@ -128,7 +128,7 @@ class ProgressiveEMICalculatorTest { final List fnValuesCalculated = new ArrayList<>(); BigDecimal previousFnValue = BigDecimal.ZERO; for (LoanRepaymentScheduleInstallment period : periods) { - BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period); + BigDecimal rateFactor = getRateFactorsByMonth(daysInYearType, daysInMonthType, interestRate, period).add(BigDecimal.ONE, mc); final BigDecimal currentFnValue = emiCalculator.fnValue(previousFnValue, rateFactor, mc); fnValuesCalculated.add(currentFnValue); @@ -143,457 +143,535 @@ class ProgressiveEMICalculatorTest { } @Test - public void testEMICalculation_principal100_dayInYearsActual_daysInMonthActual_repayEvery1Month() { + public void testEMICalculator_generateInterestScheduleModel() { final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); + final List expectedRepaymentPeriods = new ArrayList<>(); + final Integer installmentAmountInMultiplesOf = null; - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); + 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))); - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); + final ProgressiveLoanInterestScheduleModel interestScheduleModel = emiCalculator + .generateInterestScheduleModel(expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); - // 17.13 - Assertions.assertEquals(BigDecimal.valueOf(17.13), result.getEqualMonthlyInstallmentValue().getAmount()); - - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00751321858), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00777229508), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00777229508), result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.valueOf(0), result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); + Assertions.assertTrue(interestScheduleModel != null); + Assertions.assertTrue(interestScheduleModel.loanProductRelatedDetail() != null); + Assertions.assertTrue(interestScheduleModel.mc() != null); + Assertions.assertTrue(interestScheduleModel.installmentAmountInMultiplesOf() == null); + Assertions.assertTrue(interestScheduleModel.repayments() != null); + Assertions.assertEquals(4, interestScheduleModel.repayments().size()); + Assertions.assertEquals(121, interestScheduleModel.getLoanTermInDays()); } @Test - public void testEMICalculation_principal10000_dayInYearsActual_daysInMonthActual_repayEvery1Month_DisbursementInPreviousYear() { + public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month() { final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2023, 12, 12), LocalDate.of(2024, 1, 12))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 12), LocalDate.of(2024, 2, 12))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 2, 12), LocalDate.of(2024, 3, 12))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 3, 12), LocalDate.of(2024, 4, 12))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 4, 12), LocalDate.of(2024, 5, 12))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 5, 12), LocalDate.of(2024, 6, 12))); + final List expectedRepaymentPeriods = new ArrayList<>(); - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(10_000); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); + expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); + expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); + expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); + expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); + expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); + expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); - - // 1713.12 - Assertions.assertEquals(BigDecimal.valueOf(1713.12), result.getEqualMonthlyInstallmentValue().getAmount()); - - Assertions.assertEquals(BigDecimal.valueOf(0.00804485776), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00751321858), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00777229508), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00803137158), result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - } - - @Test - public void testEMICalculation_principal100_dayInYears365_daysInMonthActual_repayEvery1Month() { - final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); - - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); - - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_365.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); - - // 17.13 - Assertions.assertEquals(BigDecimal.valueOf(17.13), result.getEqualMonthlyInstallmentValue().getAmount()); - - Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00753380274), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00779358904), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00779358904), result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - // check reset - Assertions.assertEquals(BigDecimal.valueOf(0.00805337534), result.getNextRepaymentPeriodRateFactorMinus1()); - } - - @Test - public void testEMICalculation_principal100_dayInYears360_daysInMonth30_repayEvery1Month() { - final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); - - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); + final BigDecimal interestRate = new BigDecimal("9.4822"); + 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); - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); - // 17.13 - Assertions.assertEquals(BigDecimal.valueOf(17.13), result.getEqualMonthlyInstallmentValue().getAmount()); + final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - // check reset - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 17.13, 0.007901833333, 0.79, 16.34, 83.66); + checkPeriod(interestSchedule, 1, 0, 17.13, 0.007901833333, 0.66, 16.47, 67.19); + checkPeriod(interestSchedule, 2, 0, 17.13, 0.007901833333, 0.53, 16.60, 50.59); + checkPeriod(interestSchedule, 3, 0, 17.13, 0.007901833333, 0.40, 16.73, 33.86); + checkPeriod(interestSchedule, 4, 0, 17.13, 0.007901833333, 0.27, 16.86, 17.0); + checkPeriod(interestSchedule, 5, 0, 17.13, 0.007901833333, 0.13, 17.00, 0.0); } @Test - public void testEMICalculation_principal100_dayInYears364_daysInMonthDoesntMatter_repayEvery1Week() { + public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month_reschedule() { final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 8))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 8), LocalDate.of(2024, 1, 15))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 22))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 1, 22), LocalDate.of(2024, 1, 29))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 1, 29), LocalDate.of(2024, 1, 5))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 2, 5), LocalDate.of(2024, 2, 12))); + final List expectedRepaymentPeriods = new ArrayList<>(); - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); + 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, 15))); + expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 15), LocalDate.of(2024, 4, 15))); + expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 15), LocalDate.of(2024, 5, 15))); + expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 15), LocalDate.of(2024, 6, 15))); + expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 15), LocalDate.of(2024, 7, 15))); - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_364.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); - - // 16.77 - Assertions.assertEquals(BigDecimal.valueOf(16.77), result.getEqualMonthlyInstallmentValue().getAmount()); - - final BigDecimal fixValue = new BigDecimal("0.0018235"); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - // check reset - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - } - - @Test - public void testEMICalculation_principal100_dayInYears360_daysInMonthDoesntMatter_repayEvery2Week() { - final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 15))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 29))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 1, 29), LocalDate.of(2024, 2, 12))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 2, 12), LocalDate.of(2024, 2, 26))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 2, 26), LocalDate.of(2024, 3, 11))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 3, 11), LocalDate.of(2024, 3, 25))); - - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); + final BigDecimal interestRate = new BigDecimal("9.4822"); + 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.INVALID.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(2); + 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); - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); - // 16.88 - Assertions.assertEquals(new BigDecimal("16.88"), result.getEqualMonthlyInstallmentValue().getAmount()); + final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); - final BigDecimal fixValue = new BigDecimal("0.00368752222"); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 17.13, 0.007901833333, 0.79, 16.34, 83.66); + checkPeriod(interestSchedule, 1, 0, 17.13, 0.007901833333, 0.66, 16.47, 67.19); + checkPeriod(interestSchedule, 2, 0, 17.13, 0.007901833333, 0.53, 16.60, 50.59); + checkPeriod(interestSchedule, 3, 0, 17.13, 0.007901833333, 0.40, 16.73, 33.86); + checkPeriod(interestSchedule, 4, 0, 17.13, 0.007901833333, 0.27, 16.86, 17.0); + checkPeriod(interestSchedule, 5, 0, 17.13, 0.007901833333, 0.13, 17.00, 0.0); + } - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // @Test + // public void testEMICalculation_disbursedAmt100_dayInYearsActual_daysInMonthActual_repayEvery1Month_reschedule() { + // 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, 15))); + // expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 15), LocalDate.of(2024, 4, 15))); + // expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 15), LocalDate.of(2024, 5, 15))); + // expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 15), LocalDate.of(2024, 6, 15))); + // expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 15), LocalDate.of(2024, 7, 15))); + // + // final BigDecimal interestRate = new BigDecimal("9.4822"); + // final Integer installmentAmountInMultiplesOf = null; + // + // Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + // Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); + // Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); + // Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); + // Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + // Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); + // + // 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); + // + // checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + // checkPeriod(interestSchedule, 0, 0, 17.18, 0.008053375342, 0.81, 16.37, 83.66); + // checkPeriod(interestSchedule, 1, 0, 17.18, 0.011170810959, 0.93, 16.25, 67.38); + // checkPeriod(interestSchedule, 2, 0, 17.18, 0.008053375342, 0.54, 16.64, 50.74); + // checkPeriod(interestSchedule, 3, 0, 17.18, 0.007793589041, 0.40, 16.78, 33.96); + // checkPeriod(interestSchedule, 4, 0, 17.18, 0.008053375342, 0.27, 16.91, 17.05); + // checkPeriod(interestSchedule, 5, 0, 17.18, 0.007793589041, 0.13, 17.05, 0.0); + // } - result.reset(); - // check reset - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); + @Test + public void testEMICalculation_multiDisbursedAmt300InSamePeriod_dayInYears360_daysInMonth30_repayEvery1Month() { + 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("9.4822"); + 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); + + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); + + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 17.13, 0.007901833333, 0.79, 16.34, 83.66); + checkPeriod(interestSchedule, 1, 0, 17.13, 0.007901833333, 0.66, 16.47, 67.19); + checkPeriod(interestSchedule, 2, 0, 17.13, 0.007901833333, 0.53, 16.60, 50.59); + checkPeriod(interestSchedule, 3, 0, 17.13, 0.007901833333, 0.40, 16.73, 33.86); + checkPeriod(interestSchedule, 4, 0, 17.13, 0.007901833333, 0.27, 16.86, 17.0); + checkPeriod(interestSchedule, 5, 0, 17.13, 0.007901833333, 0.13, 17.00, 0.0); + + disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(200)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 8), disbursedAmount); + + checkDisbursementOnPeriod(interestSchedule, 0, 300); + checkPeriod(interestSchedule, 0, 0, 51.33, 0.001784284946, 0.18, 2.02, 49.31, 250.69); + checkPeriod(interestSchedule, 0, 1, 51.33, 0.006117548387, 1.84, 2.02, 49.31, 250.69); + checkPeriod(interestSchedule, 1, 0, 51.33, 0.007901833333, 1.98, 49.35, 201.34); + checkPeriod(interestSchedule, 2, 0, 51.33, 0.007901833333, 1.59, 49.74, 151.60); + checkPeriod(interestSchedule, 3, 0, 51.33, 0.007901833333, 1.20, 50.13, 101.47); + checkPeriod(interestSchedule, 4, 0, 51.33, 0.007901833333, 0.80, 50.53, 50.94); + checkPeriod(interestSchedule, 5, 0, 51.34, 0.007901833333, 0.40, 50.94, 0.0); } @Test - public void testEMICalculation_principal100_dayInYears360_daysInMonthDoesntMatter_repayEvery15Days() { + public void testEMICalculation_multiDisbursedAmt200InDifferentPeriod_dayInYears360_daysInMonth30_repayEvery1Month() { final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 16), LocalDate.of(2024, 1, 31))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 15))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 2, 15), LocalDate.of(2024, 3, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 16))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 3, 16), LocalDate.of(2024, 3, 31))); + final List expectedRepaymentPeriods = new ArrayList<>(); - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); + 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("9.4822"); + 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); + + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); + + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 17.13, 0.007901833333, 0.79, 16.34, 83.66); + checkPeriod(interestSchedule, 1, 0, 17.13, 0.007901833333, 0.66, 16.47, 67.19); + checkPeriod(interestSchedule, 2, 0, 17.13, 0.007901833333, 0.53, 16.60, 50.59); + checkPeriod(interestSchedule, 3, 0, 17.13, 0.007901833333, 0.40, 16.73, 33.86); + checkPeriod(interestSchedule, 4, 0, 17.13, 0.007901833333, 0.27, 16.86, 17.0); + checkPeriod(interestSchedule, 5, 0, 17.13, 0.007901833333, 0.13, 17.00, 0.0); + + disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 2, 15), disbursedAmount); + + checkDisbursementOnPeriod(interestSchedule, 1, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 17.13, 0.007901833333, 0.79, 16.34, 83.66); + checkPeriod(interestSchedule, 1, 0, 37.53, 0.003814678161, 0.32, 1.07, 36.46, 147.20); + checkPeriod(interestSchedule, 1, 1, 37.53, 0.004087155172, 0.75, 1.07, 36.46, 147.20); + checkPeriod(interestSchedule, 2, 0, 37.53, 0.007901833333, 1.16, 36.37, 110.83); + checkPeriod(interestSchedule, 3, 0, 37.53, 0.007901833333, 0.88, 36.65, 74.18); + checkPeriod(interestSchedule, 4, 0, 37.53, 0.007901833333, 0.59, 36.94, 37.24); + checkPeriod(interestSchedule, 5, 0, 37.53, 0.007901833333, 0.29, 37.24, 0.0); + } + + @Test + public void testEMICalculation_disbursedAmt100_dayInYearsActual_daysInMonthActual_repayEvery1Month() { + final MathContext mc = MoneyHelper.getMathContext(); + final List expectedRepaymentPeriods = List.of( + repayment(1, LocalDate.of(2023, 12, 12), LocalDate.of(2024, 1, 12)), + repayment(2, LocalDate.of(2024, 1, 12), LocalDate.of(2024, 2, 12)), + repayment(3, LocalDate.of(2024, 2, 12), LocalDate.of(2024, 3, 1)), + repayment(4, LocalDate.of(2024, 3, 12), LocalDate.of(2024, 4, 1)), + repayment(5, LocalDate.of(2024, 4, 12), LocalDate.of(2024, 5, 1)), + repayment(6, LocalDate.of(2024, 5, 12), LocalDate.of(2024, 6, 1))); + + final BigDecimal interestRate = new BigDecimal("9.4822"); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); + + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2023, 12, 12), disbursedAmount); + + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 17.13, 0.008044857759, 0.80, 16.33, 83.67); + checkPeriod(interestSchedule, 1, 0, 17.13, 0.008031371585, 0.67, 16.46, 67.21); + checkPeriod(interestSchedule, 2, 0, 17.13, 0.007513218579, 0.50, 16.63, 50.58); + checkPeriod(interestSchedule, 3, 0, 17.13, 0.008031371585, 0.41, 16.72, 33.86); + checkPeriod(interestSchedule, 4, 0, 17.13, 0.007772295082, 0.26, 16.87, 16.99); + checkPeriod(interestSchedule, 5, 0, 17.13, 0.008031371585, 0.14, 16.99, 0.0); + } + + @Test + public void testEMICalculation_disbursedAmt1000_NoInterest_repayEvery1Month() { + final MathContext mc = MoneyHelper.getMathContext(); + final List expectedRepaymentPeriods = List.of( + repayment(2, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)), + repayment(3, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)), + repayment(4, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)), + repayment(5, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); + + final BigDecimal interestRate = new BigDecimal("0"); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); + + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(1000)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); + + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 250.0, 0.0, 0.0, 250.0, 750.0); + checkPeriod(interestSchedule, 1, 0, 250.0, 0.0, 0.0, 250.0, 500.0); + checkPeriod(interestSchedule, 2, 0, 250.0, 0.0, 0.0, 250.0, 250.0); + checkPeriod(interestSchedule, 3, 0, 250.0, 0.0, 0.0, 250.0, 0.0); + } + + @Test + public void testEMICalculation_disbursedAmt100_dayInYears364_daysInMonthActual_repayEvery1Week() { + final MathContext mc = MoneyHelper.getMathContext(); + final List expectedRepaymentPeriods = List.of( + repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 8)), + repayment(2, LocalDate.of(2024, 1, 8), LocalDate.of(2024, 1, 15)), + repayment(3, LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 22)), + repayment(4, LocalDate.of(2024, 1, 22), LocalDate.of(2024, 1, 29)), + repayment(5, LocalDate.of(2024, 1, 29), LocalDate.of(2024, 2, 5)), + repayment(6, LocalDate.of(2024, 2, 5), LocalDate.of(2024, 2, 12))); + + final BigDecimal interestRate = new BigDecimal("9.4822"); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_364.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); + + 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); + + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 16.77, 0.001823500000, 0.18, 16.59, 83.41); + checkPeriod(interestSchedule, 1, 0, 16.77, 0.001823500000, 0.15, 16.62, 66.79); + checkPeriod(interestSchedule, 2, 0, 16.77, 0.001823500000, 0.12, 16.65, 50.14); + checkPeriod(interestSchedule, 3, 0, 16.77, 0.001823500000, 0.09, 16.68, 33.46); + checkPeriod(interestSchedule, 4, 0, 16.77, 0.001823500000, 0.06, 16.71, 16.75); + checkPeriod(interestSchedule, 5, 0, 16.78, 0.001823500000, 0.03, 16.75, 0.0); + } + + @Test + public void testEMICalculation_disbursedAmt100_dayInYears364_daysInMonthActual_repayEvery2Week() { + final MathContext mc = MoneyHelper.getMathContext(); + final List expectedRepaymentPeriods = List.of( + repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 15)), + repayment(2, LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 29)), + repayment(3, LocalDate.of(2024, 1, 29), LocalDate.of(2024, 2, 12))); + + final BigDecimal interestRate = new BigDecimal("9.4822"); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_364.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(2); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); + + 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); + + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 33.57, 0.003647000000, 0.36, 33.21, 66.79); + checkPeriod(interestSchedule, 1, 0, 33.57, 0.003647000000, 0.24, 33.33, 33.46); + checkPeriod(interestSchedule, 2, 0, 33.58, 0.003647000000, 0.12, 33.46, 0.0); + } + + @Test + public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonthDoesntMatter_repayEvery15Days() { + final MathContext mc = MoneyHelper.getMathContext(); + final List expectedRepaymentPeriods = List.of( + repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16)), + repayment(2, LocalDate.of(2024, 1, 16), LocalDate.of(2024, 1, 31)), + repayment(3, LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 15)), + repayment(4, LocalDate.of(2024, 2, 15), LocalDate.of(2024, 3, 1)), + repayment(5, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 16)), + repayment(6, LocalDate.of(2024, 3, 16), LocalDate.of(2024, 3, 31))); + + final BigDecimal interestRate = new BigDecimal("9.4822"); + 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.INVALID.getValue()); Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.DAYS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(15); + Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency); - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); - // 16.90 - Assertions.assertEquals(new BigDecimal("16.90"), result.getEqualMonthlyInstallmentValue().getAmount()); + final Money disbursedAmount = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount); - final BigDecimal fixValue = new BigDecimal("0.00395091667"); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - // check reset - Assertions.assertEquals(fixValue, result.getNextRepaymentPeriodRateFactorMinus1()); + checkDisbursementOnPeriod(interestSchedule, 0, disbursedAmount); + checkPeriod(interestSchedule, 0, 0, 16.90, 0.003950916667, 0.40, 16.50, 83.50); + checkPeriod(interestSchedule, 1, 0, 16.90, 0.003950916667, 0.33, 16.57, 66.93); + checkPeriod(interestSchedule, 2, 0, 16.90, 0.003950916667, 0.26, 16.64, 50.29); + checkPeriod(interestSchedule, 3, 0, 16.90, 0.003950916667, 0.20, 16.70, 33.59); + checkPeriod(interestSchedule, 4, 0, 16.90, 0.003950916667, 0.13, 16.77, 16.82); + checkPeriod(interestSchedule, 5, 0, 16.89, 0.003950916667, 0.07, 16.82, 0.0); } - @Test - public void testEMICalculation_Disbursement_DownPayment_Principal100_dayInYears360_daysInMonth30_repayEvery1Month() { - final MathContext mc = MoneyHelper.getMathContext(); + // + // @Test + // public void testEMICalculation_Principal1000_NoInterest_repayEvery1Month() { + // final MathContext mc = MoneyHelper.getMathContext(); + // + // final BigDecimal interestRate = BigDecimal.valueOf(0); + // final Money outstandingBalance = Money.of(monetaryCurrency, BigDecimal.valueOf(1000)); + // + // 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); + // + // 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))); + // + // final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, + // loanProductRelatedDetail, + // expectedRepaymentPeriods, 1, 4, mc); + // + // // 250.00 + // Assertions.assertEquals(new BigDecimal("250.00"), result.getEqualMonthlyInstallmentValue().getAmount()); + // + // Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // + // // no more period, no more interest + // Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // + // result.reset(); + // // check reset + // Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); + // } + // + // @Test + // public void testUnsupportedRepaymentEveryYear() { + // final MathContext mc = MoneyHelper.getMathContext(); + // final List expectedRepaymentPeriods = new ArrayList<>(); + // + // final BigDecimal interestRate = BigDecimal.valueOf(9.4822); + // final BigDecimal principal = BigDecimal.valueOf(100); + // final Money outstandingBalance = Money.of(monetaryCurrency, principal); + // + // Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + // Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); + // Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); + // Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.YEARS); + // Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + // + // expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); + // + // try { + // final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, + // loanProductRelatedDetail, + // expectedRepaymentPeriods, 1, 6, mc); + // Assertions.fail(); + // } catch (Exception e) { + // Assertions.assertInstanceOf(UnsupportedOperationException.class, e); + // } + // } + // + // @Test + // public void testUnsupportedRepaymentEveryWholeTerm() { + // final MathContext mc = MoneyHelper.getMathContext(); + // final List expectedRepaymentPeriods = new ArrayList<>(); + // + // final BigDecimal interestRate = BigDecimal.valueOf(9.4822); + // final BigDecimal principal = BigDecimal.valueOf(100); + // final Money outstandingBalance = Money.of(monetaryCurrency, principal); + // + // Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + // Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); + // Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); + // Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WHOLE_TERM); + // Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + // + // expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); + // + // try { + // final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, + // loanProductRelatedDetail, + // expectedRepaymentPeriods, 1, 6, mc); + // Assertions.fail(); + // } catch (Exception e) { + // Assertions.assertInstanceOf(UnsupportedOperationException.class, e); + // } + // } + // + // @Test + // public void testInvalidRepaymentEveryValue() { + // final MathContext mc = MoneyHelper.getMathContext(); + // final List expectedRepaymentPeriods = new ArrayList<>(); + // + // final BigDecimal interestRate = BigDecimal.valueOf(9.4822); + // final BigDecimal principal = BigDecimal.valueOf(100); + // final Money outstandingBalance = Money.of(monetaryCurrency, principal); + // + // Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); + // Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); + // Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); + // Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.INVALID); + // Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + // + // expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); + // + // try { + // final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, + // loanProductRelatedDetail, + // expectedRepaymentPeriods, 1, 6, mc); + // Assertions.fail(); + // } catch (Exception e) { + // Assertions.assertInstanceOf(UnsupportedOperationException.class, e); + // } + // } - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final Money principal = Money.of(monetaryCurrency, BigDecimal.valueOf(100)); - final Money downPaymentValue = Money.of(monetaryCurrency, BigDecimal.valueOf(25)); - final Money outstandingBalance = principal.minus(downPaymentValue); - - 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); - - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods - .add(LoanScheduleModelDisbursementPeriod.disbursement(LocalDate.of(2024, 1, 1), principal, BigDecimal.ZERO)); - expectedRepaymentPeriods - .add(LoanScheduleModelDownPaymentPeriod.downPayment(1, LocalDate.of(2024, 1, 1), downPaymentValue, outstandingBalance)); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(5, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(6, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); - - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 5, mc); - - // 15.36 - Assertions.assertEquals(new BigDecimal("15.36"), result.getEqualMonthlyInstallmentValue().getAmount()); - - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - // check reset - Assertions.assertEquals(BigDecimal.valueOf(0.00790183333), result.getNextRepaymentPeriodRateFactorMinus1()); - } - - @Test - public void testEMICalculation_Principal1000_NoInterest_repayEvery1Month() { - final MathContext mc = MoneyHelper.getMathContext(); - - final BigDecimal interestRate = BigDecimal.valueOf(0); - final Money outstandingBalance = Money.of(monetaryCurrency, BigDecimal.valueOf(1000)); - - 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); - - final List expectedRepaymentPeriods = new ArrayList<>(); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); - - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 4, mc); - - // 250.00 - Assertions.assertEquals(new BigDecimal("250.00"), result.getEqualMonthlyInstallmentValue().getAmount()); - - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - // no more period, no more interest - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - - result.reset(); - // check reset - Assertions.assertEquals(BigDecimal.ZERO, result.getNextRepaymentPeriodRateFactorMinus1()); - } - - @Test - public void testUnsupportedRepaymentEveryYear() { - final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); - - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.YEARS); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); - - try { - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); - Assertions.fail(); - } catch (Exception e) { - Assertions.assertInstanceOf(UnsupportedOperationException.class, e); - } - } - - @Test - public void testUnsupportedRepaymentEveryWholeTerm() { - final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); - - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WHOLE_TERM); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); - - try { - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); - Assertions.fail(); - } catch (Exception e) { - Assertions.assertInstanceOf(UnsupportedOperationException.class, e); - } - } - - @Test - public void testInvalidRepaymentEveryValue() { - final MathContext mc = MoneyHelper.getMathContext(); - final List expectedRepaymentPeriods = new ArrayList<>(); - - final BigDecimal interestRate = BigDecimal.valueOf(9.4822); - final BigDecimal principal = BigDecimal.valueOf(100); - final Money outstandingBalance = Money.of(monetaryCurrency, principal); - - Mockito.when(loanProductRelatedDetail.getNominalInterestRatePerPeriod()).thenReturn(interestRate); - Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); - Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.INVALID.getValue()); - Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.INVALID); - Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); - - expectedRepaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 16))); - - try { - final EMICalculationResult result = emiCalculator.calculateEMIValueAndRateFactors(outstandingBalance, loanProductRelatedDetail, - expectedRepaymentPeriods, 1, 6, mc); - Assertions.fail(); - } catch (Exception e) { - Assertions.assertInstanceOf(UnsupportedOperationException.class, e); - } + private static LoanScheduleModelRepaymentPeriod repayment(int periodNumber, LocalDate fromDate, LocalDate dueDate) { + final Money zeroAmount = Money.zero(monetaryCurrency); + return LoanScheduleModelRepaymentPeriod.repayment(periodNumber, fromDate, dueDate, zeroAmount, zeroAmount, zeroAmount, zeroAmount, + zeroAmount, zeroAmount, false); } @NotNull @@ -605,4 +683,41 @@ class ProgressiveEMICalculatorTest { return period; } + + private static void checkPeriod(final ProgressiveLoanInterestScheduleModel interestScheduleModel, final int repaymentIdx, + final int interestIdx, final double emiValue, final double rateFactorMinus1, final double interestDue, + final double principalDue, final double remaingBalance) { + checkPeriod(interestScheduleModel, repaymentIdx, interestIdx, emiValue, rateFactorMinus1, interestDue, interestDue, principalDue, + remaingBalance); + } + + private static void checkPeriod(final ProgressiveLoanInterestScheduleModel interestScheduleModel, final int repaymentIdx, + final int interestIdx, final double emiValue, final double rateFactorMinus1, final double interestDue, + final double interestDueCumulated, final double principalDue, final double remaingBalance) { + final var repaymentPeriod = interestScheduleModel.repayments().get(repaymentIdx); + final var interestPeriod = repaymentPeriod.getInterestPeriods().get(interestIdx); + + Assertions.assertEquals(emiValue, toDouble(repaymentPeriod.getEqualMonthlyInstallment().getAmount())); + Assertions.assertEquals(rateFactorMinus1, toDouble(interestPeriod.getRateFactorMinus1())); + Assertions.assertEquals(interestDue, toDouble(interestPeriod.getInterestDue().getAmount())); + Assertions.assertEquals(interestDueCumulated, toDouble(repaymentPeriod.getInterestDue().getAmount())); + Assertions.assertEquals(principalDue, toDouble(repaymentPeriod.getPrincipalDue().getAmount())); + Assertions.assertEquals(remaingBalance, toDouble(repaymentPeriod.getRemainingBalance().getAmount())); + } + + private static void checkDisbursementOnPeriod(final ProgressiveLoanInterestScheduleModel interestScheduleModel, final int repaymentIdx, + final Money disbursedAmount) { + checkDisbursementOnPeriod(interestScheduleModel, repaymentIdx, disbursedAmount.getAmount().doubleValue()); + } + + private static void checkDisbursementOnPeriod(final ProgressiveLoanInterestScheduleModel interestScheduleModel, final int repaymentIdx, + final double disbursedAmount) { + final var repaymentPeriod = interestScheduleModel.repayments().get(repaymentIdx); + Assertions.assertEquals(disbursedAmount, toDouble(repaymentPeriod.getDisbursedAmountInPeriod().getAmount())); + } + + private static double toDouble(final BigDecimal value) { + return value == null ? 0 : value.doubleValue(); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/ApplyChargeToOverdueLoansBusinessStep.java b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/ApplyChargeToOverdueLoansBusinessStep.java index 3b89896bc..86a26eceb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/ApplyChargeToOverdueLoansBusinessStep.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/ApplyChargeToOverdueLoansBusinessStep.java @@ -37,7 +37,6 @@ public class ApplyChargeToOverdueLoansBusinessStep implements LoanCOBBusinessSte public Loan execute(Loan loan) { final Collection overdueLoanScheduleDataList = loanReadPlatformService .retrieveAllOverdueInstallmentsForLoan(loan); - loanChargeWritePlatformService.applyOverdueChargesForLoan(loan.getId(), overdueLoanScheduleDataList); return loan; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java index 83245dfbb..eb077751e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGenerator.java @@ -27,6 +27,7 @@ import net.fortuna.ical4j.model.Recur; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.holiday.domain.Holiday; import org.apache.fineract.organisation.holiday.service.HolidayUtil; +import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.workingdays.data.AdjustedDateDetailsDTO; import org.apache.fineract.organisation.workingdays.domain.RepaymentRescheduleType; import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil; @@ -36,6 +37,7 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarHistory; import org.apache.fineract.portfolio.calendar.service.CalendarUtils; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; +import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; import org.springframework.stereotype.Component; @Component @@ -43,30 +45,46 @@ import org.springframework.stereotype.Component; public class DefaultScheduledDateGenerator implements ScheduledDateGenerator { @Override - public List generateRepaymentPeriods(final LocalDate scheduledDueDate, + public List generateRepaymentPeriods(final LocalDate scheduleStartDate, final LoanApplicationTerms loanApplicationTerms, final HolidayDetailDTO holidayDetailDTO) { + final Money zeroAmount = Money.zero(loanApplicationTerms.getCurrency()); final int numberOfRepayments = loanApplicationTerms.getNumberOfRepayments(); - final ArrayList repaymentPeriods = new ArrayList<>(numberOfRepayments); - LocalDate lastRepaymentDate = scheduledDueDate; + final ArrayList repaymentPeriods = new ArrayList<>(numberOfRepayments); + LocalDate lastRepaymentDate = scheduleStartDate; LocalDate nextRepaymentDate; boolean isFirstRepayment = true; - for (int repaymentPeriod = 1; repaymentPeriod <= numberOfRepayments; repaymentPeriod++) { + for (int repaymentPeriodNumber = 1; repaymentPeriodNumber <= numberOfRepayments; repaymentPeriodNumber++) { nextRepaymentDate = generateNextRepaymentDate(lastRepaymentDate, loanApplicationTerms, isFirstRepayment); - if (repaymentPeriod == numberOfRepayments) { // last period + if (repaymentPeriodNumber == numberOfRepayments) { // last period if (loanApplicationTerms.getFixedLength() != null && loanApplicationTerms.calculateMaxDateForFixedLength().compareTo(nextRepaymentDate) != 0) { nextRepaymentDate = loanApplicationTerms.calculateMaxDateForFixedLength(); } nextRepaymentDate = adjustRepaymentDate(nextRepaymentDate, loanApplicationTerms, holidayDetailDTO).getChangedScheduleDate(); } - repaymentPeriods.add(new PreGeneratedLoanSchedulePeriod(repaymentPeriod, lastRepaymentDate, nextRepaymentDate)); + nextRepaymentDate = applyLoanTermVariations(loanApplicationTerms, nextRepaymentDate); + repaymentPeriods.add(LoanScheduleModelRepaymentPeriod.repayment(repaymentPeriodNumber, lastRepaymentDate, nextRepaymentDate, + zeroAmount, zeroAmount, zeroAmount, zeroAmount, zeroAmount, zeroAmount, false)); lastRepaymentDate = nextRepaymentDate; isFirstRepayment = false; } return repaymentPeriods; } + private LocalDate applyLoanTermVariations(final LoanApplicationTerms loanApplicationTerms, final LocalDate scheduledDueDate) { + LocalDate modifiedScheduledDueDate = scheduledDueDate; + // due date changes should be applied only for that dueDate + if (loanApplicationTerms.getLoanTermVariations().hasDueDateVariation(scheduledDueDate)) { + LoanTermVariationsData loanTermVariationsData = loanApplicationTerms.getLoanTermVariations().nextDueDateVariation(); + if (DateUtils.isEqual(modifiedScheduledDueDate, loanTermVariationsData.getTermVariationApplicableFrom())) { + modifiedScheduledDueDate = loanTermVariationsData.getDateValue(); + loanApplicationTerms.updateVariationDays(DateUtils.getDifferenceInDays(scheduledDueDate, modifiedScheduledDueDate)); + } + } + return modifiedScheduledDueDate; + } + @Override public LocalDate getLastRepaymentDate(final LoanApplicationTerms loanApplicationTerms, final HolidayDetailDTO holidayDetailDTO) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java index 4f14bd250..12295fd05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java @@ -114,8 +114,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationT import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; -import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.exception.InstallmentNotFoundException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; @@ -757,7 +757,6 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo @Transactional @Override public void applyOverdueChargesForLoan(final Long loanId, Collection overdueLoanScheduleDataList) { - Loan loan = this.loanAssembler.assembleFrom(loanId); if (loan.isChargedOff()) { log.warn("Adding charge to Loan: {} is not allowed. Loan Account is Charged-off", loanId); @@ -844,7 +843,7 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo .determineProcessor(loan.transactionProcessingStrategy()); loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanChargeAdjustmentTransaction, new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), - new MoneyHolder(loan.getTotalOverpaidAsMoney()))); + new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); loan.addLoanTransaction(loanChargeAdjustmentTransaction); loan.updateLoanSummaryAndStatus(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 823a99201..8e02a793c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -175,6 +175,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepayme import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.exception.DateMismatchException; import org.apache.fineract.portfolio.loanaccount.exception.ExceedingTrancheCountException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; @@ -1111,9 +1112,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf if (isTransactionChronologicallyLatest && (!reprocess || !loan.getLoanRepaymentScheduleDetail().isInterestRecalculationEnabled()) && !loan.isForeclosure()) { loanRepaymentScheduleTransactionProcessor.processLatestTransaction(newInterestPaymentWaiverTransaction, - new LoanRepaymentScheduleTransactionProcessor.TransactionCtx(loan.getCurrency(), - loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), - new MoneyHolder(loan.getTotalOverpaidAsMoney()))); + new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), + new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); reprocess = false; if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) { if (currentInstallment == null || currentInstallment.isNotFullyPaidOff()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java index 33909fa7d..5fccb83f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java @@ -50,6 +50,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParamet import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; import org.apache.fineract.portfolio.note.domain.Note; @@ -87,9 +88,8 @@ public class LoanReAgingServiceImpl { final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory .determineProcessor(loan.transactionProcessingStrategy()); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction, - new LoanRepaymentScheduleTransactionProcessor.TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), - loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()))); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction, new TransactionCtx(loan.getCurrency(), + loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); loan.updateLoanScheduleDependentDerivedFields(); persistNote(loan, command, changes); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reamortization/LoanReAmortizationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reamortization/LoanReAmortizationServiceImpl.java index 880b85663..1546f6c93 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reamortization/LoanReAmortizationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reamortization/LoanReAmortizationServiceImpl.java @@ -47,6 +47,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -75,9 +76,8 @@ public class LoanReAmortizationServiceImpl { final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory .determineProcessor(loan.transactionProcessingStrategy()); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAmortizeTransaction, - new LoanRepaymentScheduleTransactionProcessor.TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), - loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()))); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAmortizeTransaction, new TransactionCtx(loan.getCurrency(), + loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); loanTransactionRepository.saveAndFlush(reAmortizeTransaction); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java index 905f4f4bd..47b820906 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java @@ -20,7 +20,6 @@ package org.apache.fineract.portfolio.loanaccount.starter; import java.util.List; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory; -import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; 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.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor; @@ -32,6 +31,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.PrincipalInterestPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.RBILoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; @@ -104,9 +104,8 @@ public class LoanAccountAutoStarter { @Bean @Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class) - public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor( - LoanReAgingParameterRepository reAgingParameterRepository) { - return new AdvancedPaymentScheduleTransactionProcessor(reAgingParameterRepository); + public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor(EMICalculator emiCalculator) { + return new AdvancedPaymentScheduleTransactionProcessor(emiCalculator); } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java index 442e4cd33..3dc81175d 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java @@ -98,7 +98,7 @@ public class DefaultScheduledDateGeneratorTest { submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null); // when - List result = underTest.generateRepaymentPeriods(expectedDisbursementDate, loanApplicationTerms, + List result = underTest.generateRepaymentPeriods(expectedDisbursementDate, loanApplicationTerms, holidayDetailDTO); // then diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java index be43cc313..9f5a5f150 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java @@ -4257,79 +4257,6 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan }); } - // UC139c: Advanced payment allocation, with DownPayment enabled in Loan Product and change the auto downpayment and - // percentage amount - // ADVANCED_PAYMENT_ALLOCATION_STRATEGY - // 1. Create a Loan product with Adv. Pment. Alloc. and DownPayment - // 2. Submit Loan with DownPayment and approve - // 3. Modify Loan Application change the auto downpayment and percentage amount - // 4. Disburse only 10 - // 5. Check the repayment schedule is correct, without DownPayment installment - @Test - public void uc139c() { - final String processDate = "23 March 2024"; - final Double amount = 1000.0; - runAt(processDate, () -> { - BigDecimal percentageAmount = BigDecimal.valueOf(25); - PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() - .installmentAmountInMultiplesOf(null).numberOfRepayments(3).repaymentEvery(15).enableDownPayment(true) - .enableAutoRepaymentForDownPayment(true).disbursedAmountPercentageForDownPayment(percentageAmount); - PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); - PostLoansRequest applicationRequest = applyLoanRequest(client.getClientId(), loanProductResponse.getResourceId(), processDate, - amount, 4); - - applicationRequest = applicationRequest.numberOfRepayments(3).loanTermFrequency(45) - .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY).repaymentEvery(15) - .enableDownPayment(true); - - PostLoansResponse loanResponse = loanTransactionHelper.applyLoan(applicationRequest); - - GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); - assertNotNull(loanDetails); - assertEquals(true, loanDetails.getEnableDownPayment()); - assertEquals(true, loanDetails.getEnableAutoRepaymentForDownPayment()); - - // verify schedule (with DownPayment and AutoDownPayment to 25%) - verifyRepaymentSchedule(loanResponse.getLoanId(), // - installment(1000, null, "23 March 2024"), // - installment(250.0, 0, 0, 0, 250.0, false, "23 March 2024", 750.0), // - installment(250.0, 0, 0, 0, 250.0, false, "07 April 2024", 500.0), // - installment(250.0, 0, 0, 0, 250.0, false, "22 April 2024", 250.0), // - installment(250.0, 0, 0, 0, 250.0, false, "07 May 2024", 0.0) // - ); - - percentageAmount = BigDecimal.valueOf(15); - loanTransactionHelper.modifyApplicationForLoan(loanResponse.getLoanId(), "modify", - new PutLoansLoanIdRequest().clientId(client.getClientId()).productId(loanProductResponse.getResourceId()) - .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) - .repaymentEvery(15).interestCalculationPeriodType(1).interestType(0).expectedDisbursementDate(processDate) - .principal(amount.longValue()).repaymentFrequencyType(0).numberOfRepayments(3).loanTermFrequency(45) - .loanTermFrequencyType(0).enableDownPayment(true).loanType("individual").dateFormat(DATETIME_PATTERN) - .locale("en").enableAutoRepaymentForDownPayment(true) - .disbursedAmountPercentageForDownPayment(percentageAmount)); - - loanTransactionHelper.approveLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest() - .approvedLoanAmount(BigDecimal.valueOf(1000)).dateFormat(DATETIME_PATTERN).approvedOnDate(processDate).locale("en")); - - loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest().actualDisbursementDate(processDate) - .dateFormat(DATETIME_PATTERN).transactionAmount(BigDecimal.valueOf(1000.0)).locale("en")); - - loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); - assertNotNull(loanDetails); - assertEquals(true, loanDetails.getEnableDownPayment()); - assertEquals(true, loanDetails.getEnableAutoRepaymentForDownPayment()); - - // verify schedule (with DownPayment and AutoDownPayment to 15%) - verifyRepaymentSchedule(loanResponse.getLoanId(), // - installment(1000, null, "23 March 2024"), // - installment(150, 0, 0, 0, 0.0, true, "23 March 2024", 850), // - installment(283.33, 0, 0, 0, 283.33, false, "07 April 2024", 566.67), // - installment(283.33, 0, 0, 0, 283.33, false, "22 April 2024", 283.34), // - installment(283.34, 0, 0, 0, 283.34, false, "07 May 2024", 0.0) // - ); - }); - } - // uc140: Negative Test: Advanced payment allocation, with DownPayment disabled in Loan Product but try to enable in // Loan application // ADVANCED_PAYMENT_ALLOCATION_STRATEGY @@ -4996,6 +4923,178 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan }); } + // UC14-: Advanced payment allocation with Interest, originally calculated EMI, need adjustment, adjusted EMI is not + // better than original, use original EMI and apply with multi disbursement + // ADVANCED_PAYMENT_ALLOCATION_STRATEGY + // 1. Create a Loan product with Adv. Payment. Alloc. and with 5% Interest, 360/30, 1 repayment per month + // 2. Submit Loan and approve + // 3. Disburse + // 4. Validate Repayment Schedule + @Test + public void uc147a() { + final String operationDate = "1 January 2024"; + runAt(operationDate, () -> { + BigDecimal interestRatePerPeriod = BigDecimal.valueOf(9.4822); + + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() + .interestRatePerPeriod(interestRatePerPeriod.doubleValue()).interestCalculationPeriodType(RepaymentFrequencyType.DAYS) + .interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.DAYS_30.getValue()) + .daysInYearType(DaysInYearType.DAYS_360.getValue()).numberOfRepayments(6)// + .repaymentEvery(1)// + .repaymentFrequencyType(2L)// + .enableDownPayment(false)// + .allowPartialPeriodInterestCalcualtion(false)// + .enableAutoRepaymentForDownPayment(null)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType("flat")// + .overAppliedNumber(10000)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .installmentAmountInMultiplesOf(null)// + ;// + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductResponse.getResourceId(), operationDate, 100.0, 6); + + applicationRequest = applicationRequest.numberOfRepayments(6)// + .loanTermFrequency(6)// + .loanTermFrequencyType(2)// + .interestRatePerPeriod(interestRatePerPeriod).interestCalculationPeriodType(DAYS)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)// + .repaymentEvery(1)// + .repaymentFrequencyType(2)// + .maxOutstandingLoanBalance(BigDecimal.valueOf(10000.0))// + ;// + + PostLoansResponse loanResponse = loanTransactionHelper.applyLoan(applicationRequest); + + loanTransactionHelper.approveLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest() + .approvedLoanAmount(BigDecimal.valueOf(100)).dateFormat(DATETIME_PATTERN).approvedOnDate(operationDate).locale("en")); + + loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest().actualDisbursementDate(operationDate) + .dateFormat(DATETIME_PATTERN).transactionAmount(BigDecimal.valueOf(100.0)).locale("en")); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); + validateLoanSummaryBalances(loanDetails, 102.78, 0.0, 100.0, 0.0, null); + + String disbursementDate2nd = "8 January 2024"; + updateBusinessDate(disbursementDate2nd); + loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), + new PostLoansLoanIdRequest().actualDisbursementDate(disbursementDate2nd).dateFormat(DATETIME_PATTERN) + .transactionAmount(BigDecimal.valueOf(200.0)).locale("en")); + + loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); + validateLoanSummaryBalances(loanDetails, 307.99, 0.0, 300.0, 0.0, null); + + validateRepaymentPeriod(loanDetails, 1, LocalDate.of(2024, 2, 1), 49.31, 0.0, 49.31, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.02, 0.0, + 2.02, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 2, LocalDate.of(2024, 3, 1), 49.35, 0.0, 49.35, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.98, 0.0, + 1.98, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 3, LocalDate.of(2024, 4, 1), 49.74, 0.0, 49.74, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.59, 0.0, + 1.59, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 4, LocalDate.of(2024, 5, 1), 50.13, 0.0, 50.13, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.20, 0.0, + 1.20, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 5, LocalDate.of(2024, 6, 1), 50.53, 0.0, 50.53, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.80, 0.0, + 0.80, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 6, LocalDate.of(2024, 7, 1), 50.94, 0.0, 50.94, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.40, 0.0, + 0.40, 0.0, 0.0); + assertTrue(loanDetails.getStatus().getActive()); + assertEquals(loanDetails.getNumberOfRepayments(), 6); + }); + } + + // UC14-: Advanced payment allocation with Interest, originally calculated EMI, need adjustment, adjusted EMI is not + // better than original, use original EMI and apply with multi disbursement in two different periods + // ADVANCED_PAYMENT_ALLOCATION_STRATEGY + // 1. Create a Loan product with Adv. Payment. Alloc. and with 5% Interest, 360/30, 1 repayment per month + // 2. Submit Loan and approve + // 3. Disburse + // 4. Validate Repayment Schedule + @Test + public void uc147b() { + final String operationDate = "1 January 2024"; + runAt(operationDate, () -> { + BigDecimal interestRatePerPeriod = BigDecimal.valueOf(9.4822); + + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() + .interestRatePerPeriod(interestRatePerPeriod.doubleValue()).interestCalculationPeriodType(RepaymentFrequencyType.DAYS) + .interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.DAYS_30.getValue()) + .daysInYearType(DaysInYearType.DAYS_360.getValue()).numberOfRepayments(6)// + .repaymentEvery(1)// + .repaymentFrequencyType(2L)// + .enableDownPayment(false)// + .allowPartialPeriodInterestCalcualtion(false)// + .enableAutoRepaymentForDownPayment(null)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType("flat")// + .overAppliedNumber(10000)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .installmentAmountInMultiplesOf(null)// + ;// + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductResponse.getResourceId(), operationDate, 100.0, 5); + + applicationRequest = applicationRequest.numberOfRepayments(6)// + .loanTermFrequency(6)// + .loanTermFrequencyType(2)// + .interestRatePerPeriod(interestRatePerPeriod).interestCalculationPeriodType(DAYS)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)// + .repaymentEvery(1)// + .repaymentFrequencyType(2)// + .maxOutstandingLoanBalance(BigDecimal.valueOf(10000.0))// + ;// + + PostLoansResponse loanResponse = loanTransactionHelper.applyLoan(applicationRequest); + + loanTransactionHelper.approveLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest() + .approvedLoanAmount(BigDecimal.valueOf(100)).dateFormat(DATETIME_PATTERN).approvedOnDate(operationDate).locale("en")); + + loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest().actualDisbursementDate(operationDate) + .dateFormat(DATETIME_PATTERN).transactionAmount(BigDecimal.valueOf(100.0)).locale("en")); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); + validateLoanSummaryBalances(loanDetails, 102.78, 0.0, 100.0, 0.0, null); + + final String repaymentDate = "1 February 2024"; + updateBusinessDate(repaymentDate); + loanTransactionHelper.makeRepayment(repaymentDate, 17.13f, loanResponse.getLoanId().intValue()); + + loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); + + validateLoanSummaryBalances(loanDetails, 85.65, 17.13, 83.66, 16.34, null); + + final String dateOf2ndDisbursement = "15 February 2024"; + updateBusinessDate(dateOf2ndDisbursement); + loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), + new PostLoansLoanIdRequest().actualDisbursementDate(dateOf2ndDisbursement).dateFormat(DATETIME_PATTERN) + .transactionAmount(BigDecimal.valueOf(100.0)).locale("en")); + + loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); + validateLoanSummaryBalances(loanDetails, 187.65, 17.13, 183.66, 16.34, null); + + validateRepaymentPeriod(loanDetails, 1, LocalDate.of(2024, 2, 1), 16.34, 16.34, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.79, 0.79, + 0.0, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 2, LocalDate.of(2024, 3, 1), 36.46, 0.0, 36.46, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.07, 0.0, + 1.07, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 3, LocalDate.of(2024, 4, 1), 36.37, 0.0, 36.37, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.16, 0.0, + 1.16, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 4, LocalDate.of(2024, 5, 1), 36.65, 0.0, 36.65, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.88, 0.0, + 0.88, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 5, LocalDate.of(2024, 6, 1), 36.94, 0.0, 36.94, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.59, 0.0, + 0.59, 0.0, 0.0); + validateRepaymentPeriod(loanDetails, 6, LocalDate.of(2024, 7, 1), 37.24, 0.0, 37.24, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.29, 0.0, + 0.29, 0.0, 0.0); + assertTrue(loanDetails.getStatus().getActive()); + assertEquals(loanDetails.getNumberOfRepayments(), 6); + }); + } + private Long applyAndApproveLoanProgressiveAdvancedPaymentAllocationStrategyMonthlyRepayments(Long clientId, Long loanProductId, Integer numberOfRepayments, String loanDisbursementDate, double amount) { LOG.info("------------------------------APPLY AND APPROVE LOAN ---------------------------------------");