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