FINERACT-2232: add income capitalization principal handling and repayment schedule calculations on loans - E2E tests

This commit is contained in:
Peter Kovacs
2025-05-27 18:35:39 +02:00
committed by Adam Saghy
parent aac62a92bb
commit 8d7d8ca7ef
7 changed files with 1318 additions and 99 deletions
@@ -0,0 +1,41 @@
/**
* 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.infrastructure.core.persistence;
import jakarta.persistence.EntityManager;
import jakarta.persistence.FlushModeType;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Component;
@Component
public class FlushModeHandler {
@PersistenceContext
private EntityManager entityManager;
public void withFlushMode(FlushModeType flushMode, Runnable runnable) {
FlushModeType original = entityManager.getFlushMode();
try {
entityManager.setFlushMode(flushMode);
runnable.run();
} finally {
entityManager.setFlushMode(original);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,12 @@
*/ */
package org.apache.fineract.portfolio.loanaccount.service; package org.apache.fineract.portfolio.loanaccount.service;
import jakarta.persistence.FlushModeType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.persistence.FlushModeHandler;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
@@ -40,6 +42,7 @@ import org.springframework.stereotype.Service;
public class LoanBalanceService { public class LoanBalanceService {
private final CapitalizedIncomeBalanceService capitalizedIncomeBalanceService; private final CapitalizedIncomeBalanceService capitalizedIncomeBalanceService;
private final FlushModeHandler flushModeHandler;
public Money calculateTotalOverpayment(final Loan loan) { public Money calculateTotalOverpayment(final Loan loan) {
Money totalPaidInRepayments = loan.getTotalPaidInRepayments(); Money totalPaidInRepayments = loan.getTotalPaidInRepayments();
@@ -85,28 +88,30 @@ public class LoanBalanceService {
} }
public void updateLoanSummaryDerivedFields(final Loan loan) { public void updateLoanSummaryDerivedFields(final Loan loan) {
if (loan.isNotDisbursed()) { flushModeHandler.withFlushMode(FlushModeType.COMMIT, () -> {
if (loan.getSummary() != null) { if (loan.isNotDisbursed()) {
loan.getSummary().zeroFields(); if (loan.getSummary() != null) {
} loan.getSummary().zeroFields();
loan.setTotalOverpaid(null); }
} else { loan.setTotalOverpaid(null);
final Money overpaidBy = calculateTotalOverpayment(loan); } else {
loan.setTotalOverpaid(null); final Money overpaidBy = calculateTotalOverpayment(loan);
if (!overpaidBy.isLessThanZero()) { loan.setTotalOverpaid(null);
loan.setTotalOverpaid(overpaidBy.getAmountDefaultedToNullIfZero()); if (!overpaidBy.isLessThanZero()) {
} loan.setTotalOverpaid(overpaidBy.getAmountDefaultedToNullIfZero());
}
final Money recoveredAmount = loan.calculateTotalRecoveredPayments(); final Money recoveredAmount = loan.calculateTotalRecoveredPayments();
loan.setTotalRecovered(recoveredAmount.getAmountDefaultedToNullIfZero()); loan.setTotalRecovered(recoveredAmount.getAmountDefaultedToNullIfZero());
final Money principal = loan.getLoanRepaymentScheduleDetail().getPrincipal(); final Money principal = loan.getLoanRepaymentScheduleDetail().getPrincipal();
final Money capitalizedIncome = capitalizedIncomeBalanceService.calculateCapitalizedIncome(loan); final Money capitalizedIncome = capitalizedIncomeBalanceService.calculateCapitalizedIncome(loan);
final Money capitalizedIncomeAdjustment = capitalizedIncomeBalanceService.calculateCapitalizedIncomeAdjustment(loan); final Money capitalizedIncomeAdjustment = capitalizedIncomeBalanceService.calculateCapitalizedIncomeAdjustment(loan);
loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(), loan.getLoanCharges(), loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(),
capitalizedIncome, capitalizedIncomeAdjustment); loan.getLoanCharges(), capitalizedIncome, capitalizedIncomeAdjustment);
loan.updateLoanOutstandingBalances(); loan.updateLoanOutstandingBalances();
} }
});
} }
public void updateLoanToLastDisbursalState(final Loan loan, final LoanDisbursementDetails disbursementDetail) { public void updateLoanToLastDisbursalState(final Loan loan, final LoanDisbursementDetails disbursementDetail) {
@@ -81,8 +81,6 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
loan.addLoanTransaction(capitalizedIncomeTransaction); loan.addLoanTransaction(capitalizedIncomeTransaction);
// Recalculate loan transactions // Recalculate loan transactions
recalculateLoanTransactions(loan, transactionDate, capitalizedIncomeTransaction); recalculateLoanTransactions(loan, transactionDate, capitalizedIncomeTransaction);
// Create capitalized income balances
createCapitalizedIncomeBalance(capitalizedIncomeTransaction);
// Save and flush (PK is set) // Save and flush (PK is set)
loanTransactionRepository.saveAndFlush(capitalizedIncomeTransaction); loanTransactionRepository.saveAndFlush(capitalizedIncomeTransaction);
// Create capitalized income balances // Create capitalized income balances
@@ -132,6 +130,6 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
capitalizedIncomeBalance.setDate(capitalizedIncomeTransaction.getTransactionDate()); capitalizedIncomeBalance.setDate(capitalizedIncomeTransaction.getTransactionDate());
capitalizedIncomeBalance.setAmount(capitalizedIncomeTransaction.getAmount()); capitalizedIncomeBalance.setAmount(capitalizedIncomeTransaction.getAmount());
capitalizedIncomeBalance.setUnrecognizedAmount(capitalizedIncomeTransaction.getAmount()); capitalizedIncomeBalance.setUnrecognizedAmount(capitalizedIncomeTransaction.getAmount());
capitalizedIncomeBalanceRepository.save(capitalizedIncomeBalance); capitalizedIncomeBalanceRepository.saveAndFlush(capitalizedIncomeBalance);
} }
} }
@@ -18,14 +18,13 @@
*/ */
package org.apache.fineract.portfolio.loanaccount.service; package org.apache.fineract.portfolio.loanaccount.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.FlushModeType; import jakarta.persistence.FlushModeType;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.persistence.FlushModeHandler;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
@@ -47,8 +46,7 @@ public class InterestScheduleModelRepositoryWrapperImpl implements InterestSched
private final ProgressiveLoanModelRepository loanModelRepository; private final ProgressiveLoanModelRepository loanModelRepository;
private final ProgressiveLoanInterestScheduleModelParserService progressiveLoanInterestScheduleModelParserService; private final ProgressiveLoanInterestScheduleModelParserService progressiveLoanInterestScheduleModelParserService;
private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor; private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor;
@PersistenceContext private final FlushModeHandler flushModeHandler;
private final EntityManager entityManager;
@Transactional @Transactional
@Override @Override
@@ -57,9 +55,7 @@ public class InterestScheduleModelRepositoryWrapperImpl implements InterestSched
return null; return null;
} }
String jsonModel = progressiveLoanInterestScheduleModelParserService.toJson(model); String jsonModel = progressiveLoanInterestScheduleModelParserService.toJson(model);
try { flushModeHandler.withFlushMode(FlushModeType.COMMIT, () -> {
// Note: FlushMode set because otherwise it could cause a concurrent writing exception
entityManager.setFlushMode(FlushModeType.COMMIT);
ProgressiveLoanModel progressiveLoanModel = loanModelRepository.findOneByLoanId(loan.getId()).orElseGet(() -> { ProgressiveLoanModel progressiveLoanModel = loanModelRepository.findOneByLoanId(loan.getId()).orElseGet(() -> {
ProgressiveLoanModel plm = new ProgressiveLoanModel(); ProgressiveLoanModel plm = new ProgressiveLoanModel();
plm.setLoan(loan); plm.setLoan(loan);
@@ -69,15 +65,17 @@ public class InterestScheduleModelRepositoryWrapperImpl implements InterestSched
progressiveLoanModel.setLastModifiedDate(DateUtils.getAuditOffsetDateTime()); progressiveLoanModel.setLastModifiedDate(DateUtils.getAuditOffsetDateTime());
progressiveLoanModel.setJsonModel(jsonModel); progressiveLoanModel.setJsonModel(jsonModel);
loanModelRepository.save(progressiveLoanModel); loanModelRepository.save(progressiveLoanModel);
} finally { });
entityManager.setFlushMode(FlushModeType.AUTO);
}
return jsonModel; return jsonModel;
} }
@Override @Override
public Optional<ProgressiveLoanModel> findOneByLoanId(Long loanId) { public Optional<ProgressiveLoanModel> findOneByLoanId(Long loanId) {
return loanModelRepository.findOneByLoanId(loanId); final Optional[] progressiveLoanModel = new Optional[1];
flushModeHandler.withFlushMode(FlushModeType.COMMIT, () -> {
progressiveLoanModel[0] = loanModelRepository.findOneByLoanId(loanId);
});
return progressiveLoanModel[0];
} }
@Override @Override
@@ -90,40 +88,27 @@ public class InterestScheduleModelRepositoryWrapperImpl implements InterestSched
@Override @Override
public boolean hasValidModelForDate(Long loanId, LocalDate targetDate) { public boolean hasValidModelForDate(Long loanId, LocalDate targetDate) {
try { Optional<ProgressiveLoanModel> progressiveLoanModel = findOneByLoanId(loanId);
// Note: FlushMode set because an unwanted flush caused reverse replay new transactions LocalDate modelUpdatedTilDate = progressiveLoanModel.map(ProgressiveLoanModel::getBusinessDate).orElse(null);
entityManager.setFlushMode(FlushModeType.COMMIT); return progressiveLoanModel.isPresent() && !targetDate.isBefore(modelUpdatedTilDate);
Optional<ProgressiveLoanModel> progressiveLoanModel = findOneByLoanId(loanId);
LocalDate modelUpdatedTilDate = progressiveLoanModel.map(ProgressiveLoanModel::getBusinessDate).orElse(null);
return progressiveLoanModel.isPresent() && !targetDate.isBefore(modelUpdatedTilDate);
} finally {
entityManager.setFlushMode(FlushModeType.AUTO);
}
} }
@Override @Override
public Optional<ProgressiveLoanInterestScheduleModel> getSavedModel(Loan loan, LocalDate businessDate) { public Optional<ProgressiveLoanInterestScheduleModel> getSavedModel(Loan loan, LocalDate businessDate) {
try { Optional<ProgressiveLoanModel> progressiveLoanModel = findOneByLoanId(loan.getId());
// Note: FlushMode set because an unwanted flush caused reverse replay new transactions Optional<ProgressiveLoanInterestScheduleModel> savedModel;
entityManager.setFlushMode(FlushModeType.COMMIT); if (progressiveLoanModel.isPresent() && !progressiveLoanModel.get().getBusinessDate().isAfter(businessDate)) {
Optional<ProgressiveLoanModel> progressiveLoanModel = findOneByLoanId(loan.getId()); savedModel = extractModel(progressiveLoanModel);
Optional<ProgressiveLoanInterestScheduleModel> savedModel; if (savedModel.isPresent() && progressiveLoanModel.get().getBusinessDate().isBefore(businessDate)) {
if (progressiveLoanModel.isPresent() && !progressiveLoanModel.get().getBusinessDate().isAfter(businessDate)) { ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(),
savedModel = extractModel(progressiveLoanModel); Set.of(), new MoneyHolder(loan.getTotalOverpaidAsMoney()), new ChangedTransactionDetail(), savedModel.get());
if (savedModel.isPresent() && progressiveLoanModel.get().getBusinessDate().isBefore(businessDate)) { ctx.setChargedOff(loan.isChargedOff());
ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(loan.getCurrency(), advancedPaymentScheduleTransactionProcessor.recalculateInterestForDate(businessDate, ctx);
loan.getRepaymentScheduleInstallments(), Set.of(), new MoneyHolder(loan.getTotalOverpaidAsMoney()),
new ChangedTransactionDetail(), savedModel.get());
ctx.setChargedOff(loan.isChargedOff());
advancedPaymentScheduleTransactionProcessor.recalculateInterestForDate(businessDate, ctx);
}
} else {
savedModel = Optional.empty();
} }
return savedModel; } else {
} finally { savedModel = Optional.empty();
entityManager.setFlushMode(FlushModeType.AUTO);
} }
return savedModel;
} }
@Override @Override
@@ -255,8 +255,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
private void addCredit(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate transactionDate, Money creditedPrincipalAmount, private void addCredit(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate transactionDate, Money creditedPrincipalAmount,
Money creditedInterestAmount) { Money creditedInterestAmount) {
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(transactionDate, scheduleModel.zero(), creditedPrincipalAmount, scheduleModel.zero()) scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(transactionDate, scheduleModel.zero(), creditedPrincipalAmount,
.ifPresent(repaymentPeriod -> { scheduleModel.zero()).ifPresent(repaymentPeriod -> {
addCreditedAmountsToInterestPeriod(scheduleModel, transactionDate, creditedPrincipalAmount, creditedInterestAmount); addCreditedAmountsToInterestPeriod(scheduleModel, transactionDate, creditedPrincipalAmount, creditedInterestAmount);
calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel); calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel);
calculateOutstandingBalance(scheduleModel); calculateOutstandingBalance(scheduleModel);
@@ -76,14 +76,15 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
} }
public static InterestPeriod empty(@NotNull RepaymentPeriod repaymentPeriod, MathContext mc) { public static InterestPeriod empty(@NotNull RepaymentPeriod repaymentPeriod, MathContext mc) {
return new InterestPeriod(repaymentPeriod, null, null, null, null, null, null, null, null, null,null, mc, false); return new InterestPeriod(repaymentPeriod, null, null, null, null, null, null, null, null, null, null, mc, false);
} }
public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod) { public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod) {
return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(), return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(),
interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getCreditedPrincipal(), interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getCreditedPrincipal(),
interestPeriod.getCreditedInterest(), interestPeriod.getDisbursementAmount(), interestPeriod.getBalanceCorrectionAmount(), interestPeriod.getCreditedInterest(), interestPeriod.getDisbursementAmount(), interestPeriod.getBalanceCorrectionAmount(),
interestPeriod.getOutstandingLoanBalance(), interestPeriod.getCapitalizedIncomePrincipal(), interestPeriod.getMc(), interestPeriod.isPaused()); interestPeriod.getOutstandingLoanBalance(), interestPeriod.getCapitalizedIncomePrincipal(), interestPeriod.getMc(),
interestPeriod.isPaused());
} }
public static InterestPeriod withEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate, public static InterestPeriod withEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate,