FINERACT-1981: Progressive Loan schedule handling - Reschedule installment (Extend repayment period)

This commit is contained in:
Soma Sörös
2025-08-21 10:22:07 +02:00
committed by Adam Saghy
parent 47b55b5ee6
commit 14589e2600
10 changed files with 752 additions and 95 deletions
@@ -53,6 +53,7 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@@ -234,9 +235,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransaction> overpaidTransactions = new ArrayList<>();
for (final ChangeOperation changeOperation : changeOperations) {
if (changeOperation.isInterestRateChange()) {
final LoanTermVariationsData interestRateChange = changeOperation.getInterestRateChange().get();
processInterestRateChange(installments, interestRateChange, scheduleModel);
if (changeOperation.isLoanTermVariationsData()) {
final LoanTermVariationsData interestRateChange = changeOperation.getLoanTermVariationsData().get();
processLoanTermVariation(installments, interestRateChange, scheduleModel);
} else if (changeOperation.isTransaction()) {
LoanTransaction transaction = changeOperation.getLoanTransaction().get();
processSingleTransaction(transaction, ctx);
@@ -301,16 +302,55 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
.map(TransactionChangeData::getNewTransaction).findFirst().orElse(transaction);
}
private void processInterestRateChange(final List<LoanRepaymentScheduleInstallment> installments,
final LoanTermVariationsData interestRateChange, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final LocalDate interestRateChangeSubmittedOnDate = interestRateChange.getTermVariationApplicableFrom();
final BigDecimal newInterestRate = interestRateChange.getDecimalValue();
if (interestRateChange.getTermVariationType().isInterestPauseVariation()) {
final LocalDate pauseEndDate = interestRateChange.getDateValue();
emiCalculator.applyInterestPause(scheduleModel, interestRateChangeSubmittedOnDate, pauseEndDate);
} else {
emiCalculator.changeInterestRate(scheduleModel, interestRateChangeSubmittedOnDate, newInterestRate);
private void processLoanTermVariation(final List<LoanRepaymentScheduleInstallment> installments,
final LoanTermVariationsData termVariationsData, final ProgressiveLoanInterestScheduleModel scheduleModel) {
switch (termVariationsData.getTermVariationType()) {
case INTEREST_PAUSE -> handleInterestPause(installments, termVariationsData, scheduleModel);
case INTEREST_RATE_FROM_INSTALLMENT -> handleChangeInterestRate(installments, termVariationsData, scheduleModel);
case EXTEND_REPAYMENT_PERIOD -> handleExtraRepaymentPeriod(installments, termVariationsData, scheduleModel);
default -> throw new IllegalStateException("Unhandled LoanTermVariationType.");
}
}
private void handleExtraRepaymentPeriod(final List<LoanRepaymentScheduleInstallment> installments,
final LoanTermVariationsData termVariationsData, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final LocalDate interestRateChangeSubmittedOnDate = termVariationsData.getTermVariationApplicableFrom();
final int repaymentPeriodsToAdd = termVariationsData.getDecimalValue().intValue();
emiCalculator.addRepaymentPeriods(scheduleModel, interestRateChangeSubmittedOnDate, repaymentPeriodsToAdd);
final Loan loan = installments.getFirst().getLoan();
int nextInstallmentNumber = installments.stream().mapToInt(LoanRepaymentScheduleInstallment::getInstallmentNumber).max().orElse(0)
+ 1;
for (int i = 0; i < scheduleModel.repaymentPeriods().size(); i++) {
final RepaymentPeriod rp = scheduleModel.repaymentPeriods().get(i);
// Check if this period already exists in installments
if (installments.stream().noneMatch(installment -> installment.getFromDate().equals(rp.getFromDate())
&& installment.getDueDate().equals(rp.getDueDate()))) {
final LoanRepaymentScheduleInstallment newInstallment = new LoanRepaymentScheduleInstallment(loan, nextInstallmentNumber,
rp.getFromDate(), rp.getDueDate(), rp.getDuePrincipal().getAmount(), rp.getDueInterest().getAmount(), ZERO, ZERO,
false, null, ZERO);
installments.add(newInstallment);
nextInstallmentNumber++;
}
}
processInterestRateChangeOnInstallments(scheduleModel, interestRateChangeSubmittedOnDate, installments);
}
private void handleChangeInterestRate(final List<LoanRepaymentScheduleInstallment> installments,
final LoanTermVariationsData termVariationsData, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final LocalDate interestRateChangeSubmittedOnDate = termVariationsData.getTermVariationApplicableFrom();
final BigDecimal newInterestRate = termVariationsData.getDecimalValue();
emiCalculator.changeInterestRate(scheduleModel, interestRateChangeSubmittedOnDate, newInterestRate);
processInterestRateChangeOnInstallments(scheduleModel, interestRateChangeSubmittedOnDate, installments);
}
private void handleInterestPause(final List<LoanRepaymentScheduleInstallment> installments,
final LoanTermVariationsData termVariationsData, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final LocalDate interestRateChangeSubmittedOnDate = termVariationsData.getTermVariationApplicableFrom();
final LocalDate pauseEndDate = termVariationsData.getDateValue();
emiCalculator.applyInterestPause(scheduleModel, interestRateChangeSubmittedOnDate, pauseEndDate);
processInterestRateChangeOnInstallments(scheduleModel, interestRateChangeSubmittedOnDate, installments);
}
@@ -1204,14 +1244,14 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<ChangeOperation> changeOperations = new ArrayList<>();
Map<LoanTermVariationType, List<LoanTermVariationsData>> loanTermVariationsMap = loanTermVariations.stream()
.collect(Collectors.groupingBy(ltvd -> LoanTermVariationType.fromInt(ltvd.getTermType().getId().intValue())));
if (loanTermVariationsMap.get(LoanTermVariationType.INTEREST_RATE_FROM_INSTALLMENT) != null) {
changeOperations.addAll(loanTermVariationsMap.get(LoanTermVariationType.INTEREST_RATE_FROM_INSTALLMENT).stream()
.map(ChangeOperation::new).toList());
}
if (loanTermVariationsMap.get(LoanTermVariationType.INTEREST_PAUSE) != null) {
changeOperations
.addAll(loanTermVariationsMap.get(LoanTermVariationType.INTEREST_PAUSE).stream().map(ChangeOperation::new).toList());
}
Stream.of(LoanTermVariationType.INTEREST_RATE_FROM_INSTALLMENT, LoanTermVariationType.INTEREST_PAUSE,
LoanTermVariationType.EXTEND_REPAYMENT_PERIOD).forEach(key -> {
if (loanTermVariationsMap.get(key) != null) {
changeOperations.addAll(loanTermVariationsMap.get(key).stream().map(ChangeOperation::new).toList());
}
});
if (charges != null) {
changeOperations.addAll(charges.stream().map(ChangeOperation::new).toList());
}
@@ -32,30 +32,30 @@ import org.springframework.lang.NonNull;
@Getter
public class ChangeOperation implements Comparable<ChangeOperation> {
private final Optional<LoanTermVariationsData> interestRateChange;
private final Optional<LoanTermVariationsData> loanTermVariationsData;
private final Optional<LoanCharge> loanCharge;
private final Optional<LoanTransaction> loanTransaction;
public ChangeOperation(LoanCharge loanCharge) {
this.interestRateChange = Optional.empty();
this.loanTermVariationsData = Optional.empty();
this.loanCharge = Optional.of(loanCharge);
this.loanTransaction = Optional.empty();
}
public ChangeOperation(LoanTransaction loanTransaction) {
this.interestRateChange = Optional.empty();
this.loanTermVariationsData = Optional.empty();
this.loanTransaction = Optional.of(loanTransaction);
this.loanCharge = Optional.empty();
}
public ChangeOperation(LoanTermVariationsData interestRateChange) {
this.interestRateChange = Optional.of(interestRateChange);
public ChangeOperation(LoanTermVariationsData loanTermVariationsData) {
this.loanTermVariationsData = Optional.of(loanTermVariationsData);
this.loanTransaction = Optional.empty();
this.loanCharge = Optional.empty();
}
public boolean isInterestRateChange() {
return interestRateChange.isPresent();
public boolean isLoanTermVariationsData() {
return loanTermVariationsData.isPresent();
}
public boolean isTransaction() {
@@ -80,7 +80,7 @@ public class ChangeOperation implements Comparable<ChangeOperation> {
}
private LocalDate getEffectiveDate() {
if (interestRateChange.isPresent()) {
if (loanTermVariationsData.isPresent()) {
return getSubmittedOnDate();
} else if (loanCharge.isPresent()) {
if (isBackdatedCharge()) {
@@ -98,8 +98,8 @@ public class ChangeOperation implements Comparable<ChangeOperation> {
}
private LocalDate getSubmittedOnDate() {
if (interestRateChange.isPresent()) {
return interestRateChange.get().getTermVariationApplicableFrom();
if (loanTermVariationsData.isPresent()) {
return loanTermVariationsData.get().getTermVariationApplicableFrom();
} else if (loanCharge.isPresent()) {
return loanCharge.get().getSubmittedOnDate();
} else if (loanTransaction.isPresent()) {
@@ -110,7 +110,7 @@ public class ChangeOperation implements Comparable<ChangeOperation> {
}
private OffsetDateTime getCreatedDateTime() {
if (interestRateChange.isPresent()) {
if (loanTermVariationsData.isPresent()) {
return DateUtils.getOffsetDateTimeOfTenantFromLocalDate(getSubmittedOnDate());
} else if (loanCharge.isPresent() && loanCharge.get().getCreatedDate().isPresent()) {
return loanCharge.get().getCreatedDate().get();
@@ -38,6 +38,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -78,42 +79,73 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
validateRescheduleReasonId(fromJsonHelper, jsonElement, dataValidatorBuilder);
validateRescheduleReasonComment(fromJsonHelper, jsonElement, dataValidatorBuilder);
LocalDate adjustedDueDate = validateAndRetrieveAdjustedDate(fromJsonHelper, jsonElement, rescheduleFromDate, dataValidatorBuilder);
BigDecimal interestRate = validateInterestRate(fromJsonHelper, jsonElement, dataValidatorBuilder, loan);
BigDecimal interestRate = validateInterestRateParam(fromJsonHelper, jsonElement, dataValidatorBuilder, loan);
Integer extraTerms = validateExtraTermsParam(fromJsonHelper, jsonElement, dataValidatorBuilder, loan);
validateUnsupportedParams(jsonElement, dataValidatorBuilder);
boolean hasInterestRateChange = interestRate != null;
boolean hasAdjustDueDateChange = adjustedDueDate != null;
if (hasInterestRateChange) {
validateLoanStatusIsActiveOrClosed(loan, dataValidatorBuilder);
} else {
validateLoanIsActive(loan, dataValidatorBuilder);
}
boolean hasExtraTermsChange = extraTerms != null;
if (hasInterestRateChange && hasAdjustDueDateChange) {
if (Stream.of(hasInterestRateChange, hasAdjustDueDateChange, hasExtraTermsChange).filter(f -> f).count() > 1) {
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.adjustedDueDateParamName).failWithCode(
RescheduleLoansApiConstants.rescheduleMultipleOperationsNotSupportedErrorCode,
"Only one operation is supported at a time during Loan Rescheduling");
}
if (rescheduleFromDate != null && hasInterestRateChange) {
validateInterestRateChangeRescheduleFromDate(loan, rescheduleFromDate);
if (hasExtraTermsChange) {
validateExtraTerms(dataValidatorBuilder, loan);
} else if (hasAdjustDueDateChange) {
validateAdjustDueDateChange(dataValidatorBuilder, loan, rescheduleFromDate);
} else if (hasInterestRateChange) {
validateInterestRate(dataValidatorBuilder, loan, rescheduleFromDate);
}
LoanRepaymentScheduleInstallment installment;
if (hasInterestRateChange) {
installment = loan.getRelatedRepaymentScheduleInstallment(rescheduleFromDate);
} else {
installment = loan.fetchLoanRepaymentScheduleInstallmentByDueDate(rescheduleFromDate);
}
validateReschedulingInstallment(dataValidatorBuilder, installment);
validateForOverdueCharges(dataValidatorBuilder, loan, installment);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
private void validateAdjustDueDateChange(DataValidatorBuilder dataValidatorBuilder, Loan loan, LocalDate rescheduleFromDate) {
validateLoanIsActive(loan, dataValidatorBuilder);
LoanRepaymentScheduleInstallment installment = loan.fetchLoanRepaymentScheduleInstallmentByDueDate(rescheduleFromDate);
validateReschedulingInstallment(dataValidatorBuilder, installment);
validateForOverdueCharges(dataValidatorBuilder, loan, installment);
}
private void validateInterestRate(DataValidatorBuilder dataValidatorBuilder, Loan loan, LocalDate rescheduleFromDate) {
validateLoanStatusIsActiveOrClosed(loan, dataValidatorBuilder);
if (rescheduleFromDate != null) {
validateInterestRateChangeRescheduleFromDate(loan, rescheduleFromDate);
}
LoanRepaymentScheduleInstallment installment;
installment = loan.getRelatedRepaymentScheduleInstallment(rescheduleFromDate);
validateReschedulingInstallment(dataValidatorBuilder, installment);
validateForOverdueCharges(dataValidatorBuilder, loan, installment);
}
private void validateExtraTerms(DataValidatorBuilder dataValidatorBuilder, Loan loan) {
validateLoanIsActive(loan, dataValidatorBuilder);
}
private Integer validateExtraTermsParam(FromJsonHelper fromJsonHelper, JsonElement jsonElement,
DataValidatorBuilder dataValidatorBuilder, Loan loan) {
final Integer extraTerms = fromJsonHelper.extractIntegerWithLocaleNamed(RescheduleLoansApiConstants.extraTermsParamName,
jsonElement);
DataValidatorBuilder extraTermsDataValidator = dataValidatorBuilder.reset()
.parameter(RescheduleLoansApiConstants.extraTermsParamName).value(extraTerms).ignoreIfNull().integerGreaterThanZero();
if (extraTerms != null) {
Integer maxNumberOfRepayments = loan.getLoanProduct().getMaxNumberOfRepayments();
if (maxNumberOfRepayments != null) {
Integer numberOfRepayments = loan.getLoanProductRelatedDetail().getNumberOfRepayments();
extraTermsDataValidator.notGreaterThanMax(maxNumberOfRepayments - numberOfRepayments);
}
}
return extraTerms;
}
@Override
public void validateReschedulingInstallment(DataValidatorBuilder dataValidatorBuilder, LoanRepaymentScheduleInstallment installment) {
if (installment == null) {
@@ -125,7 +157,6 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
private void validateUnsupportedParams(JsonElement jsonElement, DataValidatorBuilder dataValidatorBuilder) {
final var unsupportedFields = List.of(RescheduleLoansApiConstants.graceOnPrincipalParamName, //
RescheduleLoansApiConstants.graceOnInterestParamName, //
RescheduleLoansApiConstants.extraTermsParamName, //
RescheduleLoansApiConstants.emiParamName//
);
@@ -152,6 +183,7 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
final Loan loan = loanRescheduleRequest.getLoan();
LoanRepaymentScheduleInstallment installment;
boolean hasExtraTerms = false;
boolean hasInterestRateChange = false;
for (LoanRescheduleRequestToTermVariationMapping mapping : loanRescheduleRequest
.getLoanRescheduleRequestToTermVariationMappings()) {
@@ -159,6 +191,9 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
if (termType.isInterestRateVariation() || termType.isInterestRateFromInstallment()) {
hasInterestRateChange = true;
}
if (termType.isExtendRepaymentPeriod()) {
hasExtraTerms = true;
}
}
if (hasInterestRateChange) {
validateLoanStatusIsActiveOrClosed(loan, dataValidatorBuilder);
@@ -166,13 +201,15 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
validateLoanIsActive(loan, dataValidatorBuilder);
}
if (loanRescheduleRequest.getInterestRateFromInstallmentTermVariationIfExists() != null) {
if (loanRescheduleRequest.getInterestRateFromInstallmentTermVariationIfExists() != null || hasExtraTerms) {
installment = loan.getRelatedRepaymentScheduleInstallment(rescheduleFromDate);
} else {
installment = loan.fetchLoanRepaymentScheduleInstallmentByDueDate(rescheduleFromDate);
}
validateReschedulingInstallment(dataValidatorBuilder, installment);
validateForOverdueCharges(dataValidatorBuilder, loan, installment);
if (!hasExtraTerms) {
validateForOverdueCharges(dataValidatorBuilder, loan, installment);
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
@@ -198,7 +235,7 @@ public class ProgressiveLoanRescheduleRequestDataValidator implements LoanResche
}
}
private BigDecimal validateInterestRate(final FromJsonHelper fromJsonHelper, final JsonElement jsonElement,
private BigDecimal validateInterestRateParam(final FromJsonHelper fromJsonHelper, final JsonElement jsonElement,
DataValidatorBuilder dataValidatorBuilder, Loan loan) {
final BigDecimal interestRate = fromJsonHelper
.extractBigDecimalWithLocaleNamed(RescheduleLoansApiConstants.newInterestRateParamName, jsonElement);
@@ -78,6 +78,9 @@ public interface EMICalculator {
void changeInterestRate(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate newInterestSubmittedOnDate,
BigDecimal newInterestRate);
void addRepaymentPeriods(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate submittedOnDate,
int numberOfRepaymentPeriodsToAdd);
/**
* This method applies outstanding balance correction on the interest model. Negative amount decreases the
* outstanding balance while positive amounts are increasing that. Typically used for late repayment or to count
@@ -26,13 +26,16 @@ import java.time.LocalDate;
import java.time.Year;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
@@ -178,6 +181,58 @@ public final class ProgressiveEMICalculator implements EMICalculator {
changeInterestRate(scheduleModel, EmiChangeOperation.changeInterestRate(newInterestSubmittedOnDate, newInterestRate));
}
@Override
public void addRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate submittedOnDate,
final int numberOfRepaymentPeriodsToAdd) {
addRepaymentPeriods(scheduleModel,
EmiChangeOperation.addRepaymentPeriods(submittedOnDate, scheduleModel.zero(), numberOfRepaymentPeriodsToAdd));
}
public void addRepaymentPeriods(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
LocalDate disbursementDate = scheduleModel.getStartDate();
int repaymentPeriodCount = scheduleModel.repaymentPeriods().size();
final LocalDate interestRateChangeEffectiveDate = operation.getSubmittedOnDate().minusDays(1);
List<LocalDateInterval> periods2 = generateAdditionalRepaymentPeriodDueDates(scheduleModel, operation.getPeriodsToAdd(),
repaymentPeriodCount, scheduleModel.resolveRepaymentPEriodLengthGeneratorFunction(disbursementDate));
updateModel(scheduleModel, periods2, LocalDateInterval::startDate, LocalDateInterval::endDate);
scheduleModel
.changeOutstandingBalanceAndUpdateInterestPeriods(interestRateChangeEffectiveDate, scheduleModel.zero(),
scheduleModel.zero(), scheduleModel.zero())
.ifPresent(repaymentPeriod -> calculateEMIValueAndRateFactors(
getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, interestRateChangeEffectiveDate), scheduleModel,
operation));
}
public <T> void updateModel(ProgressiveLoanInterestScheduleModel scheduleModel, List<T> updateExpectedRepaymentPeriods,
Function<T, LocalDate> from, Function<T, LocalDate> to) {
final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail = scheduleModel.loanProductRelatedDetail();
MathContext mc = scheduleModel.mc();
final Money zero = Money.zero(loanProductRelatedDetail.getCurrencyData(), mc);
final AtomicReference<RepaymentPeriod> prev = new AtomicReference<>();
RepaymentPeriod originalLAstRepaymentPeriod = scheduleModel.getLastRepaymentPeriod();
prev.set(originalLAstRepaymentPeriod);
List<RepaymentPeriod> repaymentPeriods = updateExpectedRepaymentPeriods.stream().map(e -> {
RepaymentPeriod rp = RepaymentPeriod.create(prev.get(), from.apply(e), to.apply(e), zero, mc, loanProductRelatedDetail);
rp.setTotalDisbursedAmount(originalLAstRepaymentPeriod.getTotalDisbursedAmount());
rp.setTotalCapitalizedIncomeAmount(originalLAstRepaymentPeriod.getTotalCapitalizedIncomeAmount());
prev.set(rp);
return rp;
}).toList();
scheduleModel.repaymentPeriods().addAll(repaymentPeriods);
}
List<LocalDateInterval> generateAdditionalRepaymentPeriodDueDates(final ProgressiveLoanInterestScheduleModel scheduleModel,
final int periods, final int existingRepayments, Function<Long, LocalDate> repaymentPeriodLengthResolver) {
final List<LocalDateInterval> expectedRepaymentPeriods = new ArrayList<>();
Integer repayEvery = scheduleModel.loanProductRelatedDetail().getRepayEvery();
IntStream.range(existingRepayments, existingRepayments + periods).forEach(
i -> expectedRepaymentPeriods.add(LocalDateInterval.create(repaymentPeriodLengthResolver.apply((long) i * repayEvery),
repaymentPeriodLengthResolver.apply((long) (i + 1) * repayEvery))));
return expectedRepaymentPeriods;
}
private void changeInterestRate(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
final LocalDate interestRateChangeEffectiveDate = operation.getSubmittedOnDate().minusDays(1);
scheduleModel.addInterestRate(interestRateChangeEffectiveDate, operation.getInterestRate());
@@ -486,7 +541,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
final List<RepaymentPeriod> relatedRepaymentPeriods = scheduleModel.getRelatedRepaymentPeriods(calculateFromRepaymentPeriodDueDate);
final boolean onlyOnActualModelShouldApply = scheduleModel.isEmpty()
|| operation.getAction() == EmiChangeOperation.Action.INTEREST_RATE_CHANGE || scheduleModel.isCopy();
|| operation.getAction() == EmiChangeOperation.Action.INTEREST_RATE_CHANGE
|| operation.getAction() == EmiChangeOperation.Action.ADD_REPAYMENT_PERIODS || scheduleModel.isCopy();
calculateRateFactorForPeriods(relatedRepaymentPeriods, scheduleModel);
calculateOutstandingBalance(scheduleModel);
@@ -932,13 +988,40 @@ public final class ProgressiveEMICalculator implements EMICalculator {
Money periodEmi = total.dividedBy(repaymentPeriods.size(), mc);
Money periodEmiInMultiplesOf = applyInstallmentAmountInMultiplesOf(scheduleModel, periodEmi);
Money remainder = total.minus(periodEmiInMultiplesOf.multipliedBy(repaymentPeriods.size(), mc));
AtomicReference<Money> reallocationAmount = new AtomicReference<>(Money.zero(currency));
repaymentPeriods.forEach(rp -> {
Money emi = rp.equals(lastRepaymentPeriod) ? periodEmiInMultiplesOf.add(remainder) : periodEmiInMultiplesOf;
rp.setEmi(emi);
rp.setOriginalEmi(emi);
rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance);
if (emi.plus(rp.getTotalCreditedAmount(), mc).plus(rp.getFutureUnrecognizedInterest(), mc)
.isGreaterThanOrEqualTo(rp.getTotalPaidAmount())) {
rp.setEmi(emi);
rp.setOriginalEmi(emi);
rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance);
} else {
Money adjustment = rp.getTotalPaidAmount()
.minus(emi.plus(rp.getTotalCreditedAmount(), mc).plus(rp.getFutureUnrecognizedInterest(), mc));
reallocationAmount.set(reallocationAmount.get().add(adjustment));
rp.setEmi(rp.getTotalPaidAmount());
rp.setOriginalEmi(rp.getTotalPaidAmount());
rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance);
}
});
if (reallocationAmount.get().isGreaterThanZero()) {
repaymentPeriods.reversed().forEach(rp -> {
if (reallocationAmount.get().isGreaterThanZero() && !rp.isFullyPaid()) {
Money minimumNewEmi = MathUtil.max(rp.getEmi().minus(reallocationAmount.get()), Money.zero(currency), true);
Money alreadyPaidEmi = rp.getTotalPaidAmount().minus(rp.getTotalCreditedAmount(), mc)
.minus(rp.getFutureUnrecognizedInterest(), mc);
Money newEmi = MathUtil.max(rp.getCalculatedDueInterest(), MathUtil.max(minimumNewEmi, alreadyPaidEmi, true), true);
reallocationAmount.set(reallocationAmount.get().minus(rp.getEmi().minus(newEmi)));
rp.setEmi(newEmi);
rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance);
}
});
}
}
private void calculateEMIOnActualModel(List<RepaymentPeriod> repaymentPeriods, ProgressiveLoanInterestScheduleModel scheduleModel) {
@@ -33,7 +33,7 @@ public class EmiChangeOperation {
DISBURSEMENT, //
INTEREST_RATE_CHANGE, //
CAPITALIZED_INCOME, //
;
ADD_REPAYMENT_PERIODS, //
}
private final Action action;
@@ -42,22 +42,37 @@ public class EmiChangeOperation {
private final Money amount;
private final BigDecimal interestRate;
private final int periodsToAdd;
public static EmiChangeOperation disburse(final LocalDate disbursementDueDate, final Money disbursedAmount) {
return new EmiChangeOperation(EmiChangeOperation.Action.DISBURSEMENT, disbursementDueDate, disbursedAmount, null);
return new EmiChangeOperation(EmiChangeOperation.Action.DISBURSEMENT, disbursementDueDate, disbursedAmount, null, 0);
}
public static EmiChangeOperation changeInterestRate(final LocalDate newInterestSubmittedOnDate, final BigDecimal newInterestRate) {
return new EmiChangeOperation(EmiChangeOperation.Action.INTEREST_RATE_CHANGE, newInterestSubmittedOnDate, null, newInterestRate);
return new EmiChangeOperation(EmiChangeOperation.Action.INTEREST_RATE_CHANGE, newInterestSubmittedOnDate, null, newInterestRate, 0);
}
public static EmiChangeOperation capitalizedIncome(final LocalDate transactionDueDate, final Money transactionAmount) {
return new EmiChangeOperation(Action.CAPITALIZED_INCOME, transactionDueDate, transactionAmount, null);
return new EmiChangeOperation(Action.CAPITALIZED_INCOME, transactionDueDate, transactionAmount, null, 0);
}
public static EmiChangeOperation addRepaymentPeriods(final LocalDate transactionDueDate, final Money transactionAmount,
final int numPeriods) {
return new EmiChangeOperation(Action.ADD_REPAYMENT_PERIODS, transactionDueDate, transactionAmount, null, numPeriods);
}
public EmiChangeOperation withZeroAmount() {
if (action == Action.DISBURSEMENT || action == Action.CAPITALIZED_INCOME) {
return new EmiChangeOperation(action, submittedOnDate, amount.zero(), null);
return new EmiChangeOperation(action, submittedOnDate, amount.zero(), null, 0);
}
return null;
}
public boolean isAddRepaymentPeriods() {
return action == Action.ADD_REPAYMENT_PERIODS;
}
public boolean isInterestRateChange() {
return action == Action.INTEREST_RATE_CHANGE;
}
}
@@ -38,6 +38,7 @@ import java.util.TreeSet;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -430,4 +431,13 @@ public class ProgressiveLoanInterestScheduleModel {
public boolean isCopy() {
return this.modifiers.get(COPY);
}
public Function<Long, LocalDate> resolveRepaymentPEriodLengthGeneratorFunction(LocalDate instance) {
return switch (loanProductRelatedDetail.getRepaymentPeriodFrequencyType()) {
case MONTHS -> instance::plusMonths;
case WEEKS -> instance::plusWeeks;
case DAYS -> instance::plusDays;
default -> throw new UnsupportedOperationException();
};
}
}
@@ -3953,6 +3953,225 @@ class ProgressiveEMICalculatorTest {
}
}
@Nested
public class RescheduleInstallmentExtendRepaymentPeriod {
@BeforeEach
void beforeEach() {
Mockito.when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.DECLINING_BALANCE);
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod()).thenReturn(InterestCalculationPeriodMethod.DAILY);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue());
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(BigDecimal.valueOf(12.0));
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
}
@Test
public void testExtraTermsDecliningBalance() {
final LocalDate disbursementDate = LocalDate.of(2024, 6, 1);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000));
checkPeriod(interestSchedule, 0, 340.02, 10.00, 330.02, 669.98, false); //
checkPeriod(interestSchedule, 1, 340.02, 6.70, 333.32, 336.66, false); //
checkPeriod(interestSchedule, 2, 340.03, 3.37, 336.66, 0.00, false); //
// add extra repayment period 1
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 7, 2), 1);
checkPeriod(interestSchedule, 0, 340.02, 10.00, 330.02, 669.98, false); //
checkPeriod(interestSchedule, 1, 227.81, 6.70, 221.11, 448.87, false); //
checkPeriod(interestSchedule, 2, 227.81, 4.49, 223.32, 225.55, false); //
checkPeriod(interestSchedule, 3, 227.81, 2.26, 225.55, 0.00, false); //
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 8, 2), 2);
checkPeriod(interestSchedule, 0, 340.02, 10.00, 330.02, 669.98, false); //
checkPeriod(interestSchedule, 1, 227.81, 6.70, 221.11, 448.87, false); //
checkPeriod(interestSchedule, 2, 115.04, 4.49, 110.55, 338.32, false); //
checkPeriod(interestSchedule, 3, 115.04, 3.38, 111.66, 226.66, false); //
checkPeriod(interestSchedule, 4, 115.04, 2.27, 112.77, 113.89, false); //
checkPeriod(interestSchedule, 5, 115.03, 1.14, 113.89, 0.00, false); //
}
@Test
public void testExtraTermsFlat() {
final LocalDate disbursementDate = LocalDate.of(2024, 6, 1);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
Mockito.when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.FLAT);
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod())
.thenReturn(InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000));
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 343.33, 10.00, 333.33, 333.34, false); //
checkPeriod(interestSchedule, 2, 343.34, 10.00, 333.34, 0.00, false); //
// add extra repayment period 1
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 7, 2), 1);
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 232.22, 10.00, 222.22, 444.45, false); //
checkPeriod(interestSchedule, 2, 232.22, 10.00, 222.22, 222.23, false); //
checkPeriod(interestSchedule, 3, 232.23, 10.00, 222.23, 0.00, false); //
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 8, 2), 2);
checkPeriod(interestSchedule, 0, 343.33, 10.00, 333.33, 666.67, false); //
checkPeriod(interestSchedule, 1, 232.22, 10.00, 222.22, 444.45, false); //
checkPeriod(interestSchedule, 2, 121.11, 10.00, 111.11, 333.34, false); //
checkPeriod(interestSchedule, 3, 121.11, 10.00, 111.11, 222.23, false); //
checkPeriod(interestSchedule, 4, 121.11, 10.00, 111.11, 111.12, false); //
checkPeriod(interestSchedule, 5, 121.12, 10.00, 111.12, 0.00, false); //
}
@Test
public void testExtraTermsDecliningBalanceWithFullyRepaidPeriods() {
final LocalDate disbursementDate = LocalDate.of(2024, 6, 1);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(4);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000));
checkPeriod(interestSchedule, 0, 256.28, 10.00, 246.28, 753.72, false); //
checkPeriod(interestSchedule, 1, 256.28, 7.54, 248.74, 504.98, false); //
checkPeriod(interestSchedule, 2, 256.28, 5.05, 251.23, 253.75, false); //
checkPeriod(interestSchedule, 3, 256.29, 2.54, 253.75, 0.00, false); //
// simulate merchant issued refund allocated on last installment ( fully repaid ) on first date
emiCalculator.payPrincipal(interestSchedule, LocalDate.of(2024, 10, 1), LocalDate.of(2024, 6, 1),
Money.of(currency, BigDecimal.valueOf(256.29D)));
// simulate early repayment on day 0
emiCalculator.payPrincipal(interestSchedule, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 6, 1),
Money.of(currency, BigDecimal.valueOf(256.28D)));
// verify 1st and 4th installments are fully repaid
checkPeriod(interestSchedule, 0, 256.28, 0.00, 256.28, 487.43, true); //
checkPeriod(interestSchedule, 1, 256.28, 9.74, 246.54, 240.89, false); //
checkPeriod(interestSchedule, 2, 243.30, 2.41, 240.89, 0.00, false); //
checkPeriod(interestSchedule, 3, 256.29, 0.00, 256.29, 0.00, true); //
// add extra repayment period 1
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 6, 15), 1);
// verify 1st and 4th installments are not modified, additional principal paid are substract from last
// period
checkPeriod(interestSchedule, 0, 256.28, 0.00, 256.28, 487.43, true); //
checkPeriod(interestSchedule, 1, 206.04, 9.74, 196.30, 291.13, false); //
checkPeriod(interestSchedule, 2, 206.04, 2.91, 203.13, 88.00, false); //
checkPeriod(interestSchedule, 3, 256.29, 0.00, 256.29, 88.00, true); //
checkPeriod(interestSchedule, 4, 89.76, 1.76, 88.00, 0.00, false); //
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 6, 15), 2);
// verify 1st and 4th installments are not modified, additional principal paid are substract from last
// periods
checkPeriod(interestSchedule, 0, 256.28, 0.00, 256.28, 487.43, true); //
checkPeriod(interestSchedule, 1, 126.91, 9.74, 117.17, 370.26, false); //
checkPeriod(interestSchedule, 2, 126.91, 3.70, 123.21, 247.05, false); //
checkPeriod(interestSchedule, 3, 256.29, 0.00, 256.29, 247.05, true); //
checkPeriod(interestSchedule, 4, 126.91, 4.94, 121.97, 125.08, false); //
checkPeriod(interestSchedule, 5, 126.33, 1.25, 125.08, 0.00, false); //
checkPeriod(interestSchedule, 6, 0.00, 0.00, 0.00, 0.00, true); //
}
@Test
public void testExtraTermsFlatWithFullyRepaidPeriods() {
final LocalDate disbursementDate = LocalDate.of(2024, 6, 1);
Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(4);
Mockito.when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.FLAT);
Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod())
.thenReturn(InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD);
Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate);
final Integer installmentAmountInMultiplesOf = null;
final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(interestSchedule, disbursementDate, toMoney(1000));
checkPeriod(interestSchedule, 0, 260.00, 10.00, 250.00, 750.00, false); //
checkPeriod(interestSchedule, 1, 260.00, 10.00, 250.00, 500.00, false); //
checkPeriod(interestSchedule, 2, 260.00, 10.00, 250.00, 250.00, false); //
checkPeriod(interestSchedule, 3, 260.00, 10.00, 250.00, 0.00, false); //
// simulate merchant issued refund allocated on last installment ( fully repaid ) on first date
emiCalculator.payPrincipal(interestSchedule, LocalDate.of(2024, 10, 1), LocalDate.of(2024, 6, 1),
Money.of(currency, BigDecimal.valueOf(250.0D)));
emiCalculator.payInterest(interestSchedule, LocalDate.of(2024, 10, 1), LocalDate.of(2024, 6, 1),
Money.of(currency, BigDecimal.valueOf(10.0D)));
checkPeriod(interestSchedule, 0, 260.00, 10.00, 250.00, 500.00, false); //
checkPeriod(interestSchedule, 1, 260.00, 10.00, 250.00, 250.00, false); //
checkPeriod(interestSchedule, 2, 260.00, 10.00, 250.00, 0.00, false); //
checkPeriod(interestSchedule, 3, 260.00, 10.00, 250.00, 0.00, true); //
// simulate early repayment on day 0
emiCalculator.payPrincipal(interestSchedule, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 6, 1),
Money.of(currency, BigDecimal.valueOf(250.0D)));
emiCalculator.payInterest(interestSchedule, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 6, 1),
Money.of(currency, BigDecimal.valueOf(10.0D)));
// verify 1st and 4th installments are fully repaid
checkPeriod(interestSchedule, 0, 260.00, 10.00, 250.00, 500.00, true); //
checkPeriod(interestSchedule, 1, 260.00, 10.00, 250.00, 250.00, false); //
checkPeriod(interestSchedule, 2, 260.00, 10.00, 250.00, 0.00, false); //
checkPeriod(interestSchedule, 3, 260.00, 10.00, 250.00, 0.00, true); //
// add extra repayment period 1
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 6, 15), 1);
// verify 1st and 4th installments are not modified, additional principal paid are substract from last
// period
checkPeriod(interestSchedule, 0, 260.00, 10.00, 250.00, 500.00, true); //
checkPeriod(interestSchedule, 1, 210.00, 10.00, 200.00, 300.00, false); //
checkPeriod(interestSchedule, 2, 210.00, 10.00, 200.00, 100.00, false); //
checkPeriod(interestSchedule, 3, 260.00, 10.00, 250.00, 100.00, true); //
checkPeriod(interestSchedule, 4, 110.00, 10.00, 100.00, 0.00, false); //
// add extra repayment period 2
emiCalculator.addRepaymentPeriods(interestSchedule, LocalDate.of(2024, 6, 15), 2);
// verify 1st and 4th installments are not modified, additional principal paid are substract from last
// periods
checkPeriod(interestSchedule, 0, 260.00, 10.00, 250.00, 500.00, true); //
checkPeriod(interestSchedule, 1, 152.86, 10.00, 142.86, 357.14, false); //
checkPeriod(interestSchedule, 2, 152.86, 10.00, 142.86, 214.28, false); //
checkPeriod(interestSchedule, 3, 260.00, 10.00, 250.00, 214.28, true); //
checkPeriod(interestSchedule, 4, 152.86, 10.00, 142.86, 71.42, false); //
checkPeriod(interestSchedule, 5, 81.42, 10.00, 71.42, 0.00, false); //
checkPeriod(interestSchedule, 6, 10.00, 10.00, 0.00, 0.00, false); //
}
}
// utilities
private List<LoanScheduleModelRepaymentPeriod> generateExpectedRepaymentPeriods(LocalDate disbursementDate) {
return switch (loanProductRelatedDetail.getRepaymentPeriodFrequencyType()) {