FINERACT-2220: Use stored ProgressiveLoanInterestScheduleModel for calculation

This commit is contained in:
Soma Sörös
2025-04-29 13:12:51 +02:00
committed by Adam Saghy
parent d7f321bc65
commit 2d17fcd478
14 changed files with 408 additions and 162 deletions
@@ -0,0 +1,189 @@
/**
* 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.service;
import java.math.MathContext;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.service.DateUtils;
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.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
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.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleGenerator;
import org.apache.fineract.portfolio.loanaccount.mapper.LoanTermVariationsMapper;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.ObjectUtils;
@Service
@RequiredArgsConstructor
public class LoanTransactionProcessingServiceImpl implements LoanTransactionProcessingService {
private final LoanRepaymentScheduleTransactionProcessorFactory transactionProcessorFactory;
private final LoanTermVariationsMapper loanMapper;
private final InterestScheduleModelRepositoryWrapper modelRepository;
@Override
public ChangedTransactionDetail processLatestTransaction(String transactionProcessingStrategyCode, LoanTransaction loanTransaction,
TransactionCtx ctx) {
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor(
transactionProcessingStrategyCode);
return loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, ctx);
}
private Loan getLoan(List<LoanTransaction> loanTransactions, List<LoanRepaymentScheduleInstallment> installments,
Set<LoanCharge> charges) {
if (!ObjectUtils.isEmpty(loanTransactions)) {
return loanTransactions.getFirst().getLoan();
} else if (!ObjectUtils.isEmpty(installments)) {
return installments.getFirst().getLoan();
} else if (!ObjectUtils.isEmpty(charges)) {
return charges.iterator().next().getLoan();
} else {
throw new IllegalArgumentException("No loan found for the given transactions, installments or charges");
}
}
@Override
public ChangedTransactionDetail reprocessLoanTransactions(String transactionProcessingStrategyCode, LocalDate disbursementDate,
List<LoanTransaction> loanTransactions, MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
Set<LoanCharge> charges) {
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor(
transactionProcessingStrategyCode);
if (loanRepaymentScheduleTransactionProcessor instanceof AdvancedPaymentScheduleTransactionProcessor advancedProcessor) {
LocalDate currentDate = DateUtils.getBusinessLocalDate();
Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> result = advancedProcessor
.reprocessProgressiveLoanTransactions(disbursementDate, currentDate, loanTransactions, currency, installments, charges);
if (!TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
modelRepository.writeInterestScheduleModel(getLoan(loanTransactions, installments, charges), result.getRight());
}
return result.getLeft();
} else {
return loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(disbursementDate, loanTransactions, currency,
installments, charges);
}
}
@Override
public LoanRepaymentScheduleTransactionProcessor getTransactionProcessor(String transactionProcessingStrategyCode) {
return transactionProcessorFactory.determineProcessor(transactionProcessingStrategyCode);
}
@Override
public Optional<ChangedTransactionDetail> processPostDisbursementTransactions(Loan loan) {
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor(
loan.getTransactionProcessingStrategyCode());
final List<LoanTransaction> allNonContraTransactionsPostDisbursement = loan.retrieveListOfTransactionsForReprocessing();
final List<LoanTransaction> copyTransactions = new ArrayList<>();
if (allNonContraTransactionsPostDisbursement.isEmpty()) {
return Optional.empty();
}
// TODO: Probably this is not needed and can be eliminated, make sure to double check it
for (LoanTransaction loanTransaction : allNonContraTransactionsPostDisbursement) {
copyTransactions.add(LoanTransaction.copyTransactionProperties(loanTransaction));
}
final ChangedTransactionDetail changedTransactionDetail = loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(
loan.getDisbursementDate(), copyTransactions, loan.getCurrency(), loan.getRepaymentScheduleInstallments(),
loan.getActiveCharges());
loan.updateLoanSummaryDerivedFields();
return Optional.of(changedTransactionDetail);
}
@Override
public LoanScheduleDTO getRecalculatedSchedule(final ScheduleGeneratorDTO generatorDTO, Loan loan) {
if (!loan.isInterestBearingAndInterestRecalculationEnabled() || loan.isNpa() || loan.isChargedOff()) {
return null;
}
final InterestMethod interestMethod = loan.getLoanRepaymentScheduleDetail().getInterestMethod();
final LoanScheduleGenerator loanScheduleGenerator = generatorDTO.getLoanScheduleFactory()
.create(loan.getLoanRepaymentScheduleDetail().getLoanScheduleType(), interestMethod);
final MathContext mc = MoneyHelper.getMathContext();
final LoanApplicationTerms loanApplicationTerms = loanMapper.constructLoanApplicationTerms(generatorDTO, loan);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor(
loan.getTransactionProcessingStrategyCode());
return loanScheduleGenerator.rescheduleNextInstallments(mc, loanApplicationTerms, loan, generatorDTO.getHolidayDetailDTO(),
loanRepaymentScheduleTransactionProcessor, generatorDTO.getRecalculateFrom());
}
@Override
public OutstandingAmountsDTO fetchPrepaymentDetail(final ScheduleGeneratorDTO scheduleGeneratorDTO, final LocalDate onDate, Loan loan) {
OutstandingAmountsDTO outstandingAmounts;
if (loan.isInterestBearingAndInterestRecalculationEnabled() && !loan.isChargeOffOnDate(onDate)) {
final MathContext mc = MoneyHelper.getMathContext();
final InterestMethod interestMethod = loan.getLoanRepaymentScheduleDetail().getInterestMethod();
final LoanApplicationTerms loanApplicationTerms = loanMapper.constructLoanApplicationTerms(scheduleGeneratorDTO, loan);
final LoanScheduleGenerator loanScheduleGenerator = scheduleGeneratorDTO.getLoanScheduleFactory()
.create(loanApplicationTerms.getLoanScheduleType(), interestMethod);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor(
loan.getTransactionProcessingStrategyCode());
outstandingAmounts = loanScheduleGenerator.calculatePrepaymentAmount(loan.getCurrency(), onDate, loanApplicationTerms, mc, loan,
scheduleGeneratorDTO.getHolidayDetailDTO(), loanRepaymentScheduleTransactionProcessor);
} else {
outstandingAmounts = getTotalOutstandingOnLoan(loan);
}
return outstandingAmounts;
}
private OutstandingAmountsDTO getTotalOutstandingOnLoan(Loan loan) {
Money totalPrincipal = Money.zero(loan.getCurrency());
Money totalInterest = Money.zero(loan.getCurrency());
Money feeCharges = Money.zero(loan.getCurrency());
Money penaltyCharges = Money.zero(loan.getCurrency());
List<LoanRepaymentScheduleInstallment> repaymentSchedule = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment scheduledRepayment : repaymentSchedule) {
totalPrincipal = totalPrincipal.plus(scheduledRepayment.getPrincipalOutstanding(loan.getCurrency()));
totalInterest = totalInterest.plus(scheduledRepayment.getInterestOutstanding(loan.getCurrency()));
feeCharges = feeCharges.plus(scheduledRepayment.getFeeChargesOutstanding(loan.getCurrency()));
penaltyCharges = penaltyCharges.plus(scheduledRepayment.getPenaltyChargesOutstanding(loan.getCurrency()));
}
return new OutstandingAmountsDTO(totalPrincipal.getCurrency()).principal(totalPrincipal).interest(totalInterest)
.feeCharges(feeCharges).penaltyCharges(penaltyCharges);
}
}
@@ -53,6 +53,7 @@ public class ProgressiveLoanSummaryDataProvider extends CommonLoanSummaryDataPro
private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor;
private final EMICalculator emiCalculator;
private final LoanRepositoryWrapper loanRepository;
private final InterestScheduleModelRepositoryWrapper modelRepository;
@Override
public boolean accept(String loanProcessingStrategyCode) {
@@ -78,6 +79,24 @@ public class ProgressiveLoanSummaryDataProvider extends CommonLoanSummaryDataPro
&& businessDate.isAfter(i.getFromDate()) && !businessDate.isAfter(i.getDueDate())).findFirst();
}
private ProgressiveLoanInterestScheduleModel calculateModel(Loan loan, LocalDate businessDate) {
List<LoanTransaction> transactionsToReprocess = loan.retrieveListOfTransactionsForReprocessing().stream()
.filter(t -> !t.isAccrualActivity()).toList();
Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> changedTransactionDetailProgressiveLoanInterestScheduleModelPair = advancedPaymentScheduleTransactionProcessor
.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(), businessDate, transactionsToReprocess, loan.getCurrency(),
loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
ProgressiveLoanInterestScheduleModel model = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getRight();
final List<Long> replayedTransactions = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft()
.getTransactionChanges().stream().filter(change -> change.getOldTransaction() != null && change.getNewTransaction() != null)
.map(change -> change.getNewTransaction().getId()).filter(Objects::nonNull).toList();
if (!replayedTransactions.isEmpty()) {
log.warn("Reprocessed transactions show differences: There are unsaved changes of the following transactions: {}",
replayedTransactions);
}
return model;
}
@Override
public BigDecimal computeTotalUnpaidPayableNotDueInterestAmountOnActualPeriod(final Loan loan,
final Collection<LoanSchedulePeriodData> periods, final LocalDate businessDate, final CurrencyData currency,
@@ -93,21 +112,10 @@ public class ProgressiveLoanSummaryDataProvider extends CommonLoanSummaryDataPro
return MathUtil.subtractToZero(currentRepaymentPeriod.get().getInterestOutstanding(loan.getCurrency()).getAmount(),
totalUnpaidPayableDueInterest);
} else {
List<LoanTransaction> transactionsToReprocess = loan.retrieveListOfTransactionsForReprocessing().stream()
.filter(t -> !t.isAccrualActivity()).toList();
Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> changedTransactionDetailProgressiveLoanInterestScheduleModelPair = advancedPaymentScheduleTransactionProcessor
.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(), businessDate, transactionsToReprocess,
loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
ProgressiveLoanInterestScheduleModel model = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getRight();
final List<Long> replayedTransactions = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft()
.getTransactionChanges().stream()
.filter(change -> change.getOldTransaction() != null && change.getNewTransaction() != null)
.map(change -> change.getNewTransaction().getId()).filter(Objects::nonNull).toList();
if (!replayedTransactions.isEmpty()) {
log.warn("Reprocessed transactions show differences: There are unsaved changes of the following transactions: {}",
replayedTransactions);
}
Optional<ProgressiveLoanInterestScheduleModel> savedModel = modelRepository.getSavedModel(loan, businessDate);
ProgressiveLoanInterestScheduleModel model = savedModel.orElseGet(() -> calculateModel(loan, businessDate));
if (model != null) {
OutstandingDetails outstandingDetails = emiCalculator.getOutstandingAmountsTillDate(model, businessDate);
if (!loan.isInterestRecalculationEnabled()) {
@@ -0,0 +1,154 @@
/**
* 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.service;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.interestpauses.service.LoanAccountTransfersService;
import org.apache.fineract.portfolio.loanaccount.data.TransactionChangeData;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ReprocessLoanTransactionsServiceImpl implements ReprocessLoanTransactionsService {
private final LoanAccountService loanAccountService;
private final LoanAccountTransfersService loanAccountTransfersService;
private final ReplayedTransactionBusinessEventService replayedTransactionBusinessEventService;
private final LoanTransactionProcessingService loanTransactionProcessingService;
private final InterestScheduleModelRepositoryWrapper interestScheduleModelRepositoryWrapper;
@Override
public void reprocessTransactions(final Loan loan) {
final List<LoanTransaction> allNonContraTransactionsPostDisbursement = loan.retrieveListOfTransactionsForReprocessing();
final ChangedTransactionDetail changedTransactionDetail = reprocessTransactionsAndFetchChangedTransactions(loan,
allNonContraTransactionsPostDisbursement);
handleChangedDetail(changedTransactionDetail);
}
@Override
public void reprocessParticularTransactions(final Loan loan, final List<LoanTransaction> loanTransactions) {
final ChangedTransactionDetail changedTransactionDetail = reprocessTransactionsAndFetchChangedTransactions(loan, loanTransactions);
handleChangedDetail(changedTransactionDetail);
}
@Override
public void reprocessTransactionsWithPostTransactionChecks(final Loan loan, final LocalDate transactionDate) {
final ChangedTransactionDetail changedTransactionDetail = reprocessTransactionsAndFetchChangedTransactions(loan,
loan.retrieveListOfTransactionsForReprocessing());
handleChangedDetail(changedTransactionDetail);
}
@Override
public void processPostDisbursementTransactions(final Loan loan) {
loanTransactionProcessingService.processPostDisbursementTransactions(loan).ifPresent(this::handleChangedDetail);
}
@Override
public void removeLoanCharge(final Loan loan, final LoanCharge loanCharge) {
final boolean removed = loanCharge.isActive();
if (removed) {
loanCharge.setActive(false);
final LoanRepaymentScheduleProcessingWrapper wrapper = new LoanRepaymentScheduleProcessingWrapper();
wrapper.reprocess(loan.getCurrency(), loan.getDisbursementDate(), loan.getRepaymentScheduleInstallments(),
loan.getActiveCharges());
loan.updateSummaryWithTotalFeeChargesDueAtDisbursement(loan.deriveSumTotalOfChargesDueAtDisbursement());
}
loan.removeOrModifyTransactionAssociatedWithLoanChargeIfDueAtDisbursement(loanCharge);
if (!loanCharge.isDueAtDisbursement() && loanCharge.isPaidOrPartiallyPaid(loan.getCurrency())) {
/*
* TODO Vishwas Currently we do not allow removing a loan charge after a loan is approved (hence there is no
* need to adjust any loan transactions).
*
* Consider removing this block of code or logically completing it for the future by getting the list of
* affected Transactions
*/
reprocessTransactions(loan);
return;
}
loan.getLoanCharges().remove(loanCharge);
loan.updateLoanSummaryDerivedFields();
}
@Override
public void processLatestTransaction(final LoanTransaction loanTransaction, final Loan loan) {
final ChangedTransactionDetail changedTransactionDetail = loanTransactionProcessingService.processLatestTransaction(
loan.getTransactionProcessingStrategyCode(), loanTransaction,
new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(),
new MoneyHolder(loan.getTotalOverpaidAsMoney()), new ChangedTransactionDetail()));
final List<LoanTransaction> newTransactions = changedTransactionDetail.getTransactionChanges().stream()
.map(TransactionChangeData::getNewTransaction).peek(transaction -> transaction.updateLoan(loan)).toList();
loan.getLoanTransactions().addAll(newTransactions);
loan.updateLoanSummaryDerivedFields();
handleChangedDetail(changedTransactionDetail);
}
@Override
public void updateModel(Loan loan) {
Optional<ProgressiveLoanInterestScheduleModel> savedModel = interestScheduleModelRepositoryWrapper.getSavedModel(loan,
ThreadLocalContextUtil.getBusinessDate());
if (savedModel.isEmpty()) {
reprocessTransactions(loan);
}
}
private void handleChangedDetail(final ChangedTransactionDetail changedTransactionDetail) {
for (TransactionChangeData change : changedTransactionDetail.getTransactionChanges()) {
final LoanTransaction newTransaction = change.getNewTransaction();
final LoanTransaction oldTransaction = change.getOldTransaction();
loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(newTransaction);
if (oldTransaction != null) {
loanAccountTransfersService.updateLoanTransaction(oldTransaction.getId(), newTransaction);
}
}
replayedTransactionBusinessEventService.raiseTransactionReplayedEvents(changedTransactionDetail);
}
private ChangedTransactionDetail reprocessTransactionsAndFetchChangedTransactions(final Loan loan,
final List<LoanTransaction> loanTransactions) {
final ChangedTransactionDetail changedTransactionDetail = loanTransactionProcessingService.reprocessLoanTransactions(
loan.getTransactionProcessingStrategyCode(), loan.getDisbursementDate(), loanTransactions, loan.getCurrency(),
loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
for (TransactionChangeData change : changedTransactionDetail.getTransactionChanges()) {
change.getNewTransaction().updateLoan(loan);
}
final List<LoanTransaction> newTransactions = changedTransactionDetail.getTransactionChanges().stream()
.map(TransactionChangeData::getNewTransaction).toList();
loan.getLoanTransactions().addAll(newTransactions);
loan.updateLoanSummaryDerivedFields();
return changedTransactionDetail;
}
}