FINERACT-1981: Progressive Loan schedule handling - Installment charges

This commit is contained in:
mariiaKraievska
2025-07-08 23:22:50 +02:00
committed by Adam Saghy
parent f76a65ed99
commit d165a3feda
22 changed files with 2484 additions and 89 deletions
@@ -76,6 +76,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeOffBehaviour;
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInstallmentCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalculationDetails;
import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
@@ -97,6 +98,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanSchedul
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.InterestRefundService;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeService;
import org.apache.fineract.portfolio.loanaccount.service.schedule.LoanScheduleComponent;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.apache.fineract.portfolio.loanproduct.calc.data.OutstandingDetails;
@@ -125,17 +127,20 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private final InterestRefundService interestRefundService;
private final LoanScheduleComponent loanSchedule;
private final LoanTransactionRepository loanTransactionRepository;
private final LoanChargeService loanChargeService;
public AdvancedPaymentScheduleTransactionProcessor(final EMICalculator emiCalculator, final LoanRepositoryWrapper loanRepositoryWrapper,
final InterestRefundService interestRefundService, final ExternalIdFactory externalIdFactory,
final LoanScheduleComponent loanSchedule, final LoanTransactionRepository loanTransactionRepository,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService,
final LoanChargeService loanChargeService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
this.emiCalculator = emiCalculator;
this.loanRepositoryWrapper = loanRepositoryWrapper;
this.interestRefundService = interestRefundService;
this.loanTransactionRepository = loanTransactionRepository;
this.loanSchedule = loanSchedule;
this.loanChargeService = loanChargeService;
}
@Override
@@ -372,6 +377,10 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
createMissingAccrualTransactionDuringChargeOffIfNeeded(newInterest, loanTransaction, loanTransaction.getTransactionDate(),
transactionCtx);
if (!loanTransaction.getLoan().isInterestBearingAndInterestRecalculationEnabled()) {
recalculateInstallmentFeeCharges(loanTransaction);
}
loanTransaction.resetDerivedComponents();
// determine how much is outstanding total and breakdown for principal, interest and charges
for (final LoanRepaymentScheduleInstallment currentInstallment : transactionCtx.getInstallments()) {
@@ -1215,11 +1224,37 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private void handleDisbursement(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
// TODO: Fix this and enhance EMICalculator to support reamortization and reaging
if (disbursementTransaction.getLoan().isInterestBearing()) {
final Loan loan = disbursementTransaction.getLoan();
if (loan.isInterestBearing()) {
handleDisbursementWithEMICalculator(disbursementTransaction, transactionCtx);
} else {
handleDisbursementWithoutEMICalculator(disbursementTransaction, transactionCtx);
}
if (!disbursementTransaction.getLoan().isInterestBearingAndInterestRecalculationEnabled()) {
recalculateInstallmentFeeCharges(disbursementTransaction);
}
}
private void recalculateInstallmentFeeCharges(final LoanTransaction loanTransaction) {
final Loan loan = loanTransaction.getLoan();
final List<LoanCharge> loanInstallmentFeeCharges = loan.getActiveCharges().stream()
.filter(c -> c.isInstalmentFee() && c.getSubmittedOnDate().isBefore(loanTransaction.getTransactionDate())).toList();
loanChargeService.recalculateParticularChargesAfterTransactionOccurs(loan, loanInstallmentFeeCharges,
loanTransaction.getTransactionDate());
if (!loanInstallmentFeeCharges.isEmpty()) {
final List<LoanRepaymentScheduleInstallment> installmentsToUpdate = loan.getRepaymentScheduleInstallments().stream()
.filter(i -> i.isNotFullyPaidOff() && !i.isAdditional()).toList();
for (LoanRepaymentScheduleInstallment installment : installmentsToUpdate) {
if (installment.isDownPayment()) {
continue;
}
final BigDecimal newFee = installment.getInstallmentCharges().stream().map(LoanInstallmentCharge::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (newFee.compareTo(BigDecimal.ZERO) != 0) {
installment.setFeeChargesCharged(newFee);
}
}
}
}
private void handleDisbursementWithEMICalculator(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
@@ -1670,6 +1705,10 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
createMissingAccrualTransactionDuringChargeOffIfNeeded(newInterest, loanTransaction, loanTransaction.getTransactionDate(),
transactionCtx);
if (!loanTransaction.getLoan().isInterestBearingAndInterestRecalculationEnabled()) {
recalculateInstallmentFeeCharges(loanTransaction);
}
loanTransaction.resetDerivedComponents();
// determine how much is outstanding total and breakdown for principal, interest and charges
for (final LoanRepaymentScheduleInstallment currentInstallment : transactionCtx.getInstallments()) {
@@ -1765,8 +1804,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
if (futureFee.compareTo(BigDecimal.ZERO) > 0 || futurePenalty.compareTo(BigDecimal.ZERO) > 0) {
final Optional<LocalDate> latestDueDate = loan.getCharges().stream()
.filter(loanCharge -> loanCharge.isActive() && loanCharge.isNotFullyPaid()).map(LoanCharge::getDueDate)
.max(LocalDate::compareTo);
.filter(loanCharge -> loanCharge.isActive() && loanCharge.isNotFullyPaid() && loanCharge.getDueDate() != null)
.map(LoanCharge::getDueDate).max(LocalDate::compareTo);
if (latestDueDate.isPresent()) {
final LoanRepaymentScheduleInstallment lastInstallment = installmentsUpToTransactionDate.getLast();
@@ -2526,11 +2565,12 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
@NotNull
private Set<LoanCharge> getLoanChargesOfInstallment(Set<LoanCharge> charges, LoanRepaymentScheduleInstallment installment,
int firstInstallmentNumber) {
boolean isFirstInstallment = installment.getInstallmentNumber().equals(firstInstallmentNumber);
private Set<LoanCharge> getLoanChargesOfInstallment(final Set<LoanCharge> charges, final LoanRepaymentScheduleInstallment installment,
final int firstInstallmentNumber) {
final boolean isFirstInstallment = installment.getInstallmentNumber().equals(firstInstallmentNumber);
return charges.stream()
.filter(loanCharge -> loanCharge.isDueInPeriod(installment.getFromDate(), installment.getDueDate(), isFirstInstallment))
.filter(loanCharge -> (loanCharge.isInstalmentFee() && loanCharge.hasInstallmentFor(installment))
|| loanCharge.isDueInPeriod(installment.getFromDate(), installment.getDueDate(), isFirstInstallment))
.collect(Collectors.toSet());
}
@@ -66,12 +66,17 @@ public class ChangeOperation implements Comparable<ChangeOperation> {
return loanCharge.isPresent();
}
public boolean isInstallmentFee() {
return loanCharge.map(LoanCharge::isInstalmentFee).orElse(false);
}
private boolean isAccrualActivity() {
return isTransaction() && loanTransaction.get().isAccrualActivity();
}
private boolean isBackdatedCharge() {
return isCharge() && DateUtils.isBefore(loanCharge.get().getDueDate(), loanCharge.get().getSubmittedOnDate());
return isCharge() && loanCharge.isPresent() && loanCharge.get().getDueDate() != null
&& DateUtils.isBefore(loanCharge.get().getDueDate(), loanCharge.get().getSubmittedOnDate());
}
private LocalDate getEffectiveDate() {
@@ -80,6 +85,8 @@ public class ChangeOperation implements Comparable<ChangeOperation> {
} else if (loanCharge.isPresent()) {
if (isBackdatedCharge()) {
return loanCharge.get().getDueDate();
} else if (isInstallmentFee()) {
return loanCharge.get().getLoan().isDisbursed() ? loanCharge.get().getSubmittedOnDate() : loanCharge.get().getDueDate();
} else {
return loanCharge.get().getSubmittedOnDate();
}
@@ -119,6 +126,17 @@ public class ChangeOperation implements Comparable<ChangeOperation> {
public int compareTo(@NonNull ChangeOperation o) {
int datePortion = DateUtils.compareWithNullsLast(this.getEffectiveDate(), o.getEffectiveDate());
if (datePortion == 0) {
boolean thisIsDisb = this.isTransaction() && this.getLoanTransaction().isPresent()
&& this.getLoanTransaction().get().isDisbursement();
boolean otherIsDisb = o.isTransaction() && o.getLoanTransaction().isPresent() && o.getLoanTransaction().get().isDisbursement();
if (thisIsDisb && o.isCharge() && o.isInstallmentFee()) {
return -1;
}
if (this.isCharge() && this.isInstallmentFee() && otherIsDisb) {
return 1;
}
final boolean isAccrual = isAccrualActivity();
if (isAccrual != o.isAccrualActivity()) {
return isAccrual ? 1 : -1;
@@ -75,6 +75,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanSchedul
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.InterestRefundService;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeService;
import org.apache.fineract.portfolio.loanaccount.service.schedule.LoanScheduleComponent;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
@@ -121,7 +122,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
underTest = new AdvancedPaymentScheduleTransactionProcessor(emiCalculator, Mockito.mock(LoanRepositoryWrapper.class),
Mockito.mock(InterestRefundService.class), Mockito.mock(ExternalIdFactory.class), Mockito.mock(LoanScheduleComponent.class),
Mockito.mock(LoanTransactionRepository.class), Mockito.mock(LoanChargeValidator.class),
Mockito.mock(LoanBalanceService.class));
Mockito.mock(LoanBalanceService.class), Mockito.mock(LoanChargeService.class));
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);