FINERACT-1981: EMI calculator rework

This commit is contained in:
Janos Meszaros
2024-10-16 14:35:58 +02:00
committed by Adam Saghy
parent d4190ede90
commit bb072709b5
17 changed files with 1082 additions and 1146 deletions
@@ -481,4 +481,14 @@ public final class MathUtil {
public static BigDecimal stripTrailingZeros(final BigDecimal value) { public static BigDecimal stripTrailingZeros(final BigDecimal value) {
return value == null ? null : new BigDecimal(value.stripTrailingZeros().toPlainString()); return value == null ? null : new BigDecimal(value.stripTrailingZeros().toPlainString());
} }
/**
* @return calculates maximum of the two values considering null values
* @param notNull
* if true then null parameter is omitted, otherwise returns null
*/
public static Money max(Money first, Money second, boolean notNull) {
return notNull ? first == null ? second : second == null ? first : max(first, second, false)
: isGreaterThan(first, second) ? first : second;
}
} }
@@ -497,10 +497,7 @@ public class LoanProductGlobalInitializerStep implements FineractGlobalInitializ
.recalculationRestFrequencyType(2)// .recalculationRestFrequencyType(2)//
.recalculationRestFrequencyInterval(1)// .recalculationRestFrequencyInterval(1)//
.paymentAllocation(List.of(// .paymentAllocation(List.of(//
createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), // createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT")));//
createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), //
createPaymentAllocation("MERCHANT_ISSUED_REFUND", "REAMORTIZATION"), //
createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT")));//
Response<PostLoanProductsResponse> responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcTillPreClose = loanProductsApi Response<PostLoanProductsResponse> responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcTillPreClose = loanProductsApi
.createLoanProduct(loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcTillPreclose).execute(); .createLoanProduct(loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcTillPreclose).execute();
TestContext.INSTANCE.set( TestContext.INSTANCE.set(
@@ -5539,7 +5539,6 @@ Feature: Loan
Scenario: Early pay-off loan with interest, TILL_PRECLOSE product Scenario: Early pay-off loan with interest, TILL_PRECLOSE product
When Admin sets the business date to "01 January 2024" When Admin sets the business date to "01 January 2024"
When Admin creates a client with random data When Admin creates a client with random data
When Admin set "LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule
When Admin creates a fully customized loan with the following data: When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE | 01 January 2024 | 100 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE | 01 January 2024 | 100 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
@@ -5595,7 +5594,6 @@ Feature: Loan
| 01 February 2024 | Repayment | 17.01 | 16.43 | 0.58 | 0.0 | 0.0 | 83.57 | false | | 01 February 2024 | Repayment | 17.01 | 16.43 | 0.58 | 0.0 | 0.0 | 83.57 | false |
| 15 February 2024 | Repayment | 83.81 | 83.57 | 0.24 | 0.0 | 0.0 | 0.0 | false | | 15 February 2024 | Repayment | 83.81 | 83.57 | 0.24 | 0.0 | 0.0 | 0.0 | false |
Then Loan's all installments have obligations met Then Loan's all installments have obligations met
When Admin set "LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE" loan product "DEFAULT" transaction type to "LAST_INSTALLMENT" future installment allocation rule
Scenario: Early pay-off loan with interest, TILL_REST_FREQUENCY_DATE product Scenario: Early pay-off loan with interest, TILL_REST_FREQUENCY_DATE product
When Admin sets the business date to "01 January 2024" When Admin sets the business date to "01 January 2024"
@@ -79,7 +79,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParamet
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor; 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.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestRepaymentModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.PayableDetails;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
@@ -171,12 +171,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<ChargeOrTransaction> chargeOrTransactions = createSortedChargesAndTransactionsList(loanTransactions, charges); List<ChargeOrTransaction> chargeOrTransactions = createSortedChargesAndTransactionsList(loanTransactions, charges);
MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(currency)); MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(currency));
final MathContext mc = MoneyHelper.getMathContext();
final Loan loan = loanTransactions.get(0).getLoan(); final Loan loan = loanTransactions.get(0).getLoan();
final Integer installmentAmountInMultiplesOf = loan.getLoanProduct().getInstallmentAmountInMultiplesOf(); final Integer installmentAmountInMultiplesOf = loan.getLoanProduct().getInstallmentAmountInMultiplesOf();
final LoanProductRelatedDetail loanProductRelatedDetail = loan.getLoanRepaymentScheduleDetail(); final LoanProductRelatedDetail loanProductRelatedDetail = loan.getLoanRepaymentScheduleDetail();
ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateModel(loanProductRelatedDetail, ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateModel(loanProductRelatedDetail,
installmentAmountInMultiplesOf, installments, mc); installmentAmountInMultiplesOf, installments);
ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder, ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder,
changedTransactionDetail, scheduleModel); changedTransactionDetail, scheduleModel);
@@ -204,7 +203,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
LoanTransaction newTransaction = newTransactionMappings.get(oldTransactionId); LoanTransaction newTransaction = newTransactionMappings.get(oldTransactionId);
createNewTransaction(oldTransaction, newTransaction, ctx); createNewTransaction(oldTransaction, newTransaction, ctx);
} }
recalculateInterestForDate(ThreadLocalContextUtil.getBusinessDate(), ctx, true); recalculateInterestForDate(ThreadLocalContextUtil.getBusinessDate(), ctx);
List<LoanTransaction> txs = chargeOrTransactions.stream() // List<LoanTransaction> txs = chargeOrTransactions.stream() //
.filter(ChargeOrTransaction::isTransaction) // .filter(ChargeOrTransaction::isTransaction) //
.map(e -> e.getLoanTransaction().get()).toList(); .map(e -> e.getLoanTransaction().get()).toList();
@@ -778,14 +777,14 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
amortizableAmount); amortizableAmount);
if (amortizableAmount.isGreaterThanZero()) { if (amortizableAmount.isGreaterThanZero()) {
progressiveTransactionCtx.getModel().repayments().forEach(rm -> { progressiveTransactionCtx.getModel().repaymentPeriods().forEach(rm -> {
LoanRepaymentScheduleInstallment installment = transactionCtx.getInstallments().stream() LoanRepaymentScheduleInstallment installment = transactionCtx.getInstallments().stream()
.filter(ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment() .filter(ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment()
&& !ri.getDueDate().isBefore(disbursementTransaction.getTransactionDate())) && !ri.getDueDate().isBefore(disbursementTransaction.getTransactionDate()))
.findFirst().orElse(null); .findFirst().orElse(null);
if (installment != null) { if (installment != null) {
installment.updatePrincipal(rm.getPrincipalDue().getAmount()); installment.updatePrincipal(rm.getDuePrincipal().getAmount());
installment.updateInterestCharged(rm.getInterestDue().getAmount()); installment.updateInterestCharged(rm.getDueInterest().getAmount());
installment.updateObligationsMet(progressiveTransactionCtx.getCurrency(), disbursementTransaction.getTransactionDate()); installment.updateObligationsMet(progressiveTransactionCtx.getCurrency(), disbursementTransaction.getTransactionDate());
} }
}); });
@@ -877,7 +876,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
.sorted(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).toList(); .sorted(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).toList();
} }
private void recalculateInterestForDate(LocalDate currentDate, ProgressiveTransactionCtx ctx, boolean isLastRecalculation) { private void recalculateInterestForDate(LocalDate currentDate, ProgressiveTransactionCtx ctx) {
if (ctx.getInstallments() != null && !ctx.getInstallments().isEmpty() if (ctx.getInstallments() != null && !ctx.getInstallments().isEmpty()
&& ctx.getInstallments().get(0).getLoan().isInterestRecalculationEnabledForProduct() && ctx.getInstallments().get(0).getLoan().isInterestRecalculationEnabledForProduct()
&& !ctx.getInstallments().get(0).getLoan().isNpa() && !ctx.getInstallments().get(0).getLoan().isChargedOff()) { && !ctx.getInstallments().get(0).getLoan().isNpa() && !ctx.getInstallments().get(0).getLoan().isChargedOff()) {
@@ -897,27 +896,32 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
LoanRepaymentScheduleInstallment currentInstallment = currentInstallmentOptional.orElse(lastInstallment); LoanRepaymentScheduleInstallment currentInstallment = currentInstallmentOptional.orElse(lastInstallment);
Money overDuePrincipal = Money.zero(ctx.getCurrency()); Money overDuePrincipal = Money.zero(ctx.getCurrency());
Money aggregatedOverDuePrincipal = Money.zero(ctx.getCurrency());
for (LoanRepaymentScheduleInstallment processingInstallment : overdueInstallmentsSortedByInstallmentNumber) { for (LoanRepaymentScheduleInstallment processingInstallment : overdueInstallmentsSortedByInstallmentNumber) {
// add and subtract outstanding principal // add and subtract outstanding principal
if (!overDuePrincipal.isZero()) { if (!overDuePrincipal.isZero()) {
adjustOverduePrincipalForInstallment(currentDate, isLastRecalculation, processingInstallment, overDuePrincipal, adjustOverduePrincipalForInstallment(currentDate, processingInstallment, overDuePrincipal,
ctx); aggregatedOverDuePrincipal, ctx);
} }
overDuePrincipal = overDuePrincipal.add(processingInstallment.getPrincipalOutstanding(ctx.getCurrency()).getAmount()); overDuePrincipal = processingInstallment.getPrincipalOutstanding(ctx.getCurrency());
aggregatedOverDuePrincipal = aggregatedOverDuePrincipal.add(overDuePrincipal);
} }
boolean adjustNeeded = !currentInstallment.equals(lastInstallment) || !lastInstallment.isOverdueOn(currentDate); boolean adjustNeeded = !currentInstallment.equals(lastInstallment) || !lastInstallment.isOverdueOn(currentDate);
if (adjustNeeded) { if (adjustNeeded) {
adjustOverduePrincipalForInstallment(currentDate, isLastRecalculation, currentInstallment, overDuePrincipal, ctx); adjustOverduePrincipalForInstallment(currentDate, currentInstallment, overDuePrincipal, aggregatedOverDuePrincipal,
ctx);
} }
} }
} }
} }
private void adjustOverduePrincipalForInstallment(LocalDate currentDate, boolean isLastRecalculation, private void adjustOverduePrincipalForInstallment(LocalDate currentDate, LoanRepaymentScheduleInstallment currentInstallment,
LoanRepaymentScheduleInstallment currentInstallment, Money overduePrincipal, ProgressiveTransactionCtx ctx) { Money overduePrincipal, Money aggregatedOverDuePrincipal, ProgressiveTransactionCtx ctx) {
LocalDate fromDate = currentInstallment.getFromDate(); LocalDate fromDate = currentInstallment.getFromDate();
LocalDate toDate = currentInstallment.getDueDate();
boolean hasUpdate = false; boolean hasUpdate = false;
if (currentInstallment.getLoan().getLoanInterestRecalculationDetails().getRestFrequencyType().isSameAsRepayment()) { if (currentInstallment.getLoan().getLoanInterestRecalculationDetails().getRestFrequencyType().isSameAsRepayment()) {
@@ -927,6 +931,12 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
emiCalculator.addBalanceCorrection(ctx.getModel(), fromDate, overduePrincipal); emiCalculator.addBalanceCorrection(ctx.getModel(), fromDate, overduePrincipal);
ctx.setLastOverdueBalanceChange(fromDate); ctx.setLastOverdueBalanceChange(fromDate);
hasUpdate = true; hasUpdate = true;
if (currentDate.isAfter(fromDate) && !currentDate.isAfter(toDate)) {
emiCalculator.addBalanceCorrection(ctx.getModel(), currentInstallment.getDueDate(),
aggregatedOverDuePrincipal.negated());
ctx.setLastOverdueBalanceChange(currentInstallment.getDueDate());
}
} }
} }
@@ -946,8 +956,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
// setting negative correction for the period from current date, expecting the overdue balance's full // setting negative correction for the period from current date, expecting the overdue balance's full
// repayment on that day. // repayment on that day.
if (currentDate.isAfter(currentInstallment.getFromDate()) && currentDate.isBefore(currentInstallment.getDueDate())) { // TODO: we might need to do it outside of this method only for the current date at the end
emiCalculator.addBalanceCorrection(ctx.getModel(), currentDate, overduePrincipal.negated()); if (currentDate.isAfter(currentInstallment.getFromDate()) && !currentDate.isAfter(currentInstallment.getDueDate())) {
emiCalculator.addBalanceCorrection(ctx.getModel(), currentDate, aggregatedOverDuePrincipal.negated());
ctx.setLastOverdueBalanceChange(currentDate); ctx.setLastOverdueBalanceChange(currentDate);
} }
hasUpdate = true; hasUpdate = true;
@@ -959,13 +970,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
} }
private void updateInstallmentsPrincipalAndInterestByModel(ProgressiveTransactionCtx ctx) { private void updateInstallmentsPrincipalAndInterestByModel(ProgressiveTransactionCtx ctx) {
ctx.getModel().repayments().forEach(repayment -> { ctx.getModel().repaymentPeriods().forEach(repayment -> {
LoanRepaymentScheduleInstallment installment = ctx.getInstallments().stream() LoanRepaymentScheduleInstallment installment = ctx.getInstallments().stream()
.filter(ri -> !ri.isDownPayment() && Objects.equals(ri.getFromDate(), repayment.getFromDate())) // .filter(ri -> !ri.isDownPayment() && Objects.equals(ri.getFromDate(), repayment.getFromDate())) //
.findFirst().orElse(null); .findFirst().orElse(null);
if (installment != null) { if (installment != null) {
installment.updatePrincipal(repayment.getPrincipalDue().getAmount()); installment.updatePrincipal(repayment.getDuePrincipal().getAmount());
installment.updateInterestCharged(repayment.getInterestDue().getAmount()); installment.updateInterestCharged(repayment.getDueInterest().getAmount());
installment.setRecalculatedInterestComponent(true); installment.setRecalculatedInterestComponent(true);
} }
}); });
@@ -973,7 +984,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private void handleRepayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { private void handleRepayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) {
if (transactionCtx instanceof ProgressiveTransactionCtx) { if (transactionCtx instanceof ProgressiveTransactionCtx) {
recalculateInterestForDate(loanTransaction.getTransactionDate(), (ProgressiveTransactionCtx) transactionCtx, false); recalculateInterestForDate(loanTransaction.getTransactionDate(), (ProgressiveTransactionCtx) transactionCtx);
} }
if (loanTransaction.isRepaymentLikeType() || loanTransaction.isInterestWaiver() || loanTransaction.isRecoveryRepayment()) { if (loanTransaction.isRepaymentLikeType() || loanTransaction.isInterestWaiver() || loanTransaction.isRecoveryRepayment()) {
loanTransaction.resetDerivedComponents(); loanTransaction.resetDerivedComponents();
@@ -1412,9 +1423,17 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
firstNormalInstallmentNumber); firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, oldestPastDueInstallment, currency); transactionMappings, loanTransaction, oldestPastDueInstallment, currency);
paidPortion = processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment, loanTransaction, Loan loan = loanTransaction.getLoan();
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing()
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); && loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) {
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction,
transactionAmountUnprocessed, balances, paymentAllocationType, oldestPastDueInstallment, ctx,
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges);
} else {
paidPortion = processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
} else { } else {
exit = true; exit = true;
@@ -1426,9 +1445,17 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
firstNormalInstallmentNumber); firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, dueInstallment, currency); transactionMappings, loanTransaction, dueInstallment, currency);
paidPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction, Loan loan = loanTransaction.getLoan();
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing()
balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); && loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) {
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction,
transactionAmountUnprocessed, balances, paymentAllocationType, dueInstallment, ctx,
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges);
} else {
paidPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges,
balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
} else { } else {
exit = true; exit = true;
@@ -1452,56 +1479,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Loan loan = loanTransaction.getLoan(); Loan loan = loanTransaction.getLoan();
if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing() if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing()
&& loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) { && loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) {
ProgressiveLoanInterestScheduleModel model = ctx.getModel(); paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction, evenPortion,
LoanPreClosureInterestCalculationStrategy strategy = loanTransaction.getLoan().getLoanProduct() balances, paymentAllocationType, inAdvanceInstallment, ctx,
.preCloseInterestCalculationStrategy(); loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges);
LocalDate payDate = switch (strategy) {
case TILL_PRE_CLOSURE_DATE -> {
LocalDate transactionDate = loanTransaction.getTransactionDate();
yield inAdvanceInstallment.getFromDate().isAfter(transactionDate)
? inAdvanceInstallment.getFromDate()
: transactionDate;
}
case TILL_REST_FREQUENCY_DATE -> inAdvanceInstallment.getDueDate();
case NONE ->
throw new IllegalStateException("Unexpected PreClosureInterestCalculationStrategy: NONE");
};
ProgressiveLoanInterestRepaymentModel payableDetails = emiCalculator
.getPayableDetails(model, inAdvanceInstallment.getDueDate(), payDate).orElseThrow();
switch (paymentAllocationType) {
case IN_ADVANCE_INTEREST ->
inAdvanceInstallment.updateInterestCharged(payableDetails.getInterestDue().getAmount());
case IN_ADVANCE_PRINCIPAL ->
inAdvanceInstallment.updatePrincipal(payableDetails.getPrincipalDue().getAmount());
default -> {
}
}
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
inAdvanceInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
switch (paymentAllocationType) {
case IN_ADVANCE_PRINCIPAL -> {
Money balance = switch (strategy) {
case TILL_PRE_CLOSURE_DATE -> payableDetails.getOutstandingBalance();
case TILL_REST_FREQUENCY_DATE -> payableDetails.getRemainingBalance();
default -> throw new IllegalStateException();
};
emiCalculator.addBalanceCorrection(model, payDate, balance.multipliedBy(-1));
emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getPrincipalDue().minus(paidPortion));
}
case IN_ADVANCE_INTEREST -> {
emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getInterestDue().minus(paidPortion));
}
default -> {
}
}
} else { } else {
// Adjust the portion for the last installment // Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
@@ -1527,8 +1507,74 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
return transactionAmountUnprocessed; return transactionAmountUnprocessed;
} }
private Money handlingPaymentAllocationForInterestBearingProgressiveLoan(LoanTransaction loanTransaction,
Money transactionAmountUnprocessed, Balances balances, PaymentAllocationType paymentAllocationType,
LoanRepaymentScheduleInstallment installment, ProgressiveTransactionCtx ctx,
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping, Set<LoanCharge> charges) {
Money paidPortion;
ProgressiveLoanInterestScheduleModel model = ctx.getModel();
LocalDate payDate = loanTransaction.getTransactionDate();
if (DueType.IN_ADVANCE.equals(paymentAllocationType.getDueType())) {
payDate = calculateNewPayDateInCaseOfInAdvancePayment(loanTransaction, installment);
updateRepaymentPeriodBalances(paymentAllocationType, installment, model, payDate);
}
paidPortion = processPaymentAllocation(paymentAllocationType, installment, loanTransaction, transactionAmountUnprocessed,
loanTransactionToRepaymentScheduleMapping, charges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
if (PRINCIPAL.equals(paymentAllocationType.getAllocationType())) {
emiCalculator.payPrincipal(model, installment.getDueDate(), payDate, paidPortion);
updateRepaymentPeriods(loanTransaction, ctx, model);
} else if (INTEREST.equals(paymentAllocationType.getAllocationType())) {
emiCalculator.payInterest(model, installment.getDueDate(), payDate, paidPortion);
updateRepaymentPeriods(loanTransaction, ctx, model);
}
return paidPortion;
}
private void updateRepaymentPeriods(LoanTransaction loanTransaction, ProgressiveTransactionCtx ctx,
ProgressiveLoanInterestScheduleModel model) {
model.repaymentPeriods().forEach(rm -> {
LoanRepaymentScheduleInstallment installment = ctx.getInstallments().stream()
.filter(ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment()).findFirst().orElse(null);
if (installment != null) {
installment.updatePrincipal(rm.getDuePrincipal().getAmount());
installment.updateInterestCharged(rm.getDueInterest().getAmount());
installment.updateObligationsMet(ctx.getCurrency(), loanTransaction.getTransactionDate());
}
});
}
private void updateRepaymentPeriodBalances(PaymentAllocationType paymentAllocationType,
LoanRepaymentScheduleInstallment inAdvanceInstallment, ProgressiveLoanInterestScheduleModel model, LocalDate payDate) {
PayableDetails payableDetails = emiCalculator.getPayableDetails(model, inAdvanceInstallment.getDueDate(), payDate);
switch (paymentAllocationType) {
case IN_ADVANCE_INTEREST -> inAdvanceInstallment.updateInterestCharged(payableDetails.getPayableInterest().getAmount());
case IN_ADVANCE_PRINCIPAL -> inAdvanceInstallment.updatePrincipal(payableDetails.getPayablePrincipal().getAmount());
default -> {
}
}
}
private LocalDate calculateNewPayDateInCaseOfInAdvancePayment(LoanTransaction loanTransaction,
LoanRepaymentScheduleInstallment inAdvanceInstallment) {
LoanPreClosureInterestCalculationStrategy strategy = loanTransaction.getLoan().getLoanProduct()
.preCloseInterestCalculationStrategy();
LocalDate payDate = switch (strategy) {
case TILL_PRE_CLOSURE_DATE -> loanTransaction.getTransactionDate();
case TILL_REST_FREQUENCY_DATE -> loanTransaction.getTransactionDate().isAfter(inAdvanceInstallment.getFromDate()) //
&& !loanTransaction.getTransactionDate().isAfter(inAdvanceInstallment.getDueDate()) //
? inAdvanceInstallment.getDueDate() //
: loanTransaction.getTransactionDate(); //
case NONE -> throw new IllegalStateException("Unexpected PreClosureInterestCalculationStrategy: NONE");
};
return payDate;
}
@NotNull @NotNull
private static Set<LoanCharge> getLoanChargesOfInstallment(Set<LoanCharge> charges, LoanRepaymentScheduleInstallment currentInstallment, private Set<LoanCharge> getLoanChargesOfInstallment(Set<LoanCharge> charges, LoanRepaymentScheduleInstallment currentInstallment,
int firstNormalInstallmentNumber) { int firstNormalInstallmentNumber) {
return charges.stream().filter(loanCharge -> currentInstallment.getInstallmentNumber().equals(firstNormalInstallmentNumber) return charges.stream().filter(loanCharge -> currentInstallment.getInstallmentNumber().equals(firstNormalInstallmentNumber)
? loanCharge.isDueForCollectionFromIncludingAndUpToAndIncluding(currentInstallment.getFromDate(), ? loanCharge.isDueForCollectionFromIncludingAndUpToAndIncluding(currentInstallment.getFromDate(),
@@ -0,0 +1,106 @@
/**
* 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 jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Optional;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money;
@Getter
@ToString(exclude = { "repaymentPeriod" })
@EqualsAndHashCode(exclude = { "repaymentPeriod" })
public class InterestPeriod implements Comparable<InterestPeriod> {
private final RepaymentPeriod repaymentPeriod;
private final LocalDate fromDate;
@Setter
@NotNull
private LocalDate dueDate;
@Setter
private BigDecimal rateFactor;
private Money disbursementAmount;
private Money balanceCorrectionAmount;
private Money outstandingLoanBalance;
public InterestPeriod(RepaymentPeriod repaymentPeriod, LocalDate fromDate, LocalDate dueDate, BigDecimal rateFactor,
Money disbursementAmount, Money balanceCorrectionAmount, Money outstandingLoanBalance) {
this.repaymentPeriod = repaymentPeriod;
this.fromDate = fromDate;
this.dueDate = dueDate;
this.rateFactor = rateFactor;
this.disbursementAmount = disbursementAmount;
this.balanceCorrectionAmount = balanceCorrectionAmount;
this.outstandingLoanBalance = outstandingLoanBalance;
}
public InterestPeriod(RepaymentPeriod repaymentPeriod, InterestPeriod interestPeriod) {
this(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(), interestPeriod.getRateFactor(),
interestPeriod.getDisbursementAmount(), interestPeriod.getBalanceCorrectionAmount(),
interestPeriod.getOutstandingLoanBalance());
}
@Override
public int compareTo(@NotNull InterestPeriod o) {
return dueDate.compareTo(o.dueDate);
}
public void addBalanceCorrectionAmount(final Money balanceCorrectionAmount) {
this.balanceCorrectionAmount = MathUtil.plus(this.balanceCorrectionAmount, balanceCorrectionAmount);
}
public void addDisbursementAmount(final Money disbursementAmount) {
this.disbursementAmount = MathUtil.plus(this.disbursementAmount, disbursementAmount);
}
public Money getCalculatedDueInterest() {
return getOutstandingLoanBalance().multipliedBy(getRateFactor());
}
public void updateOutstandingLoanBalance() {
if (isFirstInterestPeriod()) {
Optional<RepaymentPeriod> previousRepaymentPeriod = getRepaymentPeriod().getPrevious();
if (previousRepaymentPeriod.isPresent()) {
InterestPeriod previousInterestPeriod = previousRepaymentPeriod.get().getInterestPeriods()
.get(previousRepaymentPeriod.get().getInterestPeriods().size() - 1);
this.outstandingLoanBalance = previousInterestPeriod.getOutstandingLoanBalance()//
.plus(previousInterestPeriod.getDisbursementAmount())//
.plus(previousInterestPeriod.getBalanceCorrectionAmount())//
.minus(previousRepaymentPeriod.get().getDuePrincipal())//
.plus(previousRepaymentPeriod.get().getPaidPrincipal());//
}
} else {
int index = getRepaymentPeriod().getInterestPeriods().indexOf(this);
InterestPeriod previousInterestPeriod = getRepaymentPeriod().getInterestPeriods().get(index - 1);
this.outstandingLoanBalance = previousInterestPeriod.getOutstandingLoanBalance() //
.plus(previousInterestPeriod.getBalanceCorrectionAmount()) //
.plus(previousInterestPeriod.getDisbursementAmount()); //
}
}
private boolean isFirstInterestPeriod() {
return getRepaymentPeriod().getInterestPeriods().get(0).equals(this);
}
}
@@ -22,11 +22,13 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
public record ProgressiveLoanInterestRate(LocalDate effectiveFrom, LocalDate validFrom, public record InterestRate(LocalDate effectiveFrom, //
BigDecimal interestRate) implements Comparable<ProgressiveLoanInterestRate> { LocalDate validFrom, //
BigDecimal interestRate //
) implements Comparable<InterestRate> {
@Override @Override
public int compareTo(@NotNull ProgressiveLoanInterestRate o) { public int compareTo(@NotNull InterestRate o) {
return this.effectiveFrom().compareTo(o.effectiveFrom()); return this.effectiveFrom().compareTo(o.effectiveFrom());
} }
} }
@@ -16,29 +16,16 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
package org.apache.fineract.portfolio.loanproduct.calc; package org.apache.fineract.portfolio.loanaccount.loanschedule.data;
import java.math.BigDecimal; import lombok.Data;
import java.util.List;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
@RequiredArgsConstructor(access = AccessLevel.PACKAGE) @Data
public class EMICalculationResult { public class PayableDetails {
@Getter private final Money emi;
private final Money equalMonthlyInstallmentValue; private final Money payablePrincipal;
private final List<BigDecimal> repaymentPeriodRateFactorMinus1List; private final Money payableInterest;
private final Money outstandingBalance;
private int counter = 0;
public BigDecimal getNextRepaymentPeriodRateFactorMinus1() {
return counter < repaymentPeriodRateFactorMinus1List.size() ? repaymentPeriodRateFactorMinus1List.get(counter++) : BigDecimal.ZERO;
}
public void reset() {
counter = 0;
}
} }
@@ -1,61 +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.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<ProgressiveLoanInterestRepaymentInterestPeriod> {
private LocalDate fromDate;
private LocalDate dueDate;
private BigDecimal rateFactorMinus1;
private Money disbursedAmount;
private Money correctionAmount;
private Money interestDue;
public ProgressiveLoanInterestRepaymentInterestPeriod(final ProgressiveLoanInterestRepaymentInterestPeriod period) {
this(period.fromDate, period.dueDate, period.rateFactorMinus1, period.disbursedAmount, period.correctionAmount, period.interestDue);
}
@Override
public int compareTo(@NotNull ProgressiveLoanInterestRepaymentInterestPeriod o) {
return dueDate.compareTo(o.dueDate);
}
public void addDisbursedAmount(final Money outstandingBalance) {
if (outstandingBalance != null && !outstandingBalance.isZero()) {
this.disbursedAmount = this.disbursedAmount.add(outstandingBalance);
}
}
public void addCorrectionAmount(final Money correctionAmount) {
if (correctionAmount != null && !correctionAmount.isZero()) {
this.correctionAmount = this.correctionAmount.add(correctionAmount);
}
}
}
@@ -1,99 +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.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<ProgressiveLoanInterestRepaymentInterestPeriod> interestPeriods;
private boolean isLastPeriod;
private Money equalMonthlyInstallment;
private Money principalDue;
private Money remainingBalance;
private Money initialBalance;
public ProgressiveLoanInterestRepaymentModel(final LocalDate fromDate, final LocalDate dueDate, final Money equalMonthlyInstallment) {
this.fromDate = fromDate;
this.dueDate = dueDate;
this.equalMonthlyInstallment = equalMonthlyInstallment;
this.isLastPeriod = false;
final Money zeroAmount = Money.zero(equalMonthlyInstallment.getCurrency());
this.initialBalance = zeroAmount;
this.remainingBalance = zeroAmount;
this.principalDue = zeroAmount;
this.interestPeriods = new LinkedList<>();
this.interestPeriods.add(
new ProgressiveLoanInterestRepaymentInterestPeriod(fromDate, dueDate, BigDecimal.ZERO, zeroAmount, zeroAmount, zeroAmount));
}
public ProgressiveLoanInterestRepaymentModel(ProgressiveLoanInterestRepaymentModel repaymentModel) {
this.fromDate = repaymentModel.fromDate;
this.dueDate = repaymentModel.dueDate;
this.isLastPeriod = repaymentModel.isLastPeriod;
this.equalMonthlyInstallment = repaymentModel.equalMonthlyInstallment;
this.initialBalance = repaymentModel.initialBalance;
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 getCorrectionAmount() {
return interestPeriods.stream().map(ProgressiveLoanInterestRepaymentInterestPeriod::getCorrectionAmount)
.reduce(Money.zero(equalMonthlyInstallment.getCurrency()), Money::plus);
}
public Money getOutstandingBalance() {
return initialBalance.plus(getDisbursedAmountInPeriod());
}
public Money getCorrectedOutstandingBalance() {
return getOutstandingBalance().plus(getCorrectionAmount());
}
}
@@ -19,28 +19,56 @@
package org.apache.fineract.portfolio.loanaccount.loanschedule.data; package org.apache.fineract.portfolio.loanaccount.loanschedule.data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import lombok.Data;
import lombok.experimental.Accessors;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
public record ProgressiveLoanInterestScheduleModel(List<ProgressiveLoanInterestRepaymentModel> repayments, // @Data
List<ProgressiveLoanInterestRate> interestRates, // @Accessors(fluent = true)
LoanProductRelatedDetail loanProductRelatedDetail, // public class ProgressiveLoanInterestScheduleModel {
Integer installmentAmountInMultiplesOf, //
MathContext mc) {
public ProgressiveLoanInterestScheduleModel(List<ProgressiveLoanInterestRepaymentModel> repayments, private final List<RepaymentPeriod> repaymentPeriods;
LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc) { private final List<InterestRate> interestRates;
this(repayments, new ArrayList<>(1), loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); private final LoanProductRelatedDetail loanProductRelatedDetail;
private final Integer installmentAmountInMultiplesOf;
public ProgressiveLoanInterestScheduleModel(List<RepaymentPeriod> repaymentPeriods, LoanProductRelatedDetail loanProductRelatedDetail,
Integer installmentAmountInMultiplesOf) {
this.repaymentPeriods = repaymentPeriods;
this.interestRates = new ArrayList<>();
this.loanProductRelatedDetail = loanProductRelatedDetail;
this.installmentAmountInMultiplesOf = installmentAmountInMultiplesOf;
} }
public void addInterestRate(final LocalDate newInterestDueDate, final BigDecimal newInterestRate) { private ProgressiveLoanInterestScheduleModel(List<RepaymentPeriod> repaymentPeriods, final List<InterestRate> interestRates,
interestRates.add(new ProgressiveLoanInterestRate(newInterestDueDate, newInterestDueDate.plusDays(1), newInterestRate)); LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf) {
interestRates.sort(Collections.reverseOrder()); this.repaymentPeriods = copyRepaymentPeriods(repaymentPeriods);
this.interestRates = new ArrayList<>(interestRates);
this.loanProductRelatedDetail = loanProductRelatedDetail;
this.installmentAmountInMultiplesOf = installmentAmountInMultiplesOf;
}
public ProgressiveLoanInterestScheduleModel deepCopy() {
return new ProgressiveLoanInterestScheduleModel(repaymentPeriods, interestRates, loanProductRelatedDetail,
installmentAmountInMultiplesOf);
}
private List<RepaymentPeriod> copyRepaymentPeriods(final List<RepaymentPeriod> repaymentPeriods) {
final List<RepaymentPeriod> repaymentCopies = new ArrayList<>(repaymentPeriods.size());
RepaymentPeriod previousPeriod = null;
for (RepaymentPeriod repaymentPeriod : repaymentPeriods) {
RepaymentPeriod currentPeriod = new RepaymentPeriod(previousPeriod, repaymentPeriod);
previousPeriod = currentPeriod;
repaymentCopies.add(currentPeriod);
}
return repaymentCopies;
} }
public BigDecimal getInterestRate(final LocalDate effectiveDate) { public BigDecimal getInterestRate(final LocalDate effectiveDate) {
@@ -48,16 +76,110 @@ public record ProgressiveLoanInterestScheduleModel(List<ProgressiveLoanInterestR
} }
private BigDecimal findInterestRate(final LocalDate effectiveDate) { private BigDecimal findInterestRate(final LocalDate effectiveDate) {
return interestRates.stream().filter(ir -> !ir.effectiveFrom().isAfter(effectiveDate)) return interestRates.stream().filter(ir -> !ir.effectiveFrom().isAfter(effectiveDate)).map(InterestRate::interestRate).findFirst()
.map(ProgressiveLoanInterestRate::interestRate).findFirst().orElse(loanProductRelatedDetail.getAnnualNominalInterestRate()); .orElse(loanProductRelatedDetail.getAnnualNominalInterestRate());
}
public Optional<RepaymentPeriod> findRepaymentPeriod(final LocalDate repaymentPeriodDueDate) {
if (repaymentPeriodDueDate == null) {
return Optional.empty();
}
return repaymentPeriods.stream()//
.filter(repaymentPeriodItem -> repaymentPeriodItem.getDueDate().isEqual(repaymentPeriodDueDate))//
.findFirst();
}
public List<RepaymentPeriod> getRelatedRepaymentPeriods(final LocalDate calculateFromRepaymentPeriodDueDate) {
if (calculateFromRepaymentPeriodDueDate == null) {
return repaymentPeriods;
}
return repaymentPeriods.stream()//
.filter(period -> !period.getDueDate().isBefore(calculateFromRepaymentPeriodDueDate))//
.toList();//
} }
public int getLoanTermInDays() { public int getLoanTermInDays() {
if (repayments.isEmpty()) { if (repaymentPeriods.isEmpty()) {
return 0; return 0;
} }
final var firstPeriod = repayments.get(0); final RepaymentPeriod firstPeriod = repaymentPeriods.get(0);
final var lastPeriod = repayments.size() > 1 ? repayments.get(repayments.size() - 1) : firstPeriod; final RepaymentPeriod lastPeriod = repaymentPeriods.size() > 1 ? repaymentPeriods.get(repaymentPeriods.size() - 1) : firstPeriod;
return Math.toIntExact(ChronoUnit.DAYS.between(firstPeriod.getFromDate(), lastPeriod.getDueDate())); return Math.toIntExact(ChronoUnit.DAYS.between(firstPeriod.getFromDate(), lastPeriod.getDueDate()));
} }
public LocalDate getMaturityDate() {
return !repaymentPeriods.isEmpty() ? repaymentPeriods.get(repaymentPeriods.size() - 1).getDueDate() : null;
}
public Optional<RepaymentPeriod> changeOutstandingBalanceAndUpdateInterestPeriods(final LocalDate balanceChangeDate,
final Money disbursedAmount, final Money correctionAmount) {
return findRepaymentPeriodForBalanceChange(balanceChangeDate).stream()//
.peek(updateInterestPeriodOnRepaymentPeriod(balanceChangeDate, disbursedAmount, correctionAmount))//
.findFirst();//
}
Optional<RepaymentPeriod> findRepaymentPeriodForBalanceChange(final LocalDate balanceChangeDate) {
if (balanceChangeDate == null) {
return Optional.empty();
}
return repaymentPeriods.stream()//
.filter(repaymentPeriod -> {
if (repaymentPeriod.getPrevious().isPresent()) {
return balanceChangeDate.isAfter(repaymentPeriod.getFromDate())
&& !balanceChangeDate.isAfter(repaymentPeriod.getDueDate());
} else {
return !balanceChangeDate.isBefore(repaymentPeriod.getFromDate())
&& !balanceChangeDate.isAfter(repaymentPeriod.getDueDate());
}
})//
.findFirst();
}
private Consumer<RepaymentPeriod> updateInterestPeriodOnRepaymentPeriod(final LocalDate balanceChangeDate, final Money disbursedAmount,
final Money correctionAmount) {
return repaymentPeriod -> {
final Optional<InterestPeriod> interestPeriodOptional = findInterestPeriodForBalanceChange(repaymentPeriod, balanceChangeDate);
if (interestPeriodOptional.isPresent()) {
interestPeriodOptional.get().addDisbursementAmount(disbursedAmount);
interestPeriodOptional.get().addBalanceCorrectionAmount(correctionAmount);
} else {
insertInterestPeriod(repaymentPeriod, balanceChangeDate, disbursedAmount, correctionAmount);
}
};
}
Optional<InterestPeriod> findInterestPeriodForBalanceChange(final RepaymentPeriod repaymentPeriod, final LocalDate balanceChangeDate) {
if (repaymentPeriod == null || balanceChangeDate == null) {
return Optional.empty();
}
return repaymentPeriod.getInterestPeriods().stream()//
.filter(interestPeriod -> balanceChangeDate.isEqual(interestPeriod.getDueDate()))//
.findFirst();
}
void insertInterestPeriod(final RepaymentPeriod repaymentPeriod, final LocalDate balanceChangeDate, final Money disbursedAmount,
final Money correctionAmount) {
final InterestPeriod previousInterestPeriod;
if (balanceChangeDate.isAfter(repaymentPeriod.getFromDate())) {
previousInterestPeriod = repaymentPeriod.getInterestPeriods().get(repaymentPeriod.getInterestPeriods().size() - 1);//
} else {
previousInterestPeriod = repaymentPeriod.getInterestPeriods().stream()
.filter(ip -> balanceChangeDate.isAfter(ip.getFromDate()) && !balanceChangeDate.isAfter(ip.getDueDate()))//
.reduce((first, second) -> second)//
.orElse(repaymentPeriod.getInterestPeriods().get(0));
}
LocalDate originalDueDate = previousInterestPeriod.getDueDate();
LocalDate newDueDate = balanceChangeDate.isBefore(previousInterestPeriod.getFromDate()) ? previousInterestPeriod.getFromDate()
: balanceChangeDate.isAfter(previousInterestPeriod.getDueDate()) ? previousInterestPeriod.getDueDate() : balanceChangeDate;
previousInterestPeriod.setDueDate(newDueDate);
previousInterestPeriod.addDisbursementAmount(disbursedAmount);
previousInterestPeriod.addBalanceCorrectionAmount(correctionAmount);
final InterestPeriod interestPeriod = new InterestPeriod(repaymentPeriod, previousInterestPeriod.getDueDate(), originalDueDate,
BigDecimal.ZERO, getZero(), getZero(), getZero());
repaymentPeriod.getInterestPeriods().add(interestPeriod);
}
private Money getZero() {
return Money.zero(loanProductRelatedDetail.getCurrency());
}
} }
@@ -0,0 +1,152 @@
/**
* 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.ArrayList;
import java.util.List;
import java.util.Optional;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money;
@ToString(exclude = { "previous" })
@EqualsAndHashCode(exclude = { "previous" })
public class RepaymentPeriod {
private final RepaymentPeriod previous;
@Getter
private final LocalDate fromDate;
@Getter
private final LocalDate dueDate;
@Getter
private final List<InterestPeriod> interestPeriods;
@Setter
@Getter
private Money emi;
@Getter
private Money paidPrincipal;
@Getter
private Money paidInterest;
public RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, Money emi) {
this.previous = previous;
this.fromDate = fromDate;
this.dueDate = dueDate;
this.emi = emi;
this.interestPeriods = new ArrayList<>();
// There is always at least 1 interest period, by default with same from-due date as repayment period
getInterestPeriods().add(new InterestPeriod(this, getFromDate(), getDueDate(), BigDecimal.ZERO, getZero(), getZero(), getZero()));
this.paidInterest = getZero();
this.paidPrincipal = getZero();
}
public RepaymentPeriod(RepaymentPeriod previous, RepaymentPeriod repaymentPeriod) {
this.previous = previous;
this.fromDate = repaymentPeriod.fromDate;
this.dueDate = repaymentPeriod.dueDate;
this.emi = repaymentPeriod.emi;
this.interestPeriods = new ArrayList<>();
this.paidPrincipal = repaymentPeriod.paidPrincipal;
this.paidInterest = repaymentPeriod.paidInterest;
// There is always at least 1 interest period, by default with same from-due date as repayment period
for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) {
interestPeriods.add(new InterestPeriod(this, interestPeriod));
}
}
public Optional<RepaymentPeriod> getPrevious() {
return Optional.ofNullable(previous);
}
public BigDecimal getRateFactorPlus1() {
return interestPeriods.stream().map(InterestPeriod::getRateFactor).reduce(BigDecimal.ONE, BigDecimal::add);
}
public Money getCalculatedDueInterest() {
Money calculatedDueInterest = getInterestPeriods().stream().map(InterestPeriod::getCalculatedDueInterest).reduce(getZero(),
Money::plus);
if (getPrevious().isPresent()) {
calculatedDueInterest = calculatedDueInterest.add(getPrevious().get().getUnrecognizedInterest());
}
return calculatedDueInterest;
}
private Money getZero() {
// EMI is always initiated
return this.emi.zero();
}
public Money getCalculatedDuePrincipal() {
return getEmi().minus(getCalculatedDueInterest());
}
public boolean isFullyPaid() {
return getEmi().isEqualTo(getPaidPrincipal().plus(getPaidInterest()));
}
public Money getDueInterest() {
// Due interest might be the maximum paid if there is pay-off or early repayment
return MathUtil.max(getPaidPrincipal().isGreaterThan(getCalculatedDuePrincipal()) ? getPaidInterest() : getCalculatedDueInterest(),
getPaidInterest(), false);
}
public Money getDuePrincipal() {
// Due principal might be the maximum paid if there is pay-off or early repayment
return MathUtil.max(getEmi().minus(getDueInterest()), getPaidPrincipal(), false);
}
public Money getUnrecognizedInterest() {
return getCalculatedDueInterest().minus(getDueInterest());
}
public Money getOutstandingLoanBalance() {
InterestPeriod lastInstallmentPeriod = getInterestPeriods().get(getInterestPeriods().size() - 1);
Money calculatedOutStandingLoanBalance = lastInstallmentPeriod.getOutstandingLoanBalance() //
.plus(lastInstallmentPeriod.getBalanceCorrectionAmount()) //
.plus(lastInstallmentPeriod.getDisbursementAmount()) //
.minus(getDuePrincipal())//
.plus(getPaidPrincipal());//
return MathUtil.negativeToZero(calculatedOutStandingLoanBalance);
}
public void addPaidPrincipalAmount(Money paidPrincipal) {
this.paidPrincipal = MathUtil.plus(this.paidPrincipal, paidPrincipal);
}
public void addPaidInterestAmount(Money paidInterest) {
this.paidInterest = MathUtil.plus(this.paidInterest, paidInterest);
}
public Money getInitialBalanceForEmiRecalculation() {
Money initialBalance;
if (getPrevious().isPresent()) {
initialBalance = getPrevious().get().getOutstandingLoanBalance();
} else {
initialBalance = getZero();
}
Money totalDisbursedAmount = getInterestPeriods().stream().map(InterestPeriod::getDisbursementAmount).reduce(getZero(),
Money::plus);
return initialBalance.add(totalDisbursedAmount);
}
}
@@ -18,8 +18,6 @@
*/ */
package org.apache.fineract.portfolio.loanaccount.loanschedule.domain; package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext; import java.math.MathContext;
import java.time.LocalDate; import java.time.LocalDate;
@@ -47,7 +45,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO; 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.LoanScheduleModelDownPaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams; 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.PayableDetails;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.MultiDisbursementOutstandingAmoutException; import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.MultiDisbursementOutstandingAmoutException;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator; import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
@@ -78,7 +76,6 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
} }
// determine the total charges due at time of disbursement // determine the total charges due at time of disbursement
final BigDecimal chargesDueAtTimeOfDisbursement = deriveTotalChargesDueAtTimeOfDisbursement(loanCharges); final BigDecimal chargesDueAtTimeOfDisbursement = deriveTotalChargesDueAtTimeOfDisbursement(loanCharges);
// setup variables for tracking important facts required for loan // setup variables for tracking important facts required for loan
@@ -100,8 +97,8 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
.generateRepaymentPeriods(periodStartDate, loanApplicationTerms, holidayDetailDTO); .generateRepaymentPeriods(periodStartDate, loanApplicationTerms, holidayDetailDTO);
final ProgressiveLoanInterestScheduleModel interestScheduleModel = emiCalculator.generateInterestScheduleModel( final ProgressiveLoanInterestScheduleModel interestScheduleModel = emiCalculator.generateInterestScheduleModel(
expectedRepaymentPeriods, loanApplicationTerms.toLoanProductRelatedDetail(), expectedRepaymentPeriods, loanApplicationTerms.toLoanProductRelatedDetail(),
loanApplicationTerms.getInstallmentAmountInMultiplesOf(), mc); loanApplicationTerms.getInstallmentAmountInMultiplesOf());
final List<LoanScheduleModelPeriod> periods = new ArrayList<>(expectedRepaymentPeriods.size() + 2); final List<LoanScheduleModelPeriod> periods = new ArrayList<>(expectedRepaymentPeriods.size());
prepareDisbursementsOnLoanApplicationTerms(loanApplicationTerms); prepareDisbursementsOnLoanApplicationTerms(loanApplicationTerms);
@@ -113,34 +110,33 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
scheduleParams.setActualRepaymentDate(repaymentPeriod.getDueDate()); scheduleParams.setActualRepaymentDate(repaymentPeriod.getDueDate());
processDisbursements(loanApplicationTerms, disbursementDataList, scheduleParams, interestScheduleModel, periods, processDisbursements(loanApplicationTerms, disbursementDataList, scheduleParams, interestScheduleModel, periods,
chargesDueAtTimeOfDisbursement); chargesDueAtTimeOfDisbursement, false);
repaymentPeriod.setPeriodNumber(scheduleParams.getInstalmentNumber()); repaymentPeriod.setPeriodNumber(scheduleParams.getInstalmentNumber());
if (loanApplicationTerms.getLoanTermVariations() != null) { if (loanApplicationTerms.getLoanTermVariations() != null) {
for (var interestRateChange : loanApplicationTerms.getLoanTermVariations().getInterestRateFromInstallment()) { for (var interestRateChange : loanApplicationTerms.getLoanTermVariations().getInterestRateFromInstallment()) {
final LocalDate interestRateChangeEffectiveDate = interestRateChange.getTermVariationApplicableFrom().minusDays(1); final LocalDate interestRateSubmittedOnDate = interestRateChange.getTermVariationApplicableFrom();
final BigDecimal newInterestRate = interestRateChange.getDecimalValue(); final BigDecimal newInterestRate = interestRateChange.getDecimalValue();
if (interestRateChangeEffectiveDate.isAfter(repaymentPeriod.getFromDate()) if (interestRateSubmittedOnDate.isAfter(repaymentPeriod.getFromDate())
&& !interestRateChangeEffectiveDate.isAfter(repaymentPeriod.getDueDate())) { && !interestRateSubmittedOnDate.isAfter(repaymentPeriod.getDueDate())) {
emiCalculator.changeInterestRate(interestScheduleModel, interestRateChangeEffectiveDate, newInterestRate); emiCalculator.changeInterestRate(interestScheduleModel, interestRateSubmittedOnDate, newInterestRate);
} }
} }
} }
emiCalculator.findInterestRepaymentPeriod(interestScheduleModel, repaymentPeriod.getDueDate()) emiCalculator.findRepaymentPeriod(interestScheduleModel, repaymentPeriod.getDueDate()).ifPresent(interestRepaymentPeriod -> {
.ifPresent(interestRepaymentPeriod -> { final Money principalDue = interestRepaymentPeriod.getDuePrincipal();
final Money principalDue = interestRepaymentPeriod.getPrincipalDue(); final Money interestDue = interestRepaymentPeriod.getDueInterest();
final Money interestDue = interestRepaymentPeriod.getInterestDue();
repaymentPeriod.addPrincipalAmount(principalDue); repaymentPeriod.addPrincipalAmount(principalDue);
repaymentPeriod.addInterestAmount(interestDue); repaymentPeriod.addInterestAmount(interestDue);
repaymentPeriod.setOutstandingLoanBalance(interestRepaymentPeriod.getRemainingBalance()); repaymentPeriod.setOutstandingLoanBalance(interestRepaymentPeriod.getOutstandingLoanBalance());
scheduleParams.addTotalCumulativePrincipal(principalDue); scheduleParams.addTotalCumulativePrincipal(principalDue);
scheduleParams.addTotalCumulativeInterest(interestDue); scheduleParams.addTotalCumulativeInterest(interestDue);
// add everything // add everything
scheduleParams.addTotalRepaymentExpected(principalDue.plus(interestDue)); scheduleParams.addTotalRepaymentExpected(principalDue.plus(interestDue));
}); });
applyChargesForCurrentPeriod(repaymentPeriod, loanCharges, scheduleParams, currency, mc); applyChargesForCurrentPeriod(repaymentPeriod, loanCharges, scheduleParams, currency, mc);
periods.add(repaymentPeriod); periods.add(repaymentPeriod);
@@ -150,7 +146,8 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
} }
if (loanApplicationTerms.isMultiDisburseLoan()) { if (loanApplicationTerms.isMultiDisburseLoan()) {
processDisbursements(loanApplicationTerms, disbursementDataList, scheduleParams, null, periods, chargesDueAtTimeOfDisbursement); processDisbursements(loanApplicationTerms, disbursementDataList, scheduleParams, interestScheduleModel, periods,
chargesDueAtTimeOfDisbursement, true);
} }
// determine fees and penalties for charges which depends on total // determine fees and penalties for charges which depends on total
@@ -187,23 +184,23 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
private void processDisbursements(final LoanApplicationTerms loanApplicationTerms, private void processDisbursements(final LoanApplicationTerms loanApplicationTerms,
final ArrayList<DisbursementData> disbursementDataList, final LoanScheduleParams scheduleParams, final ArrayList<DisbursementData> disbursementDataList, final LoanScheduleParams scheduleParams,
final ProgressiveLoanInterestScheduleModel interestScheduleModel, final List<LoanScheduleModelPeriod> periods, final ProgressiveLoanInterestScheduleModel interestScheduleModel, final List<LoanScheduleModelPeriod> periods,
final BigDecimal chargesDueAtTimeOfDisbursement) { final BigDecimal chargesDueAtTimeOfDisbursement, final boolean includeDisbursementsAfterMaturityDate) {
for (DisbursementData disbursementData : disbursementDataList) { for (DisbursementData disbursementData : disbursementDataList) {
final LocalDate disbursementDate = disbursementData.disbursementDate(); final LocalDate disbursementDate = disbursementData.disbursementDate();
final LocalDate periodFromDate = scheduleParams.getPeriodStartDate(); final LocalDate periodFromDate = scheduleParams.getPeriodStartDate();
final LocalDate periodDueDate = scheduleParams.getActualRepaymentDate(); final LocalDate periodDueDate = scheduleParams.getActualRepaymentDate();
boolean hasDisbursementAfterLastRepaymentPeriod = interestScheduleModel == null && !disbursementDate.isBefore(periodDueDate); final LocalDate maturityDate = interestScheduleModel.getMaturityDate();
boolean hasDisbursementInCurrentRepaymentPeriod = interestScheduleModel != null && !disbursementDate.isBefore(periodFromDate) boolean hasDisbursementAfterLastRepaymentPeriod = includeDisbursementsAfterMaturityDate
&& disbursementDate.isBefore(periodDueDate); && !disbursementDate.isBefore(maturityDate);
boolean hasDisbursementInCurrentRepaymentPeriod = !includeDisbursementsAfterMaturityDate
&& !disbursementDate.isBefore(periodFromDate) && disbursementDate.isBefore(periodDueDate);
if (!hasDisbursementAfterLastRepaymentPeriod && !hasDisbursementInCurrentRepaymentPeriod) { if (!hasDisbursementAfterLastRepaymentPeriod && !hasDisbursementInCurrentRepaymentPeriod) {
continue; continue;
} }
Money outstandingBalance = emiCalculator.findInterestRepaymentPeriod(interestScheduleModel, periodDueDate) Money outstandingBalance = emiCalculator.getOutstandingLoanBalance(interestScheduleModel, periodDueDate, disbursementDate);
.map(ProgressiveLoanInterestRepaymentModel::getOutstandingBalance)
.orElse(Money.zero(loanApplicationTerms.getCurrency()));
final Money disbursedAmount = Money.of(loanApplicationTerms.getCurrency(), disbursementData.getPrincipal()); final Money disbursedAmount = Money.of(loanApplicationTerms.getCurrency(), disbursementData.getPrincipal());
final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod final LoanScheduleModelDisbursementPeriod disbursementPeriod = LoanScheduleModelDisbursementPeriod
@@ -277,12 +274,11 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
.filter(it -> transactionDate.isAfter(it.getFromDate()) && !transactionDate.isAfter(it.getDueDate())).findFirst() .filter(it -> transactionDate.isAfter(it.getFromDate()) && !transactionDate.isAfter(it.getDueDate())).findFirst()
.orElse(installments.get(0)); .orElse(installments.get(0));
ProgressiveLoanInterestRepaymentModel result = emiCalculator PayableDetails result = emiCalculator.getPayableDetails(model, actualInstallment.getDueDate(), transactionDate);
.getPayableDetails(model, actualInstallment.getDueDate(), transactionDate).orElseThrow(); // TODO: We should add all the past due outstanding amounts as well
OutstandingAmountsDTO amounts = new OutstandingAmountsDTO(currency) // OutstandingAmountsDTO amounts = new OutstandingAmountsDTO(currency) //
.principal(result.getOutstandingBalance()) // .principal(result.getOutstandingBalance()) //
.interest(result.getInterestDue()); .interest(result.getPayableInterest());
installments.forEach(installment -> amounts // installments.forEach(installment -> amounts //
.plusFeeCharges(installment.getFeeChargesOutstanding(currency)) .plusFeeCharges(installment.getFeeChargesOutstanding(currency))
@@ -19,41 +19,43 @@
package org.apache.fineract.portfolio.loanproduct.calc; package org.apache.fineract.portfolio.loanproduct.calc;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; 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.PayableDetails;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.RepaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
public interface EMICalculator { public interface EMICalculator {
ProgressiveLoanInterestScheduleModel generateInterestScheduleModel(List<LoanScheduleModelRepaymentPeriod> periods, ProgressiveLoanInterestScheduleModel generateInterestScheduleModel(List<LoanScheduleModelRepaymentPeriod> periods,
LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc); LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf);
ProgressiveLoanInterestScheduleModel generateModel(LoanProductRelatedDetail loanProductRelatedDetail, ProgressiveLoanInterestScheduleModel generateModel(LoanProductRelatedDetail loanProductRelatedDetail,
Integer installmentAmountInMultiplesOf, List<LoanRepaymentScheduleInstallment> repaymentPeriods, MathContext mc); Integer installmentAmountInMultiplesOf, List<LoanRepaymentScheduleInstallment> repaymentPeriods);
Optional<ProgressiveLoanInterestRepaymentModel> findInterestRepaymentPeriod(ProgressiveLoanInterestScheduleModel scheduleModel, Optional<RepaymentPeriod> findRepaymentPeriod(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate dueDate);
LocalDate dueDate);
void addDisbursement(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate disbursementDueDate, Money disbursedAmount); void addDisbursement(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate disbursementDueDate, Money disbursedAmount);
void changeInterestRate(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate newInterestEffectiveDate, void changeInterestRate(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate newInterestSubmittedOnDate,
BigDecimal newInterestRate); BigDecimal newInterestRate);
void addBalanceCorrection(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate balanceCorrectionDate, void addBalanceCorrection(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate balanceCorrectionDate,
Money balanceCorrectionAmount); Money balanceCorrectionAmount);
Optional<ProgressiveLoanInterestRepaymentModel> getPayableDetails(ProgressiveLoanInterestScheduleModel scheduleModel, void payInterest(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate repaymentPeriodDueDate, LocalDate transactionDate,
LocalDate periodDueDate, LocalDate payDate); Money interestAmount);
ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(ProgressiveLoanInterestScheduleModel scheduleModel); void payPrincipal(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate repaymentPeriodDueDate, LocalDate transactionDate,
Money principalAmount);
ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(ProgressiveLoanInterestScheduleModel scheduleModel, PayableDetails getPayableDetails(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate periodDueDate, LocalDate payDate);
LoanProductRelatedDetail loanProductRelatedDetail, Integer installmentAmountInMultiplesOf, MathContext mc);
Money getOutstandingLoanBalance(ProgressiveLoanInterestScheduleModel interestScheduleModel, LocalDate repaymentPeriodDueDate,
LocalDate targetDate);
} }
@@ -18,110 +18,63 @@
*/ */
package org.apache.fineract.portfolio.loanproduct.calc; package org.apache.fineract.portfolio.loanproduct.calc;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.Year; import java.time.Year;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType; import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.apache.fineract.portfolio.common.domain.DaysInYearType; import org.apache.fineract.portfolio.common.domain.DaysInYearType;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; 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.InterestPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestRepaymentModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.PayableDetails;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.RepaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.apache.fineract.portfolio.loanproduct.mapper.ProgressiveLoanInterestRepaymentModelMapper; import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@DependsOn("moneyHelper")
public final class ProgressiveEMICalculator implements EMICalculator { public final class ProgressiveEMICalculator implements EMICalculator {
private final ProgressiveLoanInterestRepaymentModelMapper progressiveLoanInterestRepaymentModelMapper;
private static final BigDecimal DIVISOR_100 = new BigDecimal("100"); private static final BigDecimal DIVISOR_100 = new BigDecimal("100");
private static final BigDecimal ONE_WEEK_IN_DAYS = BigDecimal.valueOf(7); private static final BigDecimal ONE_WEEK_IN_DAYS = BigDecimal.valueOf(7);
@Override @Override
public ProgressiveLoanInterestScheduleModel generateInterestScheduleModel(final List<LoanScheduleModelRepaymentPeriod> periods, public ProgressiveLoanInterestScheduleModel generateInterestScheduleModel(final List<LoanScheduleModelRepaymentPeriod> periods,
final LoanProductRelatedDetail loanProductRelatedDetail, final Integer installmentAmountInMultiplesOf, final MathContext mc) { final LoanProductRelatedDetail loanProductRelatedDetail, final Integer installmentAmountInMultiplesOf) {
final Money zeroAmount = Money.zero(loanProductRelatedDetail.getCurrency()); final Money zeroAmount = Money.zero(loanProductRelatedDetail.getCurrency());
final ArrayList<ProgressiveLoanInterestRepaymentModel> interestRepaymentModelList = new ArrayList<>(periods.size()); final ArrayList<RepaymentPeriod> interestRepaymentModelList = new ArrayList<>(periods.size());
RepaymentPeriod previousPeriod = null;
for (final LoanScheduleModelRepaymentPeriod period : periods) { for (final LoanScheduleModelRepaymentPeriod period : periods) {
interestRepaymentModelList RepaymentPeriod currentPeriod = new RepaymentPeriod(previousPeriod, period.periodFromDate(), period.periodDueDate(),
.add(new ProgressiveLoanInterestRepaymentModel(period.periodFromDate(), period.periodDueDate(), zeroAmount)); zeroAmount);
} previousPeriod = currentPeriod;
if (!interestRepaymentModelList.isEmpty()) { interestRepaymentModelList.add(currentPeriod);
interestRepaymentModelList.get(interestRepaymentModelList.size() - 1).setLastPeriod(true);
} }
return new ProgressiveLoanInterestScheduleModel(interestRepaymentModelList, loanProductRelatedDetail, return new ProgressiveLoanInterestScheduleModel(interestRepaymentModelList, loanProductRelatedDetail,
installmentAmountInMultiplesOf, mc); installmentAmountInMultiplesOf);
} }
@Override @Override
public Optional<ProgressiveLoanInterestRepaymentModel> findInterestRepaymentPeriod( public Optional<RepaymentPeriod> findRepaymentPeriod(final ProgressiveLoanInterestScheduleModel scheduleModel,
final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate repaymentPeriodDueDate) { final LocalDate repaymentPeriodDueDate) {
if (scheduleModel == null || repaymentPeriodDueDate == null) { if (scheduleModel == null) {
return Optional.empty(); return Optional.empty();
} }
return scheduleModel.repayments().stream()// return scheduleModel.findRepaymentPeriod(repaymentPeriodDueDate);
.filter(interestRepaymentPeriodItem -> interestRepaymentPeriodItem.getDueDate().isEqual(repaymentPeriodDueDate))//
.findFirst();
}
Optional<ProgressiveLoanInterestRepaymentModel> findInterestRepaymentPeriodForBalanceChange(
final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate balanceChangeDate) {
if (scheduleModel == null || balanceChangeDate == null) {
return Optional.empty();
}
return scheduleModel.repayments().stream()//
.filter(repaymentPeriod -> !balanceChangeDate.isBefore(repaymentPeriod.getFromDate())
&& (repaymentPeriod.isLastPeriod() || balanceChangeDate.isBefore(repaymentPeriod.getDueDate())))//
.findFirst();
}
Optional<ProgressiveLoanInterestRepaymentInterestPeriod> findInterestPeriodForBalanceChange(
final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate balanceChangeDate) {
if (repaymentPeriod == null || balanceChangeDate == null) {
return Optional.empty();
}
return repaymentPeriod.getInterestPeriods().stream()//
.filter(interestPeriod -> balanceChangeDate.isEqual(interestPeriod.getFromDate()))//
.findFirst();
}
Optional<ProgressiveLoanInterestRepaymentInterestPeriod> findInterestPeriodForInterestChange(
final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate interestRateChangeEffectiveDate) {
if (repaymentPeriod == null || interestRateChangeEffectiveDate == null) {
return Optional.empty();
}
return repaymentPeriod.getInterestPeriods().stream()//
.filter(interestPeriod -> interestRateChangeEffectiveDate.isEqual(interestPeriod.getFromDate()))//
.findFirst();
}
Optional<ProgressiveLoanInterestRepaymentModel> findInterestRepaymentPeriodForInterestChange(
final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate interestChangeEffectiveDate) {
if (scheduleModel == null || interestChangeEffectiveDate == null) {
return Optional.empty();
}
return scheduleModel.repayments().stream()//
.filter(repaymentPeriod -> !interestChangeEffectiveDate.isBefore(repaymentPeriod.getFromDate())
&& interestChangeEffectiveDate.isBefore(repaymentPeriod.getDueDate()))//
.findFirst();
} }
/** /**
@@ -130,166 +83,93 @@ public final class ProgressiveEMICalculator implements EMICalculator {
@Override @Override
public void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate disbursementDueDate, public void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate disbursementDueDate,
final Money disbursedAmount) { final Money disbursedAmount) {
changeOutstandingBalanceAndUpdateInterestPeriods(scheduleModel, disbursementDueDate, disbursedAmount, scheduleModel
Money.zero(disbursedAmount.getCurrency())) .changeOutstandingBalanceAndUpdateInterestPeriods(disbursementDueDate, disbursedAmount,
Money.zero(disbursedAmount.getCurrency()))
.ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors(repaymentPeriod.getDueDate(), scheduleModel)); .ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors(repaymentPeriod.getDueDate(), scheduleModel));
} }
Optional<ProgressiveLoanInterestRepaymentModel> changeOutstandingBalanceAndUpdateInterestPeriods(
final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate balanceChangeDate, final Money disbursedAmount,
final Money correctionAmount) {
return findInterestRepaymentPeriodForBalanceChange(scheduleModel, balanceChangeDate).stream()//
.peek(updateInterestPeriodOnRepaymentPeriod(balanceChangeDate, disbursedAmount, correctionAmount, false))//
.findFirst();//
}
@NotNull
private Consumer<ProgressiveLoanInterestRepaymentModel> updateInterestPeriodOnRepaymentPeriod(final LocalDate balanceChangeDate,
final Money disbursedAmount, final Money correctionAmount, final boolean tillBalanceChangeDate) {
return repaymentPeriod -> {
if (tillBalanceChangeDate && balanceChangeDate.isEqual(repaymentPeriod.getFromDate())) {
insertInterestPeriodIntoStart(repaymentPeriod, disbursedAmount, correctionAmount);
return;
}
final var interestPeriodOptional = findInterestPeriodForBalanceChange(repaymentPeriod, balanceChangeDate);
if (interestPeriodOptional.isPresent()) {
interestPeriodOptional.get().addDisbursedAmount(disbursedAmount);
interestPeriodOptional.get().addCorrectionAmount(correctionAmount);
} else {
insertInterestPeriod(repaymentPeriod, balanceChangeDate, disbursedAmount, correctionAmount);
}
};
}
void insertInterestPeriodIntoStart(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final Money disbursedAmount,
final Money correctionAmount) {
final Money zeroAmount = Money.zero(disbursedAmount.getCurrency());
// interestPeriodFromDate is after disb.date because this case when disbursement date is different then interest
// we always have at least one period
final ProgressiveLoanInterestRepaymentInterestPeriod selectedInterestPeriod = repaymentPeriod.getInterestPeriods().get(0);
final LocalDate interestPeriodDueDate = selectedInterestPeriod.getFromDate();
final var newInterestPeriod = new ProgressiveLoanInterestRepaymentInterestPeriod(selectedInterestPeriod.getFromDate(),
interestPeriodDueDate, BigDecimal.ZERO, zeroAmount, zeroAmount, Money.zero(disbursedAmount.getCurrency()));
newInterestPeriod.setDisbursedAmount(disbursedAmount.add(selectedInterestPeriod.getDisbursedAmount()));
newInterestPeriod.setCorrectionAmount(correctionAmount.add(selectedInterestPeriod.getCorrectionAmount()));
// reset amounts on next periods
selectedInterestPeriod.setDisbursedAmount(zeroAmount);
selectedInterestPeriod.setCorrectionAmount(zeroAmount);
selectedInterestPeriod.setFromDate(interestPeriodDueDate);
repaymentPeriod.getInterestPeriods().add(newInterestPeriod);
Collections.sort(repaymentPeriod.getInterestPeriods());
}
void insertInterestPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate interestPeriodFromDate,
final Money disbursedAmount, final Money correctionAmount) {
// interestPeriodFromDate is after disb.date because this case when disbursement date is different then interest
// period start date
final ProgressiveLoanInterestRepaymentInterestPeriod previousInterestPeriod = repaymentPeriod.getInterestPeriods().stream()
.filter(operationRelatedPreviousInterestPeriod(repaymentPeriod, interestPeriodFromDate))//
.findFirst()//
.get();//
final boolean changeAfterLastRepaymentPeriod = repaymentPeriod.isLastPeriod()
&& previousInterestPeriod.getDueDate().isEqual(repaymentPeriod.getDueDate())
&& !interestPeriodFromDate.isBefore(repaymentPeriod.getDueDate());
final LocalDate interestPeriodDueDate = changeAfterLastRepaymentPeriod ? interestPeriodFromDate.plusDays(1)
: previousInterestPeriod.getDueDate();
final var interestPeriod = new ProgressiveLoanInterestRepaymentInterestPeriod(interestPeriodFromDate, interestPeriodDueDate,
BigDecimal.ZERO, disbursedAmount, correctionAmount, Money.zero(disbursedAmount.getCurrency()));
previousInterestPeriod.setDueDate(interestPeriodFromDate);
repaymentPeriod.getInterestPeriods().add(interestPeriod);
Collections.sort(repaymentPeriod.getInterestPeriods());
}
private static Predicate<ProgressiveLoanInterestRepaymentInterestPeriod> operationRelatedPreviousInterestPeriod(
ProgressiveLoanInterestRepaymentModel repaymentPeriod, LocalDate operationDate) {
return interestPeriod -> operationDate.isAfter(interestPeriod.getFromDate())
&& (operationDate.isBefore(interestPeriod.getDueDate()) || (repaymentPeriod.getDueDate().equals(interestPeriod.getDueDate())
&& !operationDate.isBefore(repaymentPeriod.getDueDate())));
}
@Override @Override
public ProgressiveLoanInterestScheduleModel makeScheduleModelDeepCopy(final ProgressiveLoanInterestScheduleModel scheduleModel) { public void changeInterestRate(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate newInterestSubmittedOnDate,
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<ProgressiveLoanInterestRepaymentModel> repayments = new ArrayList<>(scheduleModel.repayments().size());
for (var repaymentModel : scheduleModel.repayments()) {
repayments.add(new ProgressiveLoanInterestRepaymentModel(repaymentModel));
}
return new ProgressiveLoanInterestScheduleModel(repayments, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
}
@Override
public void changeInterestRate(final ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate newInterestEffectiveDate,
final BigDecimal newInterestRate) { final BigDecimal newInterestRate) {
final ProgressiveLoanInterestRepaymentModel repaymentPeriod = findInterestRepaymentPeriodForInterestChange(scheduleModel, // TODO: impl
newInterestEffectiveDate).orElse(null);
if (repaymentPeriod == null) {
return;
}
scheduleModel.addInterestRate(newInterestEffectiveDate, newInterestRate);
var interestPeriodOptional = findInterestPeriodForInterestChange(repaymentPeriod, newInterestEffectiveDate);
if (interestPeriodOptional.isEmpty()) {
insertInterestPeriod(scheduleModel, repaymentPeriod, newInterestEffectiveDate);
}
calculateEMIValueAndRateFactors(repaymentPeriod.getDueDate(), scheduleModel);
}
void insertInterestPeriod(final ProgressiveLoanInterestScheduleModel scheduleModel,
final ProgressiveLoanInterestRepaymentModel repaymentPeriod, final LocalDate interestChangeDueDate) {
// period start date
final ProgressiveLoanInterestRepaymentInterestPeriod previousInterestPeriod = repaymentPeriod.getInterestPeriods().stream()
.filter(interestPeriod -> interestChangeDueDate.isAfter(interestPeriod.getFromDate())
&& interestChangeDueDate.isBefore(interestPeriod.getDueDate()))//
.findFirst()//
.get();//
final Money zeroAmount = Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency());
final var interestPeriod = new ProgressiveLoanInterestRepaymentInterestPeriod(interestChangeDueDate,
previousInterestPeriod.getDueDate(), BigDecimal.ZERO, zeroAmount, zeroAmount, zeroAmount);
previousInterestPeriod.setDueDate(interestChangeDueDate);
repaymentPeriod.getInterestPeriods().add(interestPeriod);
Collections.sort(repaymentPeriod.getInterestPeriods());
} }
@Override @Override
public void addBalanceCorrection(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate balanceCorrectionDate, public void addBalanceCorrection(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate balanceCorrectionDate,
Money balanceCorrectionAmount) { Money balanceCorrectionAmount) {
final Money zeroAmount = Money.zero(balanceCorrectionAmount.getCurrency()); final Money zeroAmount = Money.zero(balanceCorrectionAmount.getCurrency());
changeOutstandingBalanceAndUpdateInterestPeriods(scheduleModel, balanceCorrectionDate, zeroAmount, balanceCorrectionAmount) scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(balanceCorrectionDate, zeroAmount, balanceCorrectionAmount)
.ifPresent(repaymentPeriod -> { .ifPresent(repaymentPeriod -> {
calculateRateFactorMinus1ForRepaymentPeriod(repaymentPeriod, scheduleModel); calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel);
calculatePrincipalInterestComponentsForPeriods(scheduleModel); calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel);
}); });
} }
@Override @Override
public Optional<ProgressiveLoanInterestRepaymentModel> getPayableDetails(final ProgressiveLoanInterestScheduleModel scheduleModel, public void payInterest(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate repaymentPeriodDueDate, LocalDate transactionDate,
final LocalDate periodDueDate, final LocalDate payDate) { Money interestAmount) {
final var newScheduleModel = makeScheduleModelDeepCopy(scheduleModel); findRepaymentPeriod(scheduleModel, repaymentPeriodDueDate).ifPresent(rp -> rp.addPaidInterestAmount(interestAmount));
final var zeroAmount = Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()); calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel);
}
return findInterestRepaymentPeriod(newScheduleModel, periodDueDate).stream() @Override
.peek(updateInterestPeriodOnRepaymentPeriod(payDate, zeroAmount, zeroAmount, true))// public void payPrincipal(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate repaymentPeriodDueDate,
.peek(repaymentPeriod -> { LocalDate transactionDate, Money principalAmount) {
calculateRateFactorMinus1ForRepaymentPeriod(repaymentPeriod, scheduleModel); findRepaymentPeriod(scheduleModel, repaymentPeriodDueDate).ifPresent(rp -> rp.addPaidPrincipalAmount(principalAmount));
calculatePrincipalInterestComponentsForPeriod(repaymentPeriod, payDate); LocalDate balanceCorrectionDate = transactionDate;
}).findFirst(); if (repaymentPeriodDueDate.isBefore(transactionDate)) {
// If it is paid late, we need to calculate with the period due date
balanceCorrectionDate = repaymentPeriodDueDate;
}
addBalanceCorrection(scheduleModel, balanceCorrectionDate, principalAmount.negated());
}
@Override
public PayableDetails getPayableDetails(final ProgressiveLoanInterestScheduleModel scheduleModel,
final LocalDate repaymentPeriodDueDate, final LocalDate targetDate) {
RepaymentPeriod repaymentPeriod = scheduleModel.deepCopy().repaymentPeriods().stream()
.filter(rp -> rp.getDueDate().equals(repaymentPeriodDueDate)).findFirst().orElseThrow();
LocalDate adjustedTargetDate = targetDate;
InterestPeriod interestPeriod;
if (!targetDate.isAfter(repaymentPeriod.getFromDate())) {
interestPeriod = repaymentPeriod.getInterestPeriods().get(0);
adjustedTargetDate = repaymentPeriod.getFromDate();
} else if (targetDate.isAfter(repaymentPeriod.getDueDate())) {
interestPeriod = repaymentPeriod.getInterestPeriods().get(repaymentPeriod.getInterestPeriods().size() - 1);
adjustedTargetDate = repaymentPeriod.getDueDate();
} else {
interestPeriod = repaymentPeriod.getInterestPeriods().stream()
.filter(ip -> targetDate.isAfter(ip.getFromDate()) && !targetDate.isAfter(ip.getDueDate())).findFirst().orElseThrow();
}
interestPeriod.setDueDate(adjustedTargetDate);
int index = repaymentPeriod.getInterestPeriods().indexOf(interestPeriod);
repaymentPeriod.getInterestPeriods().subList(index + 1, repaymentPeriod.getInterestPeriods().size()).clear();
calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel);
// TODO: gather all the unrecognized interest from previous periods based on target date
Money payableInterest = targetDate.isBefore(repaymentPeriod.getFromDate())
? Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency())
: repaymentPeriod.getDueInterest();
Money outstandingLoanBalance = interestPeriod.getOutstandingLoanBalance().add(interestPeriod.getDisbursementAmount());
Money calculatedEmi = outstandingLoanBalance.plus(payableInterest);
if (calculatedEmi.isLessThan(repaymentPeriod.getEmi())) {
// Review this logic
repaymentPeriod.setEmi(outstandingLoanBalance.plus(payableInterest).plus(repaymentPeriod.getPaidInterest())
.plus(repaymentPeriod.getPaidPrincipal()));
}
Money payablePrincipal = repaymentPeriod.getEmi().minus(payableInterest);
return new PayableDetails(repaymentPeriod.getEmi(), payablePrincipal, payableInterest,
interestPeriod.getOutstandingLoanBalance().add(interestPeriod.getDisbursementAmount()));
}
@Override
public Money getOutstandingLoanBalance(ProgressiveLoanInterestScheduleModel interestScheduleModel, LocalDate repaymentPeriodDueDate,
LocalDate targetDate) {
return getPayableDetails(interestScheduleModel, repaymentPeriodDueDate, targetDate).getOutstandingBalance();
} }
/** /**
@@ -297,56 +177,71 @@ public final class ProgressiveEMICalculator implements EMICalculator {
*/ */
void calculateEMIValueAndRateFactors(final LocalDate calculateFromRepaymentPeriodDueDate, void calculateEMIValueAndRateFactors(final LocalDate calculateFromRepaymentPeriodDueDate,
final ProgressiveLoanInterestScheduleModel scheduleModel) { final ProgressiveLoanInterestScheduleModel scheduleModel) {
final List<ProgressiveLoanInterestRepaymentModel> relatedRepaymentPeriods = getRelatedRepaymentPeriods( final List<RepaymentPeriod> relatedRepaymentPeriods = scheduleModel.getRelatedRepaymentPeriods(calculateFromRepaymentPeriodDueDate);
calculateFromRepaymentPeriodDueDate, scheduleModel); calculateRateFactorForPeriods(relatedRepaymentPeriods, scheduleModel);
calculateRateFactorMinus1ForPeriods(relatedRepaymentPeriods, scheduleModel); // TODO: optimalize
calculateOutstandingBalance(scheduleModel);
calculateEMIOnPeriods(relatedRepaymentPeriods, scheduleModel); calculateEMIOnPeriods(relatedRepaymentPeriods, scheduleModel);
calculatePrincipalInterestComponentsForPeriods(scheduleModel); // TODO: optimalize
calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel);
checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(scheduleModel, relatedRepaymentPeriods); checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(scheduleModel, relatedRepaymentPeriods);
// TODO: optimalize
calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel);
} }
private static List<ProgressiveLoanInterestRepaymentModel> getRelatedRepaymentPeriods(LocalDate calculateFromRepaymentPeriodDueDate, private void calculateLastUnpaidRepaymentPeriodEMI(ProgressiveLoanInterestScheduleModel scheduleModel) {
ProgressiveLoanInterestScheduleModel scheduleModel) { Money totalDueInterest = scheduleModel.repaymentPeriods().stream().map(RepaymentPeriod::getDueInterest)
return calculateFromRepaymentPeriodDueDate == null ? scheduleModel.repayments() .reduce(Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()), Money::plus); // 1.46
: scheduleModel.repayments().stream().filter(period -> !period.getDueDate().isBefore(calculateFromRepaymentPeriodDueDate)) Money totalEMI = scheduleModel.repaymentPeriods().stream().map(RepaymentPeriod::getEmi)
.toList(); .reduce(Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()), Money::plus); // 101.48
Money totalDisbursedAmount = scheduleModel.repaymentPeriods().stream()
.flatMap(rp -> rp.getInterestPeriods().stream().map(InterestPeriod::getDisbursementAmount))
.reduce(Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()), Money::plus); // 100
Money diff = totalDisbursedAmount.plus(totalDueInterest).minus(totalEMI);
Optional<RepaymentPeriod> findLastUnpaidRepaymentPeriod = scheduleModel.repaymentPeriods().stream().filter(rp -> !rp.isFullyPaid())
.reduce((first, second) -> second);
findLastUnpaidRepaymentPeriod.ifPresent(repaymentPeriod -> repaymentPeriod.setEmi(repaymentPeriod.getEmi().add(diff)));
}
private void calculateOutstandingBalance(ProgressiveLoanInterestScheduleModel scheduleModel) {
scheduleModel.repaymentPeriods().forEach(rp -> rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance));
} }
private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel, private void checkAndAdjustEmiIfNeededOnRelatedRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel,
final List<ProgressiveLoanInterestRepaymentModel> relatedRepaymentPeriods) { final List<RepaymentPeriod> relatedRepaymentPeriods) {
final Money emiDifference = getDifferenceBetweenLastTwoPeriod(relatedRepaymentPeriods, scheduleModel); final Money emiDifference = getDifferenceBetweenLastTwoPeriod(relatedRepaymentPeriods, scheduleModel);
final int numberOfRelatedPeriods = relatedRepaymentPeriods.size(); final int numberOfRelatedPeriods = relatedRepaymentPeriods.size();
double lowerHalfOfRelatedPeriods = Math.floor(numberOfRelatedPeriods / 2.0); double lowerHalfOfRelatedPeriods = Math.floor(numberOfRelatedPeriods / 2.0);
if (emiDifference.isZero() || lowerHalfOfRelatedPeriods == 0.0) { if (emiDifference.isZero() || lowerHalfOfRelatedPeriods == 0.0) {
return; return;
} }
final Money originalEmi = relatedRepaymentPeriods.get(numberOfRelatedPeriods - 2).getEqualMonthlyInstallment(); final Money originalEmi = relatedRepaymentPeriods.get(numberOfRelatedPeriods - 2).getEmi();
boolean shouldBeAdjusted = emiDifference.abs().multipliedBy(100) boolean shouldBeAdjusted = emiDifference.abs().multipliedBy(100)
.isGreaterThan(Money.of(originalEmi.getCurrency(), BigDecimal.valueOf(lowerHalfOfRelatedPeriods))); .isGreaterThan(Money.of(originalEmi.getCurrency(), BigDecimal.valueOf(lowerHalfOfRelatedPeriods)));
final MathContext mc = scheduleModel.mc();
if (shouldBeAdjusted) { if (shouldBeAdjusted) {
Money adjustment = emiDifference.dividedBy(numberOfRelatedPeriods, mc.getRoundingMode()); Money adjustment = emiDifference.dividedBy(numberOfRelatedPeriods, MoneyHelper.getMathContext().getRoundingMode());
Money adjustedEqualMonthlyInstallmentValue = applyInstallmentAmountInMultiplesOf(scheduleModel, originalEmi.plus(adjustment)); Money adjustedEqualMonthlyInstallmentValue = applyInstallmentAmountInMultiplesOf(scheduleModel, originalEmi.plus(adjustment));
if (adjustedEqualMonthlyInstallmentValue.compareTo(originalEmi) == 0) { if (adjustedEqualMonthlyInstallmentValue.isEqualTo(originalEmi)) {
return; return;
} }
final LocalDate relatedPeriodsFirstDueDate = relatedRepaymentPeriods.get(0).getDueDate(); final LocalDate relatedPeriodsFirstDueDate = relatedRepaymentPeriods.get(0).getDueDate();
final var newScheduleModel = makeScheduleModelDeepCopy(scheduleModel); final ProgressiveLoanInterestScheduleModel newScheduleModel = scheduleModel.deepCopy();
newScheduleModel.repayments().forEach(period -> { newScheduleModel.repaymentPeriods().forEach(period -> {
if (!period.getDueDate().isBefore(relatedPeriodsFirstDueDate)) { if (!period.getDueDate().isBefore(relatedPeriodsFirstDueDate)) {
period.setEqualMonthlyInstallment(adjustedEqualMonthlyInstallmentValue); period.setEmi(adjustedEqualMonthlyInstallmentValue);
} }
}); });
calculatePrincipalInterestComponentsForPeriods(newScheduleModel); final Money newEmiDifference = getDifferenceBetweenLastTwoPeriod(newScheduleModel.repaymentPeriods(), scheduleModel);
final Money newEmiDifference = getDifferenceBetweenLastTwoPeriod(newScheduleModel.repayments(), scheduleModel); final boolean newEmiHasLessDifference = newEmiDifference.abs().isLessThan(emiDifference.abs());
final boolean newEmiHasLessDifference = newEmiDifference.abs().compareTo(emiDifference.abs()) < 0;
if (!newEmiHasLessDifference) { if (!newEmiHasLessDifference) {
return; return;
} }
final Iterator<ProgressiveLoanInterestRepaymentModel> relatedPeriodFromNewModelIterator = newScheduleModel// final Iterator<RepaymentPeriod> relatedPeriodFromNewModelIterator = newScheduleModel.repaymentPeriods().stream()//
.repayments().stream()//
.filter(period -> !period.getDueDate().isBefore(relatedPeriodsFirstDueDate))// .filter(period -> !period.getDueDate().isBefore(relatedPeriodsFirstDueDate))//
.toList().iterator();// .toList().iterator();//
@@ -354,12 +249,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
if (!relatedPeriodFromNewModelIterator.hasNext()) { if (!relatedPeriodFromNewModelIterator.hasNext()) {
return; return;
} }
final ProgressiveLoanInterestRepaymentModel newRepaymentPeriod = relatedPeriodFromNewModelIterator.next(); final RepaymentPeriod newRepaymentPeriod = relatedPeriodFromNewModelIterator.next();
relatedRepaymentPeriod.setEqualMonthlyInstallment(newRepaymentPeriod.getEqualMonthlyInstallment()); relatedRepaymentPeriod.setEmi(newRepaymentPeriod.getEmi());
relatedRepaymentPeriod.setPrincipalDue(newRepaymentPeriod.getInterestDue());
relatedRepaymentPeriod.setPrincipalDue(newRepaymentPeriod.getPrincipalDue());
relatedRepaymentPeriod.setRemainingBalance(newRepaymentPeriod.getRemainingBalance());
relatedRepaymentPeriod.setInterestPeriods(newRepaymentPeriod.getInterestPeriods());
}); });
} }
} }
@@ -370,36 +261,33 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* @param interestRate * @param interestRate
* Interest Rate in Percentage * Interest Rate in Percentage
* *
* @param mc
*
* @return Rate Interest Rate in fraction format * @return Rate Interest Rate in fraction format
*/ */
BigDecimal calcNominalInterestRatePercentage(final BigDecimal interestRate, MathContext mc) { BigDecimal calcNominalInterestRatePercentage(final BigDecimal interestRate) {
return MathUtil.nullToZero(interestRate).divide(DIVISOR_100, mc); return MathUtil.nullToZero(interestRate).divide(DIVISOR_100, MoneyHelper.getMathContext());
} }
/** /**
* * Calculate rate factors from ONLY repayment periods * * Calculate rate factors from ONLY repayment periods
*/ */
void calculateRateFactorMinus1ForPeriods(final List<ProgressiveLoanInterestRepaymentModel> repaymentPeriods, void calculateRateFactorForPeriods(final List<RepaymentPeriod> repaymentPeriods,
final ProgressiveLoanInterestScheduleModel scheduleModel) { final ProgressiveLoanInterestScheduleModel scheduleModel) {
repaymentPeriods.forEach(repaymentPeriod -> calculateRateFactorMinus1ForRepaymentPeriod(repaymentPeriod, scheduleModel)); repaymentPeriods.forEach(repaymentPeriod -> calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel));
} }
void calculateRateFactorMinus1ForRepaymentPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, void calculateRateFactorForRepaymentPeriod(final RepaymentPeriod repaymentPeriod,
final ProgressiveLoanInterestScheduleModel scheduleModel) { final ProgressiveLoanInterestScheduleModel scheduleModel) {
repaymentPeriod.getInterestPeriods().forEach(interestPeriod -> interestPeriod repaymentPeriod.getInterestPeriods().forEach(interestPeriod -> interestPeriod
.setRateFactorMinus1(calculateRateFactorMinus1PerPeriod(repaymentPeriod, interestPeriod, scheduleModel))); .setRateFactor(calculateRateFactorPerPeriod(repaymentPeriod, interestPeriod, scheduleModel)));
} }
/** /**
* Calculate Rate Factor-1 for an exact Period * Calculate Rate Factor for an exact Period
*/ */
BigDecimal calculateRateFactorMinus1PerPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod, BigDecimal calculateRateFactorPerPeriod(final RepaymentPeriod repaymentPeriod, final InterestPeriod interestPeriod,
final ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod, final ProgressiveLoanInterestScheduleModel scheduleModel) { final ProgressiveLoanInterestScheduleModel scheduleModel) {
final MathContext mc = scheduleModel.mc();
final LoanProductRelatedDetail loanProductRelatedDetail = scheduleModel.loanProductRelatedDetail(); final LoanProductRelatedDetail loanProductRelatedDetail = scheduleModel.loanProductRelatedDetail();
final BigDecimal interestRate = calcNominalInterestRatePercentage(scheduleModel.getInterestRate(interestPeriod.getFromDate()), mc); final BigDecimal interestRate = calcNominalInterestRatePercentage(scheduleModel.getInterestRate(interestPeriod.getFromDate()));
final DaysInYearType daysInYearType = DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType()); final DaysInYearType daysInYearType = DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType());
final DaysInMonthType daysInMonthType = DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType()); final DaysInMonthType daysInMonthType = DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType());
final PeriodFrequencyType repaymentFrequency = loanProductRelatedDetail.getRepaymentPeriodFrequencyType(); final PeriodFrequencyType repaymentFrequency = loanProductRelatedDetail.getRepaymentPeriodFrequencyType();
@@ -418,23 +306,22 @@ public final class ProgressiveEMICalculator implements EMICalculator {
// TODO review: (repayment frequency: days, weeks, years; validation day is month fix 30) // TODO review: (repayment frequency: days, weeks, years; validation day is month fix 30)
// TODO refactor this logic to represent in interest period // TODO refactor this logic to represent in interest period
if (partialPeriodCalculationNeeded) { if (partialPeriodCalculationNeeded) {
final BigDecimal cumulatedPeriodFractions = calculatePeriodFractions(interestPeriod, mc); final BigDecimal cumulatedPeriodFractions = calculatePeriodFractions(interestPeriod);
return rateFactorByRepaymentPartialPeriod(interestRate, repaymentEvery, cumulatedPeriodFractions, BigDecimal.ONE, return rateFactorByRepaymentPartialPeriod(interestRate, repaymentEvery, cumulatedPeriodFractions, BigDecimal.ONE,
BigDecimal.ONE, mc).setScale(mc.getPrecision(), mc.getRoundingMode()); BigDecimal.ONE);
} }
return calculateRateFactorMinus1PerPeriodBasedOnRepaymentFrequency(interestRate, repaymentFrequency, repaymentEvery, daysInMonth, return calculateRateFactorPerPeriodBasedOnRepaymentFrequency(interestRate, repaymentFrequency, repaymentEvery, daysInMonth,
daysInYear, actualDaysInPeriod, calculatedDaysInPeriod, mc).setScale(mc.getPrecision(), mc.getRoundingMode()); daysInYear, actualDaysInPeriod, calculatedDaysInPeriod);
} }
/** /**
* Calculate Period fractions part based on how much year has in the period * Calculate Period fractions part based on how much year has in the period
* *
* @param interestPeriod * @param interestPeriod
* @param mc
* @return * @return
*/ */
BigDecimal calculatePeriodFractions(ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod, MathContext mc) { BigDecimal calculatePeriodFractions(InterestPeriod interestPeriod) {
BigDecimal cumulatedRateFactor = BigDecimal.ZERO; BigDecimal cumulatedRateFactor = BigDecimal.ZERO;
int actualYear = interestPeriod.getFromDate().getYear(); int actualYear = interestPeriod.getFromDate().getYear();
int endYear = interestPeriod.getDueDate().getYear(); int endYear = interestPeriod.getDueDate().getYear();
@@ -445,7 +332,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
endOfActualYear = actualYear == endYear ? interestPeriod.getDueDate() : LocalDate.of(actualYear, 12, 31); endOfActualYear = actualYear == endYear ? interestPeriod.getDueDate() : LocalDate.of(actualYear, 12, 31);
BigDecimal numberOfDaysInYear = BigDecimal.valueOf(Year.of(actualYear).length()); BigDecimal numberOfDaysInYear = BigDecimal.valueOf(Year.of(actualYear).length());
BigDecimal calculatedDaysInActualYear = BigDecimal.valueOf(DateUtils.getDifferenceInDays(actualDate, endOfActualYear)); BigDecimal calculatedDaysInActualYear = BigDecimal.valueOf(DateUtils.getDifferenceInDays(actualDate, endOfActualYear));
cumulatedRateFactor = cumulatedRateFactor.add(calculatedDaysInActualYear.divide(numberOfDaysInYear, mc), mc); cumulatedRateFactor = cumulatedRateFactor
.add(calculatedDaysInActualYear.divide(numberOfDaysInYear, MoneyHelper.getMathContext()), MoneyHelper.getMathContext());
actualDate = endOfActualYear; actualDate = endOfActualYear;
actualYear++; actualYear++;
} }
@@ -462,48 +350,37 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* @param daysInYear * @param daysInYear
* @param actualDaysInPeriod * @param actualDaysInPeriod
* @param calculatedDaysInPeriod * @param calculatedDaysInPeriod
* @param mc
* @return * @return
*/ */
BigDecimal calculateRateFactorMinus1PerPeriodBasedOnRepaymentFrequency(final BigDecimal interestRate, BigDecimal calculateRateFactorPerPeriodBasedOnRepaymentFrequency(final BigDecimal interestRate,
final PeriodFrequencyType repaymentFrequency, final BigDecimal repaymentEvery, final BigDecimal daysInMonth, final PeriodFrequencyType repaymentFrequency, final BigDecimal repaymentEvery, final BigDecimal daysInMonth,
final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod) {
final MathContext mc) {
return switch (repaymentFrequency) { return switch (repaymentFrequency) {
case DAYS -> rateFactorMinus1ByRepaymentEveryDay(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, case DAYS ->
calculatedDaysInPeriod, mc); rateFactorByRepaymentEveryDay(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod);
case WEEKS -> rateFactorMinus1ByRepaymentEveryWeek(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, case WEEKS ->
calculatedDaysInPeriod, mc); rateFactorByRepaymentEveryWeek(interestRate, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod);
case MONTHS -> rateFactorMinus1ByRepaymentEveryMonth(interestRate, repaymentEvery, daysInMonth, daysInYear, actualDaysInPeriod, case MONTHS -> rateFactorByRepaymentEveryMonth(interestRate, repaymentEvery, daysInMonth, daysInYear, actualDaysInPeriod,
calculatedDaysInPeriod, mc); calculatedDaysInPeriod);
default -> throw new UnsupportedOperationException("Invalid repayment frequency"); // not supported yet default -> throw new UnsupportedOperationException("Invalid repayment frequency"); // not supported yet
}; };
} }
void calculateEMIOnPeriods(final List<ProgressiveLoanInterestRepaymentModel> repaymentPeriods, void calculateEMIOnPeriods(final List<RepaymentPeriod> repaymentPeriods, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final ProgressiveLoanInterestScheduleModel scheduleModel) {
if (repaymentPeriods.isEmpty()) { if (repaymentPeriods.isEmpty()) {
return; return;
} }
final MathContext mc = scheduleModel.mc(); final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorPlus1N(repaymentPeriods));
final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorN(repaymentPeriods, mc)); final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(repaymentPeriods));
final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(repaymentPeriods, mc)); final RepaymentPeriod startPeriod = repaymentPeriods.get(0);
final var startPeriod = repaymentPeriods.get(0); // TODO: double check
final Money remainingBalanceFromPreviousPeriod = getRemainingBalanceFromPreviousPeriod(scheduleModel, startPeriod); final Money outstandingBalance = startPeriod.getInitialBalanceForEmiRecalculation();
final Money outstandingBalance = remainingBalanceFromPreviousPeriod.add(startPeriod.getDisbursedAmountInPeriod());
final Money equalMonthlyInstallment = Money.of(outstandingBalance.getCurrency(), final Money equalMonthlyInstallment = Money.of(outstandingBalance.getCurrency(),
calculateEMIValue(rateFactorN, outstandingBalance.getAmount(), fnResult, mc)); calculateEMIValue(rateFactorN, outstandingBalance.getAmount(), fnResult));
final Money finalEqualMonthlyInstallment = applyInstallmentAmountInMultiplesOf(scheduleModel, equalMonthlyInstallment); final Money finalEqualMonthlyInstallment = applyInstallmentAmountInMultiplesOf(scheduleModel, equalMonthlyInstallment);
repaymentPeriods.forEach(period -> period.setEqualMonthlyInstallment(finalEqualMonthlyInstallment)); repaymentPeriods.forEach(period -> period.setEmi(finalEqualMonthlyInstallment));
}
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, Money applyInstallmentAmountInMultiplesOf(final ProgressiveLoanInterestScheduleModel scheduleModel,
@@ -513,41 +390,41 @@ public final class ProgressiveEMICalculator implements EMICalculator {
: equalMonthlyInstallment; : equalMonthlyInstallment;
} }
Money getDifferenceBetweenLastTwoPeriod(final List<ProgressiveLoanInterestRepaymentModel> repaymentPeriods, Money getDifferenceBetweenLastTwoPeriod(final List<RepaymentPeriod> repaymentPeriods,
final ProgressiveLoanInterestScheduleModel scheduleModel) { final ProgressiveLoanInterestScheduleModel scheduleModel) {
int numberOfUpcomingPeriods = repaymentPeriods.size(); int numberOfUpcomingPeriods = repaymentPeriods.size();
if (numberOfUpcomingPeriods < 2) { if (numberOfUpcomingPeriods < 2) {
return Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency()); return Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency());
} }
final var lastPeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 1); final RepaymentPeriod lastPeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 1);
final var penultimatePeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 2); final RepaymentPeriod penultimatePeriod = repaymentPeriods.get(numberOfUpcomingPeriods - 2);
return lastPeriod.getEqualMonthlyInstallment().minus(penultimatePeriod.getEqualMonthlyInstallment()); return lastPeriod.getEmi().minus(penultimatePeriod.getEmi());
} }
/** /**
* Calculate Rate Factor Product from rate factors * Calculate Rate Factor Product from rate factors
*/ */
BigDecimal calculateRateFactorN(final List<ProgressiveLoanInterestRepaymentModel> periods, final MathContext mc) { BigDecimal calculateRateFactorPlus1N(final List<RepaymentPeriod> periods) {
return periods.stream().map(ProgressiveLoanInterestRepaymentModel::getRateFactor).reduce(BigDecimal.ONE, return periods.stream().map(RepaymentPeriod::getRateFactorPlus1).reduce(BigDecimal.ONE,
(BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc)); (BigDecimal acc, BigDecimal value) -> acc.multiply(value, MoneyHelper.getMathContext()));
} }
/** /**
* Summarize Fn values * Summarize Fn values
*/ */
BigDecimal calculateFnResult(final List<ProgressiveLoanInterestRepaymentModel> periods, final MathContext mc) { BigDecimal calculateFnResult(final List<RepaymentPeriod> periods) {
return periods.stream()// return periods.stream()//
.skip(1)// .skip(1)//
.map(ProgressiveLoanInterestRepaymentModel::getRateFactor)// .map(RepaymentPeriod::getRateFactorPlus1)//
.reduce(BigDecimal.ONE, (BigDecimal previousValue, BigDecimal rateFactor) -> fnValue(previousValue, rateFactor, mc));// .reduce(BigDecimal.ONE, this::fnValue);//
} }
/** /**
* Calculate the EMI (Equal Monthly Installment) value * Calculate the EMI (Equal Monthly Installment) value
*/ */
BigDecimal calculateEMIValue(final BigDecimal rateFactorN, final BigDecimal outstandingBalanceForRest, final BigDecimal fnResult, BigDecimal calculateEMIValue(final BigDecimal rateFactorPlus1N, final BigDecimal outstandingBalanceForRest, final BigDecimal fnResult) {
final MathContext mc) { return rateFactorPlus1N.multiply(outstandingBalanceForRest, MoneyHelper.getMathContext()).divide(fnResult,
return rateFactorN.multiply(outstandingBalanceForRest, mc).divide(fnResult, mc); MoneyHelper.getMathContext());
} }
/** /**
@@ -572,14 +449,12 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* @param calculatedDaysInPeriod * @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule) * Calculated days in Period (It has importance related to Reschedule)
* *
* @param mc
* @return Rate Factor for period * @return Rate Factor for period
*/ */
BigDecimal rateFactorMinus1ByRepaymentEveryDay(final BigDecimal interestRate, final BigDecimal repaymentEvery, BigDecimal rateFactorByRepaymentEveryDay(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInYear,
final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod) {
final MathContext mc) { return rateFactorByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, daysInYear, actualDaysInPeriod,
return rateFactorMinus1ByRepaymentPeriod(interestRate, BigDecimal.ONE, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod);
calculatedDaysInPeriod, mc);
} }
/** /**
@@ -604,14 +479,12 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* @param calculatedDaysInPeriod * @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule) * Calculated days in Period (It has importance related to Reschedule)
* *
* @param mc
* @return Rate Factor for period * @return Rate Factor for period
*/ */
BigDecimal rateFactorMinus1ByRepaymentEveryWeek(final BigDecimal interestRate, final BigDecimal repaymentEvery, BigDecimal rateFactorByRepaymentEveryWeek(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInYear,
final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod) {
final MathContext mc) { return rateFactorByRepaymentPeriod(interestRate, ONE_WEEK_IN_DAYS, repaymentEvery, daysInYear, actualDaysInPeriod,
return rateFactorMinus1ByRepaymentPeriod(interestRate, ONE_WEEK_IN_DAYS, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod);
calculatedDaysInPeriod, mc);
} }
/** /**
@@ -639,14 +512,12 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* @param calculatedDaysInPeriod * @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule) * Calculated days in Period (It has importance related to Reschedule)
* *
* @param mc
* @return Rate Factor for period * @return Rate Factor for period
*/ */
BigDecimal rateFactorMinus1ByRepaymentEveryMonth(final BigDecimal interestRate, final BigDecimal repaymentEvery, BigDecimal rateFactorByRepaymentEveryMonth(final BigDecimal interestRate, final BigDecimal repaymentEvery, final BigDecimal daysInMonth,
final BigDecimal daysInMonth, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod) {
final BigDecimal calculatedDaysInPeriod, final MathContext mc) { return rateFactorByRepaymentPeriod(interestRate, daysInMonth, repaymentEvery, daysInYear, actualDaysInPeriod,
return rateFactorMinus1ByRepaymentPeriod(interestRate, daysInMonth, repaymentEvery, daysInYear, actualDaysInPeriod, calculatedDaysInPeriod);
calculatedDaysInPeriod, mc);
} }
/** /**
@@ -674,17 +545,18 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* @param calculatedDaysInPeriod * @param calculatedDaysInPeriod
* Calculated days in Period (It has importance related to Reschedule) * Calculated days in Period (It has importance related to Reschedule)
* *
* @param mc
* @return Rate Factor for period * @return Rate Factor for period
*/ */
BigDecimal rateFactorMinus1ByRepaymentPeriod(final BigDecimal interestRate, final BigDecimal repaymentPeriodMultiplierInDays, BigDecimal rateFactorByRepaymentPeriod(final BigDecimal interestRate, final BigDecimal repaymentPeriodMultiplierInDays,
final BigDecimal repaymentEvery, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod, final BigDecimal repaymentEvery, final BigDecimal daysInYear, final BigDecimal actualDaysInPeriod,
final BigDecimal calculatedDaysInPeriod, final MathContext mc) { final BigDecimal calculatedDaysInPeriod) {
final BigDecimal interestFractionPerPeriod = repaymentPeriodMultiplierInDays.multiply(repaymentEvery, mc).divide(daysInYear, mc); final BigDecimal interestFractionPerPeriod = repaymentPeriodMultiplierInDays//
.multiply(repaymentEvery, MoneyHelper.getMathContext())//
.divide(daysInYear, MoneyHelper.getMathContext());//
return interestRate// return interestRate//
.multiply(interestFractionPerPeriod, mc)// .multiply(interestFractionPerPeriod, MoneyHelper.getMathContext())//
.multiply(actualDaysInPeriod, mc)// .multiply(actualDaysInPeriod, MoneyHelper.getMathContext())//
.divide(calculatedDaysInPeriod, mc);// .divide(calculatedDaysInPeriod, MoneyHelper.getMathContext());//
} }
/** /**
@@ -692,13 +564,12 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* *
*/ */
BigDecimal rateFactorByRepaymentPartialPeriod(final BigDecimal interestRate, final BigDecimal repaymentEvery, BigDecimal rateFactorByRepaymentPartialPeriod(final BigDecimal interestRate, final BigDecimal repaymentEvery,
final BigDecimal cumulatedPeriodRatio, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod, final BigDecimal cumulatedPeriodRatio, final BigDecimal actualDaysInPeriod, final BigDecimal calculatedDaysInPeriod) {
final MathContext mc) { final BigDecimal interestFractionPerPeriod = repaymentEvery.multiply(cumulatedPeriodRatio);
final BigDecimal interestFractionPerPeriod = repaymentEvery.multiply(cumulatedPeriodRatio, mc);
return interestRate// return interestRate//
.multiply(interestFractionPerPeriod, mc)// .multiply(interestFractionPerPeriod, MoneyHelper.getMathContext())//
.multiply(actualDaysInPeriod, mc)// .multiply(actualDaysInPeriod, MoneyHelper.getMathContext())//
.divide(calculatedDaysInPeriod, mc);// .divide(calculatedDaysInPeriod, MoneyHelper.getMathContext());//
} }
/** /**
@@ -710,67 +581,26 @@ public final class ProgressiveEMICalculator implements EMICalculator {
* *
* @param currentRateFactor * @param currentRateFactor
* *
* @param mc
*
*/ */
BigDecimal fnValue(final BigDecimal previousFnValue, final BigDecimal currentRateFactor, final MathContext mc) { BigDecimal fnValue(final BigDecimal previousFnValue, final BigDecimal currentRateFactor) {
return BigDecimal.ONE.add(previousFnValue.multiply(currentRateFactor, mc), mc); return BigDecimal.ONE.add(previousFnValue.multiply(currentRateFactor, MoneyHelper.getMathContext()), MoneyHelper.getMathContext());
}
void calculatePrincipalInterestComponentsForPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel) {
Money outstandingBalance = Money.zero(scheduleModel.loanProductRelatedDetail().getCurrency());
for (var repaymentPeriod : scheduleModel.repayments()) {
repaymentPeriod.setInitialBalance(outstandingBalance);
calculatePrincipalInterestComponentsForPeriod(repaymentPeriod, null);
outstandingBalance = repaymentPeriod.getRemainingBalance();
}
}
void calculatePrincipalInterestComponentsForPeriod(final ProgressiveLoanInterestRepaymentModel repaymentPeriod,
final LocalDate calculateTill) {
final Money zeroAmount = Money.zero(repaymentPeriod.getInitialBalance().getCurrency());
Money outstandingBalance = repaymentPeriod.getInitialBalance();
Money balanceCorrection = zeroAmount;
Money cumulatedInterest = zeroAmount;
for (ProgressiveLoanInterestRepaymentInterestPeriod interestPeriod : repaymentPeriod.getInterestPeriods()) {
final boolean shouldInvalidateInterestPeriod = calculateTill != null && interestPeriod.getDueDate().isAfter(calculateTill);
if (shouldInvalidateInterestPeriod) {
interestPeriod.setInterestDue(zeroAmount);
interestPeriod.setDisbursedAmount(zeroAmount);
interestPeriod.setCorrectionAmount(zeroAmount);
continue;
}
outstandingBalance = outstandingBalance.plus(interestPeriod.getDisbursedAmount());
balanceCorrection = balanceCorrection.plus(interestPeriod.getCorrectionAmount());
final Money calculatedInterest = outstandingBalance.plus(balanceCorrection).multipliedBy(interestPeriod.getRateFactorMinus1());
interestPeriod.setInterestDue(calculatedInterest);
cumulatedInterest = cumulatedInterest.plus(calculatedInterest);
}
final Money calculatedPrincipal = repaymentPeriod.isLastPeriod() ? outstandingBalance
: repaymentPeriod.getEqualMonthlyInstallment().minus(cumulatedInterest);
if (repaymentPeriod.isLastPeriod()) {
repaymentPeriod.setEqualMonthlyInstallment(calculatedPrincipal.add(cumulatedInterest));
}
final Money remainingBalance = outstandingBalance.minus(calculatedPrincipal);
repaymentPeriod.setPrincipalDue(calculatedPrincipal);
repaymentPeriod.setRemainingBalance(remainingBalance);
} }
@Override @Override
public ProgressiveLoanInterestScheduleModel generateModel(LoanProductRelatedDetail loanProductRelatedDetail, public ProgressiveLoanInterestScheduleModel generateModel(LoanProductRelatedDetail loanProductRelatedDetail,
Integer installmentAmountInMultiplesOf, List<LoanRepaymentScheduleInstallment> repaymentPeriods, MathContext mc) { Integer installmentAmountInMultiplesOf, List<LoanRepaymentScheduleInstallment> repaymentPeriods) {
List<LoanRepaymentScheduleInstallment> repaymentModelsWithoutDownPayment = repaymentPeriods.stream() List<LoanRepaymentScheduleInstallment> repaymentModelsWithoutDownPayment = repaymentPeriods.stream()
.filter(period -> !period.isDownPayment() && !period.isAdditional()).toList(); .filter(period -> !period.isDownPayment() && !period.isAdditional()).toList();
List<ProgressiveLoanInterestRepaymentModel> repaymentModels = progressiveLoanInterestRepaymentModelMapper
.map(repaymentModelsWithoutDownPayment);
if (!repaymentModels.isEmpty()) { List<RepaymentPeriod> repaymentModels = new ArrayList<>();
repaymentModels.get(repaymentModels.size() - 1).setLastPeriod(true); RepaymentPeriod previousPeriod = null;
for (LoanRepaymentScheduleInstallment repaymentModel : repaymentModelsWithoutDownPayment) {
RepaymentPeriod currentPeriod = new RepaymentPeriod(previousPeriod, repaymentModel.getFromDate(), repaymentModel.getDueDate(),
Money.zero(repaymentModel.getLoan().getCurrency()));
previousPeriod = currentPeriod;
repaymentModels.add(currentPeriod);
} }
return new ProgressiveLoanInterestScheduleModel(repaymentModels, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
return new ProgressiveLoanInterestScheduleModel(repaymentModels, loanProductRelatedDetail, installmentAmountInMultiplesOf);
} }
} }
@@ -1,37 +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.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<ProgressiveLoanInterestRepaymentModel> map(List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments);
default ProgressiveLoanInterestRepaymentModel map(LoanRepaymentScheduleInstallment repaymentScheduleInstallment) {
return new ProgressiveLoanInterestRepaymentModel(repaymentScheduleInstallment.getFromDate(),
repaymentScheduleInstallment.getDueDate(), Money.zero(repaymentScheduleInstallment.getLoan().getCurrency()));
}
}
@@ -47,7 +47,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class LoanScheduleGeneratorTest { class LoanScheduleGeneratorTest {
private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator(null); private static final ProgressiveEMICalculator emiCalculator = new ProgressiveEMICalculator();
private static MockedStatic<MoneyHelper> moneyHelper = Mockito.mockStatic(MoneyHelper.class); private static MockedStatic<MoneyHelper> moneyHelper = Mockito.mockStatic(MoneyHelper.class);
private static final ApplicationCurrency APPLICATION_CURRENCY = new ApplicationCurrency("USD", "USD", 2, 1, "USD", "$"); private static final ApplicationCurrency APPLICATION_CURRENCY = new ApplicationCurrency("USD", "USD", 2, 1, "USD", "$");
private static final MonetaryCurrency MONETARY_CURRENCY = MonetaryCurrency.fromApplicationCurrency(APPLICATION_CURRENCY); private static final MonetaryCurrency MONETARY_CURRENCY = MonetaryCurrency.fromApplicationCurrency(APPLICATION_CURRENCY);