FINERACT-2190: Avoid recreation of loan repayment schedule periods

This commit is contained in:
Jose Alberto Hernandez
2025-04-04 14:29:10 +02:00
committed by Adam Saghy
parent b9cfa470ff
commit 52c41b1471
13 changed files with 229 additions and 117 deletions
@@ -57,7 +57,6 @@ import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
@@ -112,7 +111,6 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.Loa
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleGenerator;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.AmortizationMethod;
import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType;
import org.apache.fineract.portfolio.loanproduct.domain.InterestCalculationPeriodMethod;
@@ -610,8 +608,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.interestChargedFromDate = loanApplicationTerms.getInterestChargedFromDate();
this.submittedOnDate = submittedOnDate != null ? submittedOnDate : DateUtils.getBusinessLocalDate();
updateLoanSchedule(loanScheduleModel);
updateSummaryWithTotalFeeChargesDueAtDisbursement(deriveSumTotalOfChargesDueAtDisbursement());
// Copy interest recalculation settings if interest recalculation is enabled
@@ -1005,53 +1001,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.rates.addAll(loanRates);
}
public void updateLoanSchedule(final LoanScheduleModel modifiedLoanSchedule) {
this.repaymentScheduleInstallments.clear();
for (final LoanScheduleModelPeriod scheduledLoanInstallment : modifiedLoanSchedule.getPeriods()) {
if (scheduledLoanInstallment.isRepaymentPeriod() || scheduledLoanInstallment.isDownPaymentPeriod()) {
final LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(this,
scheduledLoanInstallment.periodNumber(), scheduledLoanInstallment.periodFromDate(),
scheduledLoanInstallment.periodDueDate(), scheduledLoanInstallment.principalDue(),
scheduledLoanInstallment.interestDue(), scheduledLoanInstallment.feeChargesDue(),
scheduledLoanInstallment.penaltyChargesDue(), scheduledLoanInstallment.isRecalculatedInterestComponent(),
scheduledLoanInstallment.getLoanCompoundingDetails(), scheduledLoanInstallment.rescheduleInterestPortion(),
scheduledLoanInstallment.isDownPaymentPeriod());
addLoanRepaymentScheduleInstallment(installment);
}
}
updateLoanScheduleDependentDerivedFields();
updateLoanSummaryDerivedFields();
}
public void updateLoanSchedule(final Collection<LoanRepaymentScheduleInstallment> installments) {
List<LoanRepaymentScheduleInstallment> existingInstallments = new ArrayList<>(this.repaymentScheduleInstallments);
repaymentScheduleInstallments.clear();
for (final LoanRepaymentScheduleInstallment installment : installments) {
LoanRepaymentScheduleInstallment existingInstallment = findByInstallmentNumber(existingInstallments,
installment.getInstallmentNumber());
if (existingInstallment != null) {
Set<LoanInstallmentCharge> existingCharges = existingInstallment.getInstallmentCharges();
installment.getInstallmentCharges().addAll(existingCharges);
existingCharges.forEach(c -> c.setInstallment(installment));
existingInstallment.getInstallmentCharges().clear();
}
addLoanRepaymentScheduleInstallment(installment);
}
updateLoanScheduleDependentDerivedFields();
updateLoanSummaryDerivedFields();
}
private LoanRepaymentScheduleInstallment findByInstallmentNumber(Collection<LoanRepaymentScheduleInstallment> installments,
Integer installmentNumber) {
for (LoanRepaymentScheduleInstallment installment : installments) {
if (Objects.equals(installment.getInstallmentNumber(), installmentNumber)) {
return installment;
}
}
return null;
}
public void updateLoanScheduleDependentDerivedFields() {
if (this.getLoanRepaymentScheduleInstallmentsSize() > 0) {
this.expectedMaturityDate = determineExpectedMaturityDate();
@@ -38,10 +38,12 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.AllocationType;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks;
@Getter
@Setter
@Entity
@Table(name = "m_loan_repayment_schedule")
public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDateTimeCustom<Long>
@@ -60,67 +62,67 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
@Column(name = "duedate", nullable = false)
private LocalDate dueDate;
@Column(name = "principal_amount", scale = 6, precision = 19, nullable = true)
@Column(name = "principal_amount", scale = 6, precision = 19)
private BigDecimal principal;
@Column(name = "principal_completed_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "principal_completed_derived", scale = 6, precision = 19)
private BigDecimal principalCompleted;
@Column(name = "principal_writtenoff_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "principal_writtenoff_derived", scale = 6, precision = 19)
private BigDecimal principalWrittenOff;
@Column(name = "interest_amount", scale = 6, precision = 19, nullable = true)
@Column(name = "interest_amount", scale = 6, precision = 19)
private BigDecimal interestCharged;
@Column(name = "interest_completed_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "interest_completed_derived", scale = 6, precision = 19)
private BigDecimal interestPaid;
@Column(name = "interest_waived_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "interest_waived_derived", scale = 6, precision = 19)
private BigDecimal interestWaived;
@Column(name = "interest_writtenoff_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "interest_writtenoff_derived", scale = 6, precision = 19)
private BigDecimal interestWrittenOff;
@Column(name = "accrual_interest_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "accrual_interest_derived", scale = 6, precision = 19)
private BigDecimal interestAccrued;
@Column(name = "reschedule_interest_portion", scale = 6, precision = 19, nullable = true)
@Column(name = "reschedule_interest_portion", scale = 6, precision = 19)
private BigDecimal rescheduleInterestPortion;
@Column(name = "fee_charges_amount", scale = 6, precision = 19, nullable = true)
@Column(name = "fee_charges_amount", scale = 6, precision = 19)
private BigDecimal feeChargesCharged;
@Column(name = "fee_charges_completed_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "fee_charges_completed_derived", scale = 6, precision = 19)
private BigDecimal feeChargesPaid;
@Column(name = "fee_charges_writtenoff_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "fee_charges_writtenoff_derived", scale = 6, precision = 19)
private BigDecimal feeChargesWrittenOff;
@Column(name = "fee_charges_waived_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "fee_charges_waived_derived", scale = 6, precision = 19)
private BigDecimal feeChargesWaived;
@Column(name = "accrual_fee_charges_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "accrual_fee_charges_derived", scale = 6, precision = 19)
private BigDecimal feeAccrued;
@Column(name = "penalty_charges_amount", scale = 6, precision = 19, nullable = true)
@Column(name = "penalty_charges_amount", scale = 6, precision = 19)
private BigDecimal penaltyCharges;
@Column(name = "penalty_charges_completed_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "penalty_charges_completed_derived", scale = 6, precision = 19)
private BigDecimal penaltyChargesPaid;
@Column(name = "penalty_charges_writtenoff_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "penalty_charges_writtenoff_derived", scale = 6, precision = 19)
private BigDecimal penaltyChargesWrittenOff;
@Column(name = "penalty_charges_waived_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "penalty_charges_waived_derived", scale = 6, precision = 19)
private BigDecimal penaltyChargesWaived;
@Column(name = "accrual_penalty_charges_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "accrual_penalty_charges_derived", scale = 6, precision = 19)
private BigDecimal penaltyAccrued;
@Column(name = "total_paid_in_advance_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "total_paid_in_advance_derived", scale = 6, precision = 19)
private BigDecimal totalPaidInAdvance;
@Column(name = "total_paid_late_derived", scale = 6, precision = 19, nullable = true)
@Column(name = "total_paid_late_derived", scale = 6, precision = 19)
private BigDecimal totalPaidLate;
@Column(name = "completed_derived", nullable = false)
@@ -139,17 +141,17 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
// approach
// https://blog.thepete.net/blog/2023/12/05/expand/contract-making-a-breaking-change-without-a-big-bang/
@Setter
@Column(name = "credits_amount", scale = 6, precision = 19, nullable = true)
@Column(name = "credits_amount", scale = 6, precision = 19)
private BigDecimal creditedPrincipal;
@Setter
@Column(name = "credited_interest", scale = 6, precision = 19, nullable = true)
@Column(name = "credited_interest", scale = 6, precision = 19)
private BigDecimal creditedInterest;
@Column(name = "credited_fee", scale = 6, precision = 19, nullable = true)
@Column(name = "credited_fee", scale = 6, precision = 19)
private BigDecimal creditedFee;
@Column(name = "credited_penalty", scale = 6, precision = 19, nullable = true)
@Column(name = "credited_penalty", scale = 6, precision = 19)
private BigDecimal creditedPenalty;
@Column(name = "is_down_payment", nullable = false)
@@ -1068,4 +1070,58 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
MathUtil.nullToZero(MathUtil.add(getPrincipal(), getInterestCharged(), getFeeChargesCharged(), getPenaltyCharges())));
}
public void copyFrom(final LoanScheduleModelPeriod period) {
resetBalances();
setFromDate(period.periodFromDate());
setDueDate(period.periodDueDate());
setPrincipal(period.principalDue());
setInterestCharged(period.interestDue());
setFeeChargesCharged(period.feeChargesDue());
setPenaltyCharges(period.penaltyChargesDue());
setRecalculatedInterestComponent(period.isRecalculatedInterestComponent());
if (getLoanCompoundingDetails() != null) {
getLoanCompoundingDetails().clear();
}
if (period.getLoanCompoundingDetails() != null) {
period.getLoanCompoundingDetails().forEach(lcd -> {
lcd.setLoanRepaymentScheduleInstallment(this);
getLoanCompoundingDetails().add(lcd);
});
}
setRescheduleInterestPortion(period.rescheduleInterestPortion());
setDownPayment(period.isDownPaymentPeriod());
setAdditional(false);
setReAged(false);
}
public void copyFrom(final LoanRepaymentScheduleInstallment installment) {
setFromDate(installment.getFromDate());
setDueDate(installment.getDueDate());
setPrincipal(installment.getPrincipal());
setInterestCharged(installment.getInterestCharged());
setFeeChargesCharged(installment.getFeeChargesCharged());
setPenaltyCharges(installment.getPenaltyCharges());
setRecalculatedInterestComponent(installment.isRecalculatedInterestComponent());
if (getLoanCompoundingDetails() != null) {
getLoanCompoundingDetails().clear();
}
if (installment.getLoanCompoundingDetails() != null) {
installment.getLoanCompoundingDetails().forEach(lcd -> {
lcd.setLoanRepaymentScheduleInstallment(this);
getLoanCompoundingDetails().add(lcd);
});
}
Set<LoanInstallmentCharge> installmentCharges = installment.getInstallmentCharges();
installmentCharges.forEach(c -> c.setInstallment(this));
getInstallmentCharges().addAll(installmentCharges);
setAdditional(installment.isAdditional());
setReAged(installment.isReAged());
setDownPayment(installment.isDownPayment());
}
}
@@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
import org.apache.fineract.portfolio.loanaccount.service.schedule.LoanScheduleComponent;
@RequiredArgsConstructor
public class LoanScheduleService {
@@ -36,16 +37,17 @@ public class LoanScheduleService {
private final LoanChargeService loanChargeService;
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
private final LoanTransactionProcessingService loadTransactionProcessingService;
private final LoanScheduleComponent loanSchedule;
/**
* Ability to regenerate the repayment schedule based on the loans current details/state.
*/
public void regenerateRepaymentSchedule(final Loan loan, final ScheduleGeneratorDTO scheduleGeneratorDTO) {
final LoanScheduleModel loanSchedule = loan.regenerateScheduleModel(scheduleGeneratorDTO);
if (loanSchedule == null) {
final LoanScheduleModel loanScheduleModel = loan.regenerateScheduleModel(scheduleGeneratorDTO);
if (loanScheduleModel == null) {
return;
}
loan.updateLoanSchedule(loanSchedule);
loanSchedule.updateLoanSchedule(loan, loanScheduleModel);
final Set<LoanCharge> charges = loan.getActiveCharges();
for (final LoanCharge loanCharge : charges) {
if (!loanCharge.isWaived()) {
@@ -80,15 +82,15 @@ public class LoanScheduleService {
public void regenerateRepaymentScheduleWithInterestRecalculation(final Loan loan, final ScheduleGeneratorDTO generatorDTO) {
final LocalDate lastTransactionDate = loan.getLastUserTransactionDate();
final LoanScheduleDTO loanSchedule = loadTransactionProcessingService.getRecalculatedSchedule(generatorDTO, loan);
if (loanSchedule == null) {
final LoanScheduleDTO loanScheduleDTO = loadTransactionProcessingService.getRecalculatedSchedule(generatorDTO, loan);
if (loanScheduleDTO == null) {
return;
}
// Either the installments got recalculated or the model
if (loanSchedule.getInstallments() != null) {
loan.updateLoanSchedule(loanSchedule.getInstallments());
if (loanScheduleDTO.getInstallments() != null) {
loanSchedule.updateLoanSchedule(loan, loanScheduleDTO.getInstallments());
} else {
loan.updateLoanSchedule(loanSchedule.getLoanScheduleModel());
loanSchedule.updateLoanSchedule(loan, loanScheduleDTO.getLoanScheduleModel());
}
loan.setInterestRecalculatedOn(DateUtils.getBusinessLocalDate());
final LocalDate lastRepaymentDate = loan.getLastRepaymentPeriodDueDate(true);
@@ -0,0 +1,91 @@
/**
* 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.loanaccount.service.schedule;
import java.util.Collection;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class LoanScheduleComponent {
public void updateLoanSchedule(Loan loan, final LoanScheduleModel modifiedLoanSchedule) {
final List<LoanScheduleModelPeriod> periods = modifiedLoanSchedule.getPeriods();
for (final LoanScheduleModelPeriod scheduledLoanInstallment : modifiedLoanSchedule.getPeriods()) {
if (scheduledLoanInstallment.isRepaymentPeriod() || scheduledLoanInstallment.isDownPaymentPeriod()) {
LoanRepaymentScheduleInstallment existingInstallment = findByInstallmentNumber(loan.getRepaymentScheduleInstallments(),
scheduledLoanInstallment.periodNumber());
if (existingInstallment == null) {
final LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan,
scheduledLoanInstallment.periodNumber(), scheduledLoanInstallment.periodFromDate(),
scheduledLoanInstallment.periodDueDate(), scheduledLoanInstallment.principalDue(),
scheduledLoanInstallment.interestDue(), scheduledLoanInstallment.feeChargesDue(),
scheduledLoanInstallment.penaltyChargesDue(), scheduledLoanInstallment.isRecalculatedInterestComponent(),
scheduledLoanInstallment.getLoanCompoundingDetails(), scheduledLoanInstallment.rescheduleInterestPortion(),
scheduledLoanInstallment.isDownPaymentPeriod());
loan.addLoanRepaymentScheduleInstallment(installment);
} else {
existingInstallment.copyFrom(scheduledLoanInstallment);
}
}
}
// Review Installments removed
loan.getRepaymentScheduleInstallments().removeIf(i -> !existInstallment(periods, i.getInstallmentNumber()));
loan.updateLoanScheduleDependentDerivedFields();
loan.updateLoanSummaryDerivedFields();
}
public void updateLoanSchedule(Loan loan, final List<LoanRepaymentScheduleInstallment> installments) {
for (final LoanRepaymentScheduleInstallment installment : installments) {
LoanRepaymentScheduleInstallment existingInstallment = findByInstallmentNumber(loan.getRepaymentScheduleInstallments(),
installment.getInstallmentNumber());
if (existingInstallment != null) {
existingInstallment.copyFrom(installment);
} else {
loan.addLoanRepaymentScheduleInstallment(installment);
}
}
// Review Installments removed
loan.getRepaymentScheduleInstallments().removeIf(i -> !existInstallment(installments, i.getInstallmentNumber()));
loan.updateLoanScheduleDependentDerivedFields();
loan.updateLoanSummaryDerivedFields();
}
private LoanRepaymentScheduleInstallment findByInstallmentNumber(final Collection<LoanRepaymentScheduleInstallment> installments,
final Integer installmentNumber) {
return installments.stream().filter(i -> installmentNumber.compareTo(i.getInstallmentNumber()) == 0).findFirst().orElse(null);
}
private boolean existInstallment(final Collection<LoanRepaymentScheduleInstallment> installments, final Integer installmentNumber) {
return installments.stream().anyMatch(i -> installmentNumber.compareTo(i.getInstallmentNumber()) == 0);
}
private boolean existInstallment(final List<LoanScheduleModelPeriod> periods, final Integer installmentNumber) {
return periods.stream().anyMatch(p -> p.periodNumber() != null && installmentNumber.compareTo(p.periodNumber()) == 0);
}
}