FINERACT-2322: Extend loan collection data with next due payment amount field

This commit is contained in:
Soma Sörös
2025-08-06 13:52:43 +02:00
committed by Adam Saghy
parent 514a684281
commit 28043ce438
21 changed files with 807 additions and 92 deletions
@@ -0,0 +1,79 @@
/**
* 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.delinquency.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.ProgressiveLoanModel;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.ProgressiveTransactionCtx;
import org.apache.fineract.portfolio.loanaccount.service.InterestScheduleModelRepositoryWrapper;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.calc.data.RepaymentPeriod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProgressivePossibleNextRepaymentCalculationServiceImpl extends AbstractPossibleNextRepaymentCalculationService {
private final InterestScheduleModelRepositoryWrapper interestScheduleModelRepository;
private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor;
@Override
public BigDecimal calculateInterestRecalculationFutureOutstandingValue(Loan loan, LocalDate nextPaymentDueDate,
LoanRepaymentScheduleInstallment nextInstallment) {
MonetaryCurrency currency = loan.getCurrency();
Optional<ProgressiveLoanModel> progressiveLoanModel = interestScheduleModelRepository.findOneByLoan(loan);
Optional<ProgressiveLoanInterestScheduleModel> optionalScheduleModel = interestScheduleModelRepository
.extractModel(progressiveLoanModel);
ProgressiveLoanInterestScheduleModel scheduleModel = optionalScheduleModel.orElseGet(
() -> advancedPaymentScheduleTransactionProcessor.calculateInterestScheduleModel(loan.getId(), nextPaymentDueDate));
if (scheduleModel == null) {
return BigDecimal.ZERO;
}
List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments = loan.getRepaymentScheduleInstallments();
ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(loan.getCurrency(), repaymentScheduleInstallments, Set.of(),
new MoneyHolder(loan.getTotalOverpaidAsMoney()), new ChangedTransactionDetail(), scheduleModel);
ctx.setChargedOff(loan.isChargedOff());
ctx.setContractTerminated(loan.isContractTermination());
advancedPaymentScheduleTransactionProcessor.recalculateInterestForDate(nextPaymentDueDate, ctx, false);
RepaymentPeriod repaymentPeriod = scheduleModel.findRepaymentPeriodByDueDate(nextPaymentDueDate)
.orElseGet(scheduleModel::getLastRepaymentPeriod);
return repaymentPeriod.getOutstandingPrincipal().add(repaymentPeriod.getOutstandingInterest())
.add(nextInstallment.getFeeChargesOutstanding(currency)).add(nextInstallment.getPenaltyChargesOutstanding(currency))
.getAmount();
}
@Override
public boolean canAccept(Loan loan) {
return loan.isProgressiveSchedule();
}
}
@@ -1498,104 +1498,97 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
super.handleWriteOff(transaction, ctx.getCurrency(), ctx.getInstallments());
}
private List<LoanRepaymentScheduleInstallment> findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(LocalDate targetDate,
ProgressiveTransactionCtx transactionCtx) {
return transactionCtx.getInstallments().stream() //
.filter(installment -> !installment.isDownPayment() && !installment.isAdditional())
.filter(installment -> installment.isOverdueOn(targetDate))
.sorted(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).toList();
private List<RepaymentPeriod> findPossiblyOverdueRepaymentPeriods(LocalDate targetDate, ProgressiveLoanInterestScheduleModel model) {
return model.repaymentPeriods().stream() //
.filter(repaymentPeriod -> DateUtils.isAfter(targetDate, repaymentPeriod.getDueDate())).toList();
}
public boolean rework(LocalDate targetDate, ProgressiveTransactionCtx ctx) {
boolean hasChange = false;
ProgressiveLoanInterestScheduleModel model = ctx.getModel();
List<RepaymentPeriod> overdueInstallmentsSortedByInstallmentNumber = findPossiblyOverdueRepaymentPeriods(targetDate, model);
if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) {
RepaymentPeriod lastPeriod = model.getLastRepaymentPeriod();
RepaymentPeriod currentPeriod = model.findRepaymentPeriod(targetDate).orElse(lastPeriod);
MonetaryCurrency currency = model.zero().getCurrency();
Money overDuePrincipal = Money.zero(currency);
Money aggregatedOverDuePrincipal = Money.zero(currency);
for (RepaymentPeriod processingPeriod : overdueInstallmentsSortedByInstallmentNumber) {
// add and subtract outstanding principal
if (!overDuePrincipal.isZero()) {
boolean currentChanges = adjustOverduePrincipal(targetDate, processingPeriod, overDuePrincipal,
aggregatedOverDuePrincipal, ctx);
hasChange = hasChange || currentChanges;
}
overDuePrincipal = processingPeriod.getOutstandingPrincipal();
aggregatedOverDuePrincipal = aggregatedOverDuePrincipal.add(overDuePrincipal);
}
if (!currentPeriod.equals(lastPeriod) || !targetDate.isAfter(lastPeriod.getDueDate())) {
boolean currentChanges = adjustOverduePrincipal(targetDate, currentPeriod, overDuePrincipal, aggregatedOverDuePrincipal,
ctx);
hasChange = hasChange || currentChanges;
}
if (aggregatedOverDuePrincipal.isGreaterThanZero()
&& (model.lastOverdueBalanceChange() == null || model.lastOverdueBalanceChange().isBefore(targetDate))) {
model.lastOverdueBalanceChange(targetDate);
}
}
return hasChange;
}
public void recalculateInterestForDate(LocalDate targetDate, ProgressiveTransactionCtx ctx) {
recalculateInterestForDate(targetDate, ctx, true);
}
public void recalculateInterestForDate(LocalDate targetDate, ProgressiveTransactionCtx ctx, boolean updateInstallments) {
if (ctx.getInstallments() != null && !ctx.getInstallments().isEmpty()) {
Loan loan = ctx.getInstallments().getFirst().getLoan();
if (loan.isInterestBearingAndInterestRecalculationEnabled() && !loan.isNpa() && !ctx.isChargedOff()
&& !ctx.isContractTerminated()) {
&& !ctx.isContractTerminated() && !loan.getLoanInterestRecalculationDetails().disallowInterestCalculationOnPastDue()) {
List<LoanRepaymentScheduleInstallment> overdueInstallmentsSortedByInstallmentNumber = findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(
targetDate, ctx);
if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) {
List<LoanRepaymentScheduleInstallment> normalInstallments = ctx.getInstallments().stream() //
.filter(installment -> !installment.isAdditional() && !installment.isDownPayment()).toList();
Optional<LoanRepaymentScheduleInstallment> currentInstallmentOptional = normalInstallments.stream().filter(
installment -> installment.getFromDate().isBefore(targetDate) && !installment.getDueDate().isBefore(targetDate))
.findAny();
// get DUE installment or last installment
LoanRepaymentScheduleInstallment lastInstallment = normalInstallments.stream()
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).get();
LoanRepaymentScheduleInstallment currentInstallment = currentInstallmentOptional.orElse(lastInstallment);
Money overDuePrincipal = Money.zero(ctx.getCurrency());
Money aggregatedOverDuePrincipal = Money.zero(ctx.getCurrency());
for (LoanRepaymentScheduleInstallment processingInstallment : overdueInstallmentsSortedByInstallmentNumber) {
// add and subtract outstanding principal
if (!overDuePrincipal.isZero()) {
adjustOverduePrincipalForInstallment(targetDate, processingInstallment, overDuePrincipal,
aggregatedOverDuePrincipal, ctx);
}
overDuePrincipal = processingInstallment.getPrincipalOutstanding(ctx.getCurrency());
aggregatedOverDuePrincipal = aggregatedOverDuePrincipal.add(overDuePrincipal);
}
boolean adjustNeeded = !currentInstallment.equals(lastInstallment) || !lastInstallment.isOverdueOn(targetDate);
if (adjustNeeded) {
adjustOverduePrincipalForInstallment(targetDate, currentInstallment, overDuePrincipal, aggregatedOverDuePrincipal,
ctx);
}
if (aggregatedOverDuePrincipal.isGreaterThanZero() && (ctx.getModel().lastOverdueBalanceChange() == null
|| ctx.getModel().lastOverdueBalanceChange().isBefore(targetDate))) {
ctx.getModel().lastOverdueBalanceChange(targetDate);
}
boolean modelHasUpdates = rework(targetDate, ctx);
if (modelHasUpdates && updateInstallments) {
updateInstallmentsPrincipalAndInterestByModel(ctx);
}
}
}
}
private void adjustOverduePrincipalForInstallment(LocalDate currentDate, LoanRepaymentScheduleInstallment currentInstallment,
Money overduePrincipal, Money aggregatedOverDuePrincipal, ProgressiveTransactionCtx ctx) {
if (currentInstallment.getLoan().getLoanInterestRecalculationDetails().disallowInterestCalculationOnPastDue()) {
return;
}
private boolean adjustOverduePrincipal(LocalDate currentDate, RepaymentPeriod currentInstallment, Money overduePrincipal,
Money aggregatedOverDuePrincipal, ProgressiveTransactionCtx ctx) {
LocalDate fromDate = currentInstallment.getFromDate();
LocalDate toDate = currentInstallment.getDueDate();
boolean hasUpdate = false;
ProgressiveLoanInterestScheduleModel model = ctx.getModel();
boolean prepayAttempt = ctx.isPrepayAttempt();
LoanInterestRecalculationDetails loanInterestRecalculationDetails = ctx.getInstallments().getFirst().getLoan()
.getLoanInterestRecalculationDetails();
if (!currentDate.equals(ctx.getModel().lastOverdueBalanceChange())) {
// if we have same date for fromDate & last overdue balance change then it means we have the up-to-date
// model.
if (ctx.getModel().lastOverdueBalanceChange() == null
|| currentInstallment.getFromDate().isAfter(ctx.getModel().lastOverdueBalanceChange())) {
// first overdue hit for installment. setting overdue balance correction from instalment from date.
emiCalculator.addBalanceCorrection(ctx.getModel(), fromDate, overduePrincipal);
if (!currentDate.equals(model.lastOverdueBalanceChange())) {
if (model.lastOverdueBalanceChange() == null || currentInstallment.getFromDate().isAfter(model.lastOverdueBalanceChange())) {
emiCalculator.addBalanceCorrection(model, fromDate, overduePrincipal);
} else {
// not the first balance correction on installment period, then setting overdue balance correction from
// last balance change's current date. previous interest period already has the correct balance
// correction.
emiCalculator.addBalanceCorrection(ctx.getModel(), ctx.getModel().lastOverdueBalanceChange(), overduePrincipal);
emiCalculator.addBalanceCorrection(model, model.lastOverdueBalanceChange(), overduePrincipal);
}
hasUpdate = true;
if (currentDate.isAfter(fromDate) && !currentDate.isAfter(toDate)) {
LocalDate lastOverdueBalanceChange;
if (shouldRecalculateTillInstallmentDueDate(currentInstallment.getLoan().getLoanInterestRecalculationDetails(),
ctx.isPrepayAttempt())) {
if (shouldRecalculateTillInstallmentDueDate(loanInterestRecalculationDetails, prepayAttempt)) {
lastOverdueBalanceChange = toDate;
} else {
lastOverdueBalanceChange = currentDate;
}
emiCalculator.addBalanceCorrection(ctx.getModel(), lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated());
ctx.getModel().lastOverdueBalanceChange(lastOverdueBalanceChange);
emiCalculator.addBalanceCorrection(model, lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated());
model.lastOverdueBalanceChange(lastOverdueBalanceChange);
}
return true;
}
if (hasUpdate) {
updateInstallmentsPrincipalAndInterestByModel(ctx);
}
return false;
}
private void updateInstallmentsPrincipalAndInterestByModel(ProgressiveTransactionCtx ctx) {
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.loanaccount.repository;
import java.util.Optional;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.ProgressiveLoanModel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -27,4 +28,6 @@ public interface ProgressiveLoanModelRepository
extends JpaSpecificationExecutor<ProgressiveLoanModel>, JpaRepository<ProgressiveLoanModel, Long> {
Optional<ProgressiveLoanModel> findOneByLoanId(Long loanId);
Optional<ProgressiveLoanModel> findOneByLoan(Loan loan);
}
@@ -29,6 +29,8 @@ public interface InterestScheduleModelRepositoryWrapper {
Optional<ProgressiveLoanModel> findOneByLoanId(Long loanId);
Optional<ProgressiveLoanModel> findOneByLoan(Loan loan);
Optional<ProgressiveLoanInterestScheduleModel> extractModel(Optional<ProgressiveLoanModel> progressiveLoanModel);
ProgressiveLoanInterestScheduleModel writeInterestScheduleModel(Loan loan, ProgressiveLoanInterestScheduleModel model);
@@ -23,6 +23,7 @@ import jakarta.transaction.Transactional;
import java.time.LocalDate;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.persistence.FlushModeHandler;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -78,6 +79,15 @@ public class InterestScheduleModelRepositoryWrapperImpl implements InterestSched
return progressiveLoanModel[0];
}
@Override
public Optional<ProgressiveLoanModel> findOneByLoan(Loan loan) {
AtomicReference<Optional<ProgressiveLoanModel>> progressiveLoanModelRef = new AtomicReference<>();
flushModeHandler.withFlushMode(FlushModeType.COMMIT, () -> {
progressiveLoanModelRef.set(loanModelRepository.findOneByLoan(loan));
});
return progressiveLoanModelRef.get();
}
@Override
public Optional<ProgressiveLoanInterestScheduleModel> extractModel(Optional<ProgressiveLoanModel> progressiveLoanModel) {
return progressiveLoanModel.map(ProgressiveLoanModel::getJsonModel) //
@@ -381,6 +381,15 @@ public final class RepaymentPeriod {
return previous == null;
}
/**
* Gives back getDueInterest minus paid interest
*
* @return
*/
public Money getOutstandingInterest() {
return MathUtil.negativeToZero(getDueInterest().minus(getPaidInterest()), mc);
}
public Money getOutstandingPrincipal() {
return MathUtil.negativeToZero(getDuePrincipal().minus(getPaidPrincipal()), mc);
}