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
@@ -1517,7 +1517,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return repaymetsForAdjust;
}
public int fetchNumberOfInstallmensAfterExceptions() {
public int fetchNumberOfInstallmentsAfterExceptions() {
if (!this.repaymentScheduleInstallments.isEmpty()) {
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
int numberOfInstallments = 0;
@@ -721,4 +721,8 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
.chargePaymentMode(chargePaymentModeData).paid(paid).waived(waived).loanId(loan.getId()).minCap(minCap).maxCap(maxCap)
.installmentChargeData(loanInstallmentChargeDataList).externalId(externalId).build();
}
public boolean hasInstallmentFor(final LoanRepaymentScheduleInstallment installment) {
return this.getInstallmentLoanCharge(installment.getInstallmentNumber()) != null;
}
}
@@ -51,6 +51,7 @@ import org.apache.fineract.portfolio.charge.exception.LoanChargeNotFoundExceptio
import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeRepository;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.exception.LinkedAccountRequiredException;
import org.springframework.stereotype.Component;
@@ -280,6 +281,8 @@ public final class LoanChargeApiJsonValidator {
case PERCENT_OF_INTEREST:
if (loanCharge.isSpecifiedDueDate()) {
errorcode = "specific." + LoanApiConstants.LOAN_CHARGE_CAN_NOT_BE_ADDED_WITH_INTEREST_CALCULATION_TYPE;
} else if (loanCharge.isInstalmentFee() && loanCharge.getLoan().isProgressiveSchedule()) {
errorcode = "installment." + LoanApiConstants.LOAN_CHARGE_CAN_NOT_BE_ADDED_WITH_INTEREST_CALCULATION_TYPE;
}
break;
@@ -438,6 +441,9 @@ public final class LoanChargeApiJsonValidator {
case PERCENT_OF_INTEREST:
if (chargeTime.isSpecifiedDueDate()) {
errorcode = "specific." + LoanApiConstants.LOAN_CHARGE_CAN_NOT_BE_ADDED_WITH_INTEREST_CALCULATION_TYPE;
} else if (chargeTime.isInstalmentFee()
&& loanProduct.getLoanProductRelatedDetail().getLoanScheduleType().equals(LoanScheduleType.PROGRESSIVE)) {
errorcode = "installment." + LoanApiConstants.LOAN_CHARGE_CAN_NOT_BE_ADDED_WITH_INTEREST_CALCULATION_TYPE;
}
break;
@@ -22,7 +22,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -75,6 +75,14 @@ public class LoanChargeService {
loan.updateSummaryWithTotalFeeChargesDueAtDisbursement(loan.deriveSumTotalOfChargesDueAtDisbursement());
}
public void recalculateParticularChargesAfterTransactionOccurs(final Loan loan, final List<LoanCharge> loanCharges,
final LocalDate transactionDate) {
for (final LoanCharge loanCharge : loanCharges) {
recalculateLoanCharge(loan, loanCharge, 0, transactionDate);
}
loan.updateSummaryWithTotalFeeChargesDueAtDisbursement(loan.deriveSumTotalOfChargesDueAtDisbursement());
}
public void recalculateLoanCharge(final Loan loan, final LoanCharge loanCharge, final int penaltyWaitPeriod) {
BigDecimal amount = BigDecimal.ZERO;
BigDecimal chargeAmt;
@@ -94,12 +102,38 @@ public class LoanChargeService {
}
if (loanCharge.isActive()) {
clearLoanInstallmentChargesBeforeRegeneration(loanCharge);
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmentsAfterExceptions(),
totalChargeAmt);
loanChargeValidator.validateChargeHasValidSpecifiedDateIfApplicable(loan, loanCharge, loan.getDisbursementDate());
}
}
public void recalculateLoanCharge(final Loan loan, final LoanCharge loanCharge, final int penaltyWaitPeriod,
final LocalDate transactionDate) {
BigDecimal amount = BigDecimal.ZERO;
BigDecimal chargeAmt;
BigDecimal totalChargeAmt = BigDecimal.ZERO;
if (loanCharge.getChargeCalculation().isPercentageBased()) {
if (loanCharge.isOverdueInstallmentCharge()) {
amount = calculateOverdueAmountPercentageAppliedTo(loan, loanCharge, penaltyWaitPeriod);
} else {
amount = calculateAmountPercentageAppliedTo(loan, loanCharge);
}
chargeAmt = loanCharge.getPercentage();
if (loanCharge.isInstalmentFee()) {
totalChargeAmt = calculatePerInstallmentChargeAmount(loan, loanCharge);
}
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
if (loanCharge.isActive()) {
clearLoanInstallmentChargesBeforeRegeneration(loanCharge);
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmentsAfterExceptions(),
totalChargeAmt, transactionDate);
loanChargeValidator.validateChargeHasValidSpecifiedDateIfApplicable(loan, loanCharge, loan.getDisbursementDate());
}
}
public void makeChargePayment(final Loan loan, final Long chargeId, final List<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds, final LoanTransaction paymentTransaction, final Integer installmentNumber) {
loanChargeValidator.validateChargePaymentNotInFuture(paymentTransaction);
@@ -191,7 +225,8 @@ public class LoanChargeService {
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(), totalChargeAmt);
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmentsAfterExceptions(),
totalChargeAmt);
// NOTE: must add new loan charge to set of loan charges before
// reprocessing the repayment schedule.
@@ -275,7 +310,7 @@ public class LoanChargeService {
chargeAmt = loanCharge.amountOrPercentage();
}
if (charge != null) {
update(charge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
update(charge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmentsAfterExceptions(),
totalChargeAmt);
}
}
@@ -325,7 +360,7 @@ public class LoanChargeService {
case FLAT:
if (loanCharge.isInstalmentFee()) {
loanCharge.setAmount(
newValue.multiply(BigDecimal.valueOf(loanCharge.getLoan().fetchNumberOfInstallmensAfterExceptions())));
newValue.multiply(BigDecimal.valueOf(loanCharge.getLoan().fetchNumberOfInstallmentsAfterExceptions())));
} else {
loanCharge.setAmount(newValue);
}
@@ -375,7 +410,7 @@ public class LoanChargeService {
loanCharge.setAmountPaid(null);
if (loanCharge.isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmensAfterExceptions();
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmentsAfterExceptions();
}
loanCharge.setAmount(chargeAmount.multiply(BigDecimal.valueOf(numberOfRepayments)));
} else {
@@ -574,40 +609,47 @@ public class LoanChargeService {
return calculatePerInstallmentChargeAmount(loan, loanCharge.getChargeCalculation(), loanCharge.getPercentage());
}
private void updateInstallmentCharges(final LoanCharge loanCharge) {
final Collection<LoanInstallmentCharge> remove = new HashSet<>();
public void updateInstallmentCharges(final LoanCharge loanCharge) {
final List<LoanInstallmentCharge> newChargeInstallments = generateInstallmentLoanCharges(loanCharge.getLoan(), loanCharge);
if (loanCharge.getLoanInstallmentCharge().isEmpty()) {
loanCharge.getLoanInstallmentCharge().addAll(newChargeInstallments);
} else {
int index = 0;
final List<LoanInstallmentCharge> oldChargeInstallments = new ArrayList<>();
if (loanCharge.getLoanInstallmentCharge() != null && !loanCharge.getLoanInstallmentCharge().isEmpty()) {
oldChargeInstallments.addAll(loanCharge.getLoanInstallmentCharge());
}
Collections.sort(oldChargeInstallments);
final LoanInstallmentCharge[] loanChargePerInstallmentArray = newChargeInstallments
.toArray(new LoanInstallmentCharge[newChargeInstallments.size()]);
for (final LoanInstallmentCharge chargePerInstallment : oldChargeInstallments) {
if (index == loanChargePerInstallmentArray.length) {
remove.add(chargePerInstallment);
chargePerInstallment.getInstallment().getInstallmentCharges().remove(chargePerInstallment);
} else {
LoanInstallmentCharge newLoanInstallmentCharge = loanChargePerInstallmentArray[index++];
newLoanInstallmentCharge.getInstallment().getInstallmentCharges().remove(newLoanInstallmentCharge);
chargePerInstallment.copyFrom(newLoanInstallmentCharge);
final Map<Integer, LoanInstallmentCharge> newChargeMap = new HashMap<>();
for (final LoanInstallmentCharge newCharge : newChargeInstallments) {
if (newCharge.getInstallment() != null && newCharge.getInstallment().getInstallmentNumber() != null) {
newChargeMap.put(newCharge.getInstallment().getInstallmentNumber(), newCharge);
}
}
loanCharge.getLoanInstallmentCharge().removeAll(remove);
while (index < loanChargePerInstallmentArray.length) {
loanCharge.getLoanInstallmentCharge().add(loanChargePerInstallmentArray[index++]);
final Collection<LoanInstallmentCharge> chargesToRemoveFromLoanCharge = new HashSet<>();
final Collection<LoanInstallmentCharge> chargesToAddIntoLoanCharge = new HashSet<>();
for (final LoanInstallmentCharge oldCharge : loanCharge.getLoanInstallmentCharge()) {
final Integer oldInstallmentNumber = oldCharge.getInstallment().getInstallmentNumber();
if (newChargeMap.containsKey(oldInstallmentNumber)) {
chargesToRemoveFromLoanCharge.add(oldCharge);
oldCharge.getInstallment().getInstallmentCharges().remove(oldCharge);
chargesToAddIntoLoanCharge.add(newChargeMap.get(oldInstallmentNumber));
newChargeMap.remove(oldInstallmentNumber);
} else {
chargesToRemoveFromLoanCharge.add(oldCharge);
oldCharge.getInstallment().getInstallmentCharges().remove(oldCharge);
}
}
chargesToAddIntoLoanCharge.addAll(newChargeMap.values());
loanCharge.getLoanInstallmentCharge().removeAll(chargesToRemoveFromLoanCharge);
loanCharge.getLoanInstallmentCharge().addAll(chargesToAddIntoLoanCharge);
}
Money amount = Money.zero(loanCharge.getLoan().getCurrency());
Money totalAmount = Money.zero(loanCharge.getLoan().getCurrency());
for (LoanInstallmentCharge charge : loanCharge.getLoanInstallmentCharge()) {
amount = amount.plus(charge.getAmount());
totalAmount = totalAmount.plus(charge.getAmount());
}
loanCharge.setAmount(amount.getAmount());
loanCharge.setAmount(totalAmount.getAmount());
}
private List<LoanInstallmentCharge> generateInstallmentLoanCharges(final Loan loan, final LoanCharge loanCharge) {
@@ -615,9 +657,6 @@ public class LoanChargeService {
if (loanCharge.isInstalmentFee()) {
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isRecalculatedInterestComponent()) {
continue;
}
BigDecimal amount;
if (loanCharge.getChargeCalculation().isFlat()) {
amount = loanCharge.amountOrPercentage();
@@ -633,6 +672,78 @@ public class LoanChargeService {
return loanChargePerInstallments;
}
private List<LoanInstallmentCharge> generateInstallmentLoanCharges(final Loan loan, final LoanCharge loanCharge,
final LocalDate transactionDate) {
final List<LoanInstallmentCharge> loanChargePerInstallments = new ArrayList<>();
if (loanCharge.isInstalmentFee()) {
final List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments().stream()
.filter(i -> i != null && i.isNotFullyPaidOff() && i.getDueDate() != null && !i.getDueDate().isBefore(transactionDate))
.toList();
for (final LoanRepaymentScheduleInstallment installment : installments) {
BigDecimal amount;
if (loanCharge.getChargeCalculation().isFlat()) {
amount = loanCharge.amountOrPercentage();
} else {
amount = calculateInstallmentChargeAmount(loan, loanCharge.getChargeCalculation(), loanCharge.getPercentage(),
installment).getAmount();
}
final LoanInstallmentCharge loanInstallmentCharge = new LoanInstallmentCharge(amount, loanCharge, installment);
installment.getInstallmentCharges().add(loanInstallmentCharge);
loanChargePerInstallments.add(loanInstallmentCharge);
}
}
return loanChargePerInstallments;
}
public void updateInstallmentCharges(final LoanCharge loanCharge, final LocalDate transactionDate) {
final List<LoanInstallmentCharge> newChargeInstallments = generateInstallmentLoanCharges(loanCharge.getLoan(), loanCharge,
transactionDate);
if (loanCharge.getLoanInstallmentCharge().isEmpty()) {
loanCharge.getLoanInstallmentCharge().addAll(newChargeInstallments);
} else {
final List<LoanInstallmentCharge> oldLoanInstallmentCharges = loanCharge
.getLoanInstallmentCharge().stream().filter(i -> i != null && !i.isPaid() && i.getInstallment() != null
&& i.getInstallment().getDueDate() != null && !i.getInstallment().getDueDate().isBefore(transactionDate))
.toList();
final Map<Integer, LoanInstallmentCharge> newChargeMap = new HashMap<>();
for (final LoanInstallmentCharge newCharge : newChargeInstallments) {
if (newCharge.getInstallment() != null && newCharge.getInstallment().getInstallmentNumber() != null) {
newChargeMap.put(newCharge.getInstallment().getInstallmentNumber(), newCharge);
}
}
final Collection<LoanInstallmentCharge> chargesToRemoveFromLoanCharge = new HashSet<>();
final Collection<LoanInstallmentCharge> chargesToAddIntoLoanCharge = new HashSet<>();
for (final LoanInstallmentCharge oldCharge : oldLoanInstallmentCharges) {
final Integer oldInstallmentNumber = oldCharge.getInstallment().getInstallmentNumber();
if (newChargeMap.containsKey(oldInstallmentNumber)) {
chargesToRemoveFromLoanCharge.add(oldCharge);
oldCharge.getInstallment().getInstallmentCharges().remove(oldCharge);
chargesToAddIntoLoanCharge.add(newChargeMap.get(oldInstallmentNumber));
newChargeMap.remove(oldInstallmentNumber);
} else {
chargesToRemoveFromLoanCharge.add(oldCharge);
oldCharge.getInstallment().getInstallmentCharges().remove(oldCharge);
}
}
chargesToAddIntoLoanCharge.addAll(newChargeMap.values());
loanCharge.getLoanInstallmentCharge().removeAll(chargesToRemoveFromLoanCharge);
loanCharge.getLoanInstallmentCharge().addAll(chargesToAddIntoLoanCharge);
}
Money totalAmount = Money.zero(loanCharge.getLoan().getCurrency());
for (LoanInstallmentCharge charge : loanCharge.getLoanInstallmentCharge()) {
totalAmount = totalAmount.plus(charge.getAmount());
}
loanCharge.setAmount(totalAmount.getAmount());
}
private BigDecimal calculateOverdueAmountPercentageAppliedTo(final Loan loan, final LoanCharge loanCharge,
final int penaltyWaitPeriod) {
LoanRepaymentScheduleInstallment installment = loanCharge.getOverdueInstallmentCharge().getInstallment();
@@ -674,7 +785,7 @@ public class LoanChargeService {
case FLAT:
if (loanCharge.isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmensAfterExceptions();
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmentsAfterExceptions();
}
loanCharge.setAmount(amount.multiply(BigDecimal.valueOf(numberOfRepayments)));
} else {
@@ -701,6 +812,46 @@ public class LoanChargeService {
}
}
private void update(final LoanCharge loanCharge, final BigDecimal amount, final LocalDate dueDate, final BigDecimal loanPrincipal,
Integer numberOfRepayments, BigDecimal loanChargeAmount, final LocalDate transactionDate) {
if (dueDate != null) {
loanCharge.setDueDate(dueDate);
}
if (amount != null) {
switch (loanCharge.getChargeCalculation()) {
case INVALID:
break;
case FLAT:
if (loanCharge.isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmentsAfterExceptions();
}
loanCharge.setAmount(amount.multiply(BigDecimal.valueOf(numberOfRepayments)));
} else {
loanCharge.setAmount(amount);
}
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
loanCharge.setPercentage(amount);
loanCharge.setAmountPercentageAppliedTo(loanPrincipal);
if (loanChargeAmount.compareTo(BigDecimal.ZERO) == 0) {
loanChargeAmount = loanCharge.percentageOf(loanCharge.getAmountPercentageAppliedTo());
}
loanCharge.setAmount(loanCharge.minimumAndMaximumCap(loanChargeAmount));
break;
}
loanCharge.setAmountOrPercentage(amount);
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) {
updateInstallmentCharges(loanCharge, transactionDate);
}
}
}
private Money getTotalAllTrancheDisbursementAmount(final Loan loan) {
Money amount = Money.zero(loan.getCurrency());
if (loan.isMultiDisburmentLoan()) {
@@ -42,6 +42,8 @@ public final class LoanProductsApiResourceSwagger {
@Schema(example = "1")
public Long id;
@Schema(example = "60.0")
public BigDecimal amount;
}
@Schema(description = "LoanProductChargeToGLAccountMapper")
@@ -1421,7 +1423,7 @@ public final class LoanProductsApiResourceSwagger {
@Schema(example = "false")
public Boolean canDefineInstallmentAmount;
@Schema(example = "[]")
public List<Integer> charges;
public List<LoanProductChargeData> charges;
public Set<GetLoanProductsPrincipalVariationsForBorrowerCycle> productsPrincipalVariationsForBorrowerCycle;
@Schema(example = "[]")
public List<Integer> interestRateVariationsForBorrowerCycle;