FINERACT-2326: Add the ability to reschedule non-interest-bearing progressive loans to have interest

This commit is contained in:
mark.vituska
2025-08-04 14:03:00 +02:00
committed by Adam Saghy
parent 6a67e58a3a
commit 3428e33035
14 changed files with 171 additions and 271 deletions
@@ -507,7 +507,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
MoneyHolder overpaymentHolder = ctx.getOverpaymentHolder();
if (ctx instanceof ProgressiveTransactionCtx progressiveTransactionCtx
&& loanTransaction.getLoan().isInterestBearingAndInterestRecalculationEnabled()) {
&& loanTransaction.getLoan().isInterestRecalculationEnabled()) {
var model = progressiveTransactionCtx.getModel();
// Copy and paste Logic from super.handleCreditBalanceRefund
@@ -778,7 +778,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
protected void processCreditTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) {
if (loanTransaction.getLoan().isInterestBearingAndInterestRecalculationEnabled()) {
if (loanTransaction.getLoan().isInterestRecalculationEnabled()) {
processCreditTransactionWithEmiCalculator(loanTransaction, (ProgressiveTransactionCtx) ctx);
} else if (hasNoCustomCreditAllocationRule(loanTransaction)) {
super.processCreditTransaction(loanTransaction, ctx.getOverpaymentHolder(), ctx.getCurrency(), ctx.getInstallments());
@@ -1224,8 +1224,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private void handleDisbursement(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
// TODO: Fix this and enhance EMICalculator to support reamortization and reaging
final Loan loan = disbursementTransaction.getLoan();
if (loan.isInterestBearing()) {
if (shouldUseEmiCalculation(transactionCtx, disbursementTransaction.getTransactionDate())) {
handleDisbursementWithEMICalculator(disbursementTransaction, transactionCtx);
} else {
handleDisbursementWithoutEMICalculator(disbursementTransaction, transactionCtx);
@@ -1356,7 +1355,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
private void handleCapitalizedIncome(LoanTransaction capitalizedIncomeTransaction, TransactionCtx transactionCtx) {
if (capitalizedIncomeTransaction.getLoan().isInterestBearing()) {
// TODO: Fix this and enhance EMICalculator to support reamortization and reaging
if (shouldUseEmiCalculation(transactionCtx, capitalizedIncomeTransaction.getTransactionDate())) {
handleCapitalizedIncomeWithEMICalculator(capitalizedIncomeTransaction, transactionCtx);
} else {
handleCapitalizedIncomeWithoutEMICalculator(capitalizedIncomeTransaction, transactionCtx);
@@ -1476,6 +1476,24 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
}
private boolean shouldUseEmiCalculation(TransactionCtx transactionCtx, LocalDate transactionDate) {
if (transactionCtx instanceof ProgressiveTransactionCtx progressiveTransactionCtx) {
boolean hasActiveReAmortization = progressiveTransactionCtx.getAlreadyProcessedTransactions().stream()
.anyMatch(t -> t.getTypeOf().isReAmortize() && t.isNotReversed());
boolean hasActiveReAge = progressiveTransactionCtx.getAlreadyProcessedTransactions().stream()
.anyMatch(t -> t.getTypeOf().isReAge() && t.isNotReversed());
if (hasActiveReAmortization) {
return false;
} else {
return !hasActiveReAge || !DateUtils.isAfter(transactionDate, progressiveTransactionCtx.getModel().getMaturityDate());
}
}
// From now on we are defaulting to using the EMICalculator on all progressive loans. However currently the
// model is not aware of re-aging and re-amortization. So only these specific cases should ignore this
// requirement. This method can be removed once these operations are supported by the EMI model.
return true;
}
protected void handleWriteOff(final LoanTransaction transaction, TransactionCtx ctx) {
super.handleWriteOff(transaction, ctx.getCurrency(), ctx.getInstallments());
}
@@ -24,7 +24,6 @@ import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.Loan
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateAndRetrieveRescheduleFromDate;
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateApprovalDate;
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateForOverdueCharges;
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateInterestRate;
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateLoanIsActive;
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateRescheduleReasonComment;
import static org.apache.fineract.portfolio.loanaccount.rescheduleloan.data.LoanRescheduleRequestDataValidatorImpl.validateRescheduleReasonId;
@@ -78,8 +77,7 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
validateRescheduleReasonId(fromJsonHelper, jsonElement, dataValidatorBuilder);
validateRescheduleReasonComment(fromJsonHelper, jsonElement, dataValidatorBuilder);
LocalDate adjustedDueDate = validateAndRetrieveAdjustedDate(fromJsonHelper, jsonElement, rescheduleFromDate, dataValidatorBuilder);
BigDecimal interestRate = validateInterestRate(loan.getLoanRepaymentScheduleDetail().getAnnualNominalInterestRate(), fromJsonHelper,
jsonElement, dataValidatorBuilder);
BigDecimal interestRate = validateInterestRate(fromJsonHelper, jsonElement, dataValidatorBuilder);
validateUnsupportedParams(jsonElement, dataValidatorBuilder);
boolean hasInterestRateChange = interestRate != null;
@@ -197,4 +195,13 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
"Interest rate change for the provided date is already exists.", rescheduleFromDate);
}
}
private BigDecimal validateInterestRate(final FromJsonHelper fromJsonHelper, final JsonElement jsonElement,
DataValidatorBuilder dataValidatorBuilder) {
final BigDecimal interestRate = fromJsonHelper
.extractBigDecimalWithLocaleNamed(RescheduleLoansApiConstants.newInterestRateParamName, jsonElement);
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.newInterestRateParamName).value(interestRate).ignoreIfNull()
.zeroOrPositiveAmount();
return interestRate;
}
}
@@ -182,7 +182,7 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
}
private void recalculateLoanTransactions(Loan loan, LocalDate transactionDate, LoanTransaction transaction) {
if (loan.isInterestBearingAndInterestRecalculationEnabled() || DateUtils.isBeforeBusinessDate(transactionDate)) {
if (loan.isInterestRecalculationEnabled() || DateUtils.isBeforeBusinessDate(transactionDate)) {
reprocessLoanTransactionsService.reprocessTransactions(loan);
} else {
reprocessLoanTransactionsService.processLatestTransaction(transaction, loan);
@@ -161,6 +161,10 @@ public final class ProgressiveEMICalculator implements EMICalculator {
}
private void addCapitalizedIncome(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate()))
.forEach(rp -> rp.setTotalCapitalizedIncomeAmount(
MathUtil.nullToZero(rp.getTotalCapitalizedIncomeAmount()).add(operation.getAmount().getAmount())));
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), scheduleModel.zero(),
scheduleModel.zero(), operation.getAmount()).ifPresent((repaymentPeriod) -> {
calculateEMIValueAndRateFactors(
@@ -920,9 +924,11 @@ public final class ProgressiveEMICalculator implements EMICalculator {
// already repaid principals should be subtracted from total disbursed amount to calculate correct EMI.
BigDecimal alreadyRepaidPrincipals = first.getPrevious()
.map(rp -> rp.calculateTotalDisbursedAmountTillGivenPeriod(null).subtract(rp.getOutstandingLoanBalance().getAmount()))
.map(rp -> rp.calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(null)
.subtract(rp.getOutstandingLoanBalance().getAmount()))
.orElse(BigDecimal.ZERO);
Money total = Money.of(currency, first.calculateTotalDisbursedAmountTillGivenPeriod(first.getLastInterestPeriod()))
Money total = Money
.of(currency, first.calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(first.getLastInterestPeriod()))
.plus(sumOfInterest).minus(alreadyRepaidPrincipals);
Money periodEmi = total.dividedBy(repaymentPeriods.size(), mc);
@@ -150,7 +150,7 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
return BigDecimal.ZERO;
}
BigDecimal baseAmount = switch (method) {
case FLAT -> MathUtil.nullToZero(getRepaymentPeriod().calculateTotalDisbursedAmountTillGivenPeriod(this));
case FLAT -> MathUtil.nullToZero(getRepaymentPeriod().calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(this));
case DECLINING_BALANCE -> getOutstandingLoanBalance().getAmount();
default -> throw new UnsupportedOperationException("Method not implemented: " + method);
};
@@ -84,6 +84,10 @@ public final class RepaymentPeriod {
@Getter
private BigDecimal totalDisbursedAmount;
@Setter
@Getter
private BigDecimal totalCapitalizedIncomeAmount;
@JsonExclude
@Getter
private final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail;
@@ -389,16 +393,21 @@ public final class RepaymentPeriod {
/**
* @param tillPeriod
* can be null. if null it calculates total disbursement including last interest period.
* @return disbursed amount til interest period.
* @return disbursed and capitalized income amount till interest period.
*/
public BigDecimal calculateTotalDisbursedAmountTillGivenPeriod(InterestPeriod tillPeriod) {
BigDecimal res = MathUtil.nullToZero(getTotalDisbursedAmount());
public BigDecimal calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(InterestPeriod tillPeriod) {
BigDecimal res = MathUtil.nullToZero(getTotalDisbursedAmount()).add(MathUtil.nullToZero(getTotalCapitalizedIncomeAmount()));
for (InterestPeriod interestPeriod : this.interestPeriods) {
if (interestPeriod.equals(tillPeriod)) {
break;
}
if (!interestPeriod.getDueDate().equals(getFromDate()) && interestPeriod.getDisbursementAmount() != null) {
res = res.add(interestPeriod.getDisbursementAmount().getAmount());
if (!interestPeriod.getDueDate().equals(getFromDate())) {
if (interestPeriod.getDisbursementAmount() != null) {
res = res.add(interestPeriod.getDisbursementAmount().getAmount());
}
if (interestPeriod.getCapitalizedIncomePrincipal() != null) {
res = res.add(interestPeriod.getCapitalizedIncomePrincipal().getAmount());
}
}
}
return res;
@@ -535,7 +535,6 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
Loan loan = mock(Loan.class);
when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductRelatedDetail);
when(loan.isInterestBearing()).thenReturn(true);
LoanRepaymentScheduleInstallment installment1 = spy(
new LoanRepaymentScheduleInstallment(loan, 1, disbursementDate, disbursementDate.plusMonths(1), BigDecimal.valueOf(200.0),