FINERACT-1981: pay-off transaction for progressive loans

This commit is contained in:
Kristof Jozsa
2024-08-21 17:55:18 +02:00
committed by Adam Saghy
parent 721ceaa32c
commit 7304af57bc
11 changed files with 371 additions and 43 deletions
@@ -250,7 +250,7 @@ public class LoanSummaryData {
return BigDecimal.ZERO;
}
private static BigDecimal computeAccruedInterestTillDay(final LoanSchedulePeriodData period, final long untilDay,
public static BigDecimal computeAccruedInterestTillDay(final LoanSchedulePeriodData period, final long untilDay,
final CurrencyData currency) {
Integer remainingDays = period.getDaysInPeriod();
BigDecimal totalAccruedInterest = BigDecimal.ZERO;
@@ -0,0 +1,65 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.data;
import lombok.Data;
import lombok.experimental.Accessors;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@Data
@Accessors(chain = true, fluent = true)
public class OutstandingAmountsDTO {
private Money principal;
private Money interest;
private Money feeCharges;
private Money penaltyCharges;
public OutstandingAmountsDTO(MonetaryCurrency currency) {
this.principal = Money.zero(currency);
this.interest = Money.zero(currency);
this.feeCharges = Money.zero(currency);
this.penaltyCharges = Money.zero(currency);
}
public Money getTotalOutstanding() {
return principal() //
.plus(interest()) //
.plus(feeCharges()) //
.plus(penaltyCharges());
}
public void plusPrincipal(Money principal) {
this.principal = this.principal.plus(principal);
}
public void plusInterest(Money interest) {
this.interest = this.interest.plus(interest);
}
public void plusFeeCharges(Money feeCharges) {
this.feeCharges = this.feeCharges.plus(feeCharges);
}
public void plusPenaltyCharges(Money penaltyCharges) {
this.penaltyCharges = this.penaltyCharges.plus(penaltyCharges);
}
}
@@ -109,6 +109,7 @@ import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
@@ -4368,8 +4369,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
loanRepaymentScheduleTransactionProcessor, generatorDTO.getRecalculateFrom());
}
public LoanRepaymentScheduleInstallment fetchPrepaymentDetail(final ScheduleGeneratorDTO scheduleGeneratorDTO, final LocalDate onDate) {
LoanRepaymentScheduleInstallment installment;
public OutstandingAmountsDTO fetchPrepaymentDetail(final ScheduleGeneratorDTO scheduleGeneratorDTO, final LocalDate onDate) {
OutstandingAmountsDTO outstandingAmounts;
if (this.loanRepaymentScheduleDetail.isInterestRecalculationEnabled()) {
final MathContext mc = MoneyHelper.getMathContext();
@@ -4381,12 +4382,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
.create(loanApplicationTerms.getLoanScheduleType(), interestMethod);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = this.transactionProcessorFactory
.determineProcessor(this.transactionProcessingStrategyCode);
installment = loanScheduleGenerator.calculatePrepaymentAmount(getCurrency(), onDate, loanApplicationTerms, mc, this,
outstandingAmounts = loanScheduleGenerator.calculatePrepaymentAmount(getCurrency(), onDate, loanApplicationTerms, mc, this,
scheduleGeneratorDTO.getHolidayDetailDTO(), loanRepaymentScheduleTransactionProcessor);
} else {
installment = this.getTotalOutstandingOnLoan();
outstandingAmounts = this.getTotalOutstandingOnLoan();
}
return installment;
return outstandingAmounts;
}
public LoanApplicationTerms constructLoanApplicationTerms(final ScheduleGeneratorDTO scheduleGeneratorDTO) {
@@ -4460,11 +4461,11 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return annualNominalInterestRate;
}
private LoanRepaymentScheduleInstallment getTotalOutstandingOnLoan() {
Money feeCharges = Money.zero(loanCurrency());
Money penaltyCharges = Money.zero(loanCurrency());
private OutstandingAmountsDTO getTotalOutstandingOnLoan() {
Money totalPrincipal = Money.zero(loanCurrency());
Money totalInterest = Money.zero(loanCurrency());
Money feeCharges = Money.zero(loanCurrency());
Money penaltyCharges = Money.zero(loanCurrency());
final Set<LoanInterestRecalcualtionAdditionalDetails> compoundingDetails = null;
List<LoanRepaymentScheduleInstallment> repaymentSchedule = getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment scheduledRepayment : repaymentSchedule) {
@@ -4473,9 +4474,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
feeCharges = feeCharges.plus(scheduledRepayment.getFeeChargesOutstanding(loanCurrency()));
penaltyCharges = penaltyCharges.plus(scheduledRepayment.getPenaltyChargesOutstanding(loanCurrency()));
}
LocalDate businessDate = DateUtils.getBusinessLocalDate();
return new LoanRepaymentScheduleInstallment(null, 0, businessDate, businessDate, totalPrincipal.getAmount(),
totalInterest.getAmount(), feeCharges.getAmount(), penaltyCharges.getAmount(), false, compoundingDetails);
return new OutstandingAmountsDTO(totalPrincipal.getCurrency()).principal(totalPrincipal).interest(totalInterest)
.feeCharges(feeCharges).penaltyCharges(penaltyCharges);
}
public LocalDate fetchInterestRecalculateFromDate() {
@@ -583,20 +583,20 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
return interestPortionOfTransaction;
}
public Money payPrincipalComponent(final LocalDate transactionDate, final Money transactionAmountRemaining) {
public Money payPrincipalComponent(final LocalDate transactionDate, final Money transactionAmount) {
final MonetaryCurrency currency = transactionAmountRemaining.getCurrency();
final MonetaryCurrency currency = transactionAmount.getCurrency();
Money principalPortionOfTransaction = Money.zero(currency);
if (transactionAmountRemaining.isZero()) {
if (transactionAmount.isZero()) {
return principalPortionOfTransaction;
}
final Money principalDue = getPrincipalOutstanding(currency);
if (transactionAmountRemaining.isGreaterThanOrEqualTo(principalDue)) {
if (transactionAmount.isGreaterThanOrEqualTo(principalDue)) {
this.principalCompleted = getPrincipalCompleted(currency).plus(principalDue).getAmount();
principalPortionOfTransaction = principalPortionOfTransaction.plus(principalDue);
} else {
this.principalCompleted = getPrincipalCompleted(currency).plus(transactionAmountRemaining).getAmount();
principalPortionOfTransaction = principalPortionOfTransaction.plus(transactionAmountRemaining);
this.principalCompleted = getPrincipalCompleted(currency).plus(transactionAmount).getAmount();
principalPortionOfTransaction = principalPortionOfTransaction.plus(transactionAmount);
}
this.principalCompleted = defaultToNullIfZero(this.principalCompleted);
@@ -45,6 +45,7 @@ import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalcualtionAdditionalDetails;
@@ -2775,7 +2776,7 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
* Method returns the amount payable to close the loan account as of today.
*/
@Override
public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(final MonetaryCurrency currency, final LocalDate onDate,
public OutstandingAmountsDTO calculatePrepaymentAmount(final MonetaryCurrency currency, final LocalDate onDate,
final LoanApplicationTerms loanApplicationTerms, final MathContext mc, Loan loan, final HolidayDetailDTO holidayDetailDTO,
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) {
@@ -2790,10 +2791,10 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(loanApplicationTerms.getExpectedDisbursementDate(),
loanTransactions, currency, loanScheduleDTO.getInstallments(), loan.getActiveCharges());
Money feeCharges = Money.zero(currency);
Money penaltyCharges = Money.zero(currency);
Money totalPrincipal = Money.zero(currency);
Money totalInterest = Money.zero(currency);
Money feeCharges = Money.zero(currency);
Money penaltyCharges = Money.zero(currency);
for (final LoanRepaymentScheduleInstallment currentInstallment : loanScheduleDTO.getInstallments()) {
if (currentInstallment.isNotFullyPaidOff()) {
totalPrincipal = totalPrincipal.plus(currentInstallment.getPrincipalOutstanding(currency));
@@ -2802,8 +2803,10 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
penaltyCharges = penaltyCharges.plus(currentInstallment.getPenaltyChargesOutstanding(currency));
}
}
final Set<LoanInterestRecalcualtionAdditionalDetails> compoundingDetails = null;
return new LoanRepaymentScheduleInstallment(null, 0, onDate, onDate, totalPrincipal.getAmount(), totalInterest.getAmount(),
feeCharges.getAmount(), penaltyCharges.getAmount(), false, compoundingDetails);
return new OutstandingAmountsDTO(currency) //
.principal(totalPrincipal) //
.interest(totalInterest) //
.feeCharges(feeCharges) //
.penaltyCharges(penaltyCharges);
}
}
@@ -23,9 +23,9 @@ import java.time.LocalDate;
import java.util.Set;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO;
@@ -38,8 +38,8 @@ public interface LoanScheduleGenerator {
HolidayDetailDTO holidayDetailDTO, LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor,
LocalDate rescheduleFrom);
LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO,
OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate, LoanApplicationTerms loanApplicationTerms,
MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO,
LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor);
}
@@ -18,22 +18,29 @@
*/
package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import static java.time.temporal.ChronoUnit.DAYS;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
@@ -48,6 +55,7 @@ import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
@@ -222,10 +230,80 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
}
@Override
public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO,
LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) {
return null;
return switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) {
case TILL_PRE_CLOSURE_DATE -> {
log.debug("calculating prepayment amount till pre closure date (Strategy A)");
OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency);
AtomicBoolean firstAfterPayoff = new AtomicBoolean(true);
loan.getRepaymentScheduleInstallments().forEach(installment -> {
boolean isInstallmentAfterPayoff = installment.getDueDate().isAfter(onDate);
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (isInstallmentAfterPayoff) {
if (firstAfterPayoff.getAndSet(false)) {
outstandingAmounts.plusInterest(calculatePayableInterest(loan, installment, onDate));
} else {
log.debug("Installment {} - {} is after payoff, not counting interest", installment.getFromDate(),
installment.getDueDate());
}
} else {
log.debug("adding interest for {} - {}: {}", installment.getFromDate(), installment.getDueDate(),
installment.getInterestOutstanding(currency));
outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency));
}
outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency));
outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency));
});
yield outstandingAmounts;
}
case TILL_REST_FREQUENCY_DATE -> {
log.debug("calculating prepayment amount till rest frequency date (Strategy B)");
OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency);
loan.getRepaymentScheduleInstallments().forEach(installment -> {
boolean isPayoffAfterInstallmentFrom = installment.getFromDate().isAfter(onDate);
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (!isPayoffAfterInstallmentFrom) {
outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency));
} else {
log.debug("Payoff after installment {}, not counting interest", installment.getDueDate());
}
outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency));
outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency));
});
yield outstandingAmounts;
}
case NONE -> throw new UnsupportedOperationException("Pre-closure interest calculation strategy not supported");
};
}
private Money calculatePayableInterest(Loan loan, LoanRepaymentScheduleInstallment installment, LocalDate onDate) {
RoundingMode roundingMode = MoneyHelper.getRoundingMode();
MonetaryCurrency currency = loan.getCurrency();
Money originalInterest = installment.getInterestCharged(currency);
log.debug("calculating interest for {} from {} to {}", originalInterest, installment.getFromDate(), installment.getDueDate());
LocalDate start = installment.getFromDate();
Money payableInterest = Money.zero(currency);
while (!start.isEqual(onDate)) {
long between = DAYS.between(start, installment.getDueDate());
Money dailyInterest = originalInterest.minus(payableInterest).dividedBy(between, roundingMode);
log.debug("Daily interest is {}: {} / {}, total: {}", dailyInterest, originalInterest.minus(payableInterest), between,
payableInterest.add(dailyInterest));
payableInterest = payableInterest.add(dailyInterest);
start = start.plusDays(1);
}
payableInterest = payableInterest.minus(installment.getInterestPaid(currency).minus(installment.getInterestWaived(currency)));
log.debug("Payable interest is {}", payableInterest);
return payableInterest;
}
// Private, internal methods
@@ -0,0 +1,176 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import static java.math.BigDecimal.ZERO;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_REST_FREQUENCY_DATE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.test.util.ReflectionTestUtils;
class ProgressiveLoanScheduleGeneratorTest {
static class TestRow {
LocalDate fromDate;
LocalDate dueDate;
BigDecimal balance;
BigDecimal principal;
BigDecimal interest;
BigDecimal fee;
BigDecimal penalty;
boolean paid;
TestRow(LocalDate fromDate, LocalDate dueDate, BigDecimal balance, BigDecimal principal, BigDecimal interest, BigDecimal fee,
BigDecimal penalty, boolean paid) {
this.fromDate = fromDate;
this.dueDate = dueDate;
this.balance = balance;
this.principal = principal;
this.interest = interest;
this.fee = fee;
this.penalty = penalty;
this.paid = paid;
}
}
private ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(null, null);
private MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null);
private HolidayDetailDTO holidays = new HolidayDetailDTO(false, null, null);
LoanRepaymentScheduleTransactionProcessor processor = mock(LoanRepaymentScheduleTransactionProcessor.class);
static {
ConfigurationDomainService domainService = mock(ConfigurationDomainService.class);
when(domainService.getRoundingMode()).thenReturn(RoundingMode.HALF_UP.ordinal());
ReflectionTestUtils.setField(MoneyHelper.class, "staticConfigurationDomainService", domainService);
}
@BeforeAll
public static void beforeAll() {
((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).setLevel(ch.qos.logback.classic.Level.DEBUG);
}
@AfterAll
public static void afterAll() {
((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).setLevel(Level.INFO);
}
public List<TestRow> testRows() {
return List.of(
new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(83.57), BigDecimal.valueOf(16.43),
BigDecimal.valueOf(0.58), ZERO, ZERO, true),
new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(67.05), BigDecimal.valueOf(16.52),
BigDecimal.valueOf(0.49), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1), BigDecimal.valueOf(50.43), BigDecimal.valueOf(16.62),
BigDecimal.valueOf(0.39), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1), BigDecimal.valueOf(33.71), BigDecimal.valueOf(16.72),
BigDecimal.valueOf(0.29), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1), BigDecimal.valueOf(16.90), BigDecimal.valueOf(16.81),
BigDecimal.valueOf(0.20), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1), BigDecimal.valueOf(00.90), BigDecimal.valueOf(16.90),
BigDecimal.valueOf(0.10), ZERO, ZERO, false));
}
@Test
public void calculatePrepaymentAmount_TILL_PRE_CLOSURE_DATE() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_PRE_CLOSURE_DATE);
Loan loan = prepareLoanWithInstallments(testRows());
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 2, 15), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(83.84), amounts.getTotalOutstanding().getAmount());
}
@Test
public void calculatePrepaymentAmount_TILL_REST_FREQUENCY_DATE() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_REST_FREQUENCY_DATE);
Loan loan = prepareLoanWithInstallments(testRows());
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 2, 15), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(84.06), amounts.getTotalOutstanding().getAmount());
}
@Test
public void calculateSameDayPayoff() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_PRE_CLOSURE_DATE);
Loan loan = prepareLoanWithInstallments(List.of(
new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100),
BigDecimal.valueOf(2), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100),
BigDecimal.valueOf(2), ZERO, ZERO, false)));
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 1, 1), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(200.0).longValue(), amounts.getTotalOutstanding().getAmount().longValue());
}
@NotNull
private Loan prepareLoanWithInstallments(List<TestRow> rows) {
Loan loan = mock(Loan.class);
List<LoanRepaymentScheduleInstallment> installments = createInstallments(rows, loan, usd);
when(loan.getRepaymentScheduleInstallments()).thenReturn(installments);
when(loan.getCurrency()).thenReturn(usd);
return loan;
}
private List<LoanRepaymentScheduleInstallment> createInstallments(List<TestRow> rows, Loan loan, MonetaryCurrency usd) {
AtomicInteger count = new AtomicInteger(1);
return rows.stream().map(row -> {
LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, count.incrementAndGet(), row.fromDate,
row.dueDate, row.principal, row.interest, row.fee, row.penalty, true, null, null, row.paid);
if (row.paid) {
installment.payPrincipalComponent(row.fromDate, Money.of(usd, row.principal));
installment.payInterestComponent(row.fromDate, Money.of(usd, row.interest));
installment.updateObligationMet(true);
}
return installment;
}).toList();
}
}
@@ -91,6 +91,7 @@ import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
@@ -732,7 +733,7 @@ public class LoanScheduleAssembler {
loanRepaymentScheduleTransactionProcessor, rescheduleFrom).getLoanScheduleModel();
}
public LoanRepaymentScheduleInstallment calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
LoanApplicationTerms loanApplicationTerms, Loan loan, final Long officeId,
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) {
final LoanScheduleGenerator loanScheduleGenerator = this.loanScheduleFactory.create(loanApplicationTerms.getLoanScheduleType(),
@@ -748,7 +749,6 @@ public class LoanScheduleAssembler {
return loanScheduleGenerator.calculatePrepaymentAmount(currency, onDate, loanApplicationTerms, mc, loan, holidayDetailDTO,
loanRepaymentScheduleTransactionProcessor);
}
public void assempleVariableScheduleFrom(final Loan loan, final String json) {
@@ -31,6 +31,7 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails;
@@ -115,8 +116,12 @@ public class LoanScheduleCalculationPlatformServiceImpl implements LoanScheduleC
}
}
LoanApplicationTerms loanApplicationTerms = constructLoanApplicationTerms(loan);
LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment = this.loanScheduleAssembler.calculatePrepaymentAmount(currency,
today, loanApplicationTerms, loan, loan.getOfficeId(), loanRepaymentScheduleTransactionProcessor);
OutstandingAmountsDTO outstandingAmountsDTO = this.loanScheduleAssembler.calculatePrepaymentAmount(currency, today,
loanApplicationTerms, loan, loan.getOfficeId(), loanRepaymentScheduleTransactionProcessor);
LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment = new LoanRepaymentScheduleInstallment(null, 0, today, today,
outstandingAmountsDTO.principal().getAmount(), outstandingAmountsDTO.interest().getAmount(),
outstandingAmountsDTO.feeCharges().getAmount(), outstandingAmountsDTO.penaltyCharges().getAmount(), false, null);
Money totalAmount = totalPrincipal.plus(loanRepaymentScheduleInstallment.getFeeChargesOutstanding(currency))
.plus(loanRepaymentScheduleInstallment.getPenaltyChargesOutstanding(currency));
Money interestDue = Money.zero(currency);
@@ -104,6 +104,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionRelationData;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.data.PaidInAdvanceData;
import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
@@ -473,20 +474,20 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
final LocalDate earliestUnpaidInstallmentDate = DateUtils.getBusinessLocalDate();
final LocalDate recalculateFrom = null;
final ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, recalculateFrom);
final LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment = loan.fetchPrepaymentDetail(scheduleGeneratorDTO, onDate);
final OutstandingAmountsDTO outstandingAmounts = loan.fetchPrepaymentDetail(scheduleGeneratorDTO, onDate);
final LoanTransactionEnumData transactionType = LoanEnumerations.transactionType(repaymentTransactionType);
final Collection<PaymentTypeData> paymentOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final BigDecimal outstandingLoanBalance = loanRepaymentScheduleInstallment.getPrincipalOutstanding(currency).getAmount();
final BigDecimal outstandingLoanBalance = outstandingAmounts.principal().getAmount();
final BigDecimal unrecognizedIncomePortion = null;
BigDecimal adjustedChargeAmount = adjustPrepayInstallmentCharge(loan, onDate);
return new LoanTransactionData(null, null, null, transactionType, null, currencyData, earliestUnpaidInstallmentDate,
loanRepaymentScheduleInstallment.getTotalOutstanding(currency).getAmount().subtract(adjustedChargeAmount),
loan.getNetDisbursalAmount(), loanRepaymentScheduleInstallment.getPrincipalOutstanding(currency).getAmount(),
loanRepaymentScheduleInstallment.getInterestOutstanding(currency).getAmount(),
loanRepaymentScheduleInstallment.getFeeChargesOutstanding(currency).getAmount().subtract(adjustedChargeAmount),
loanRepaymentScheduleInstallment.getPenaltyChargesOutstanding(currency).getAmount(), null, unrecognizedIncomePortion,
paymentOptions, ExternalId.empty(), null, null, outstandingLoanBalance, false, loanId, loan.getExternalId());
BigDecimal adjustedChargeAmount = adjustPrepayInstallmentCharge(loan, onDate);
BigDecimal totalAdjusted = outstandingAmounts.getTotalOutstanding().getAmount().subtract(adjustedChargeAmount);
return new LoanTransactionData(null, null, null, transactionType, null, currencyData, earliestUnpaidInstallmentDate, totalAdjusted,
loan.getNetDisbursalAmount(), outstandingAmounts.principal().getAmount(), outstandingAmounts.interest().getAmount(),
outstandingAmounts.feeCharges().getAmount().subtract(adjustedChargeAmount), outstandingAmounts.penaltyCharges().getAmount(),
null, unrecognizedIncomePortion, paymentOptions, ExternalId.empty(), null, null, outstandingLoanBalance, false, loanId,
loan.getExternalId());
}
private BigDecimal adjustPrepayInstallmentCharge(Loan loan, final LocalDate onDate) {