FINERACT-2181: Introduce Loop guard to avoid endless loops

This commit is contained in:
Adam Saghy
2025-04-10 10:58:44 +02:00
parent 7f9a812048
commit 64b453e9ab
4 changed files with 767 additions and 409 deletions
@@ -0,0 +1,21 @@
/**
* 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.util;
public interface LoopContext {}
@@ -0,0 +1,56 @@
/**
* 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.util;
import java.util.function.Predicate;
/**
* Loop Guard is a utility solution to avoid endless loops
*
* Example: LoopGuard.runSafeDoWhileLoop(500, () -> { // loop body }, () -> conditions() );
*/
public final class LoopGuard {
private LoopGuard() {}
public interface LoopBody<T extends LoopContext> {
void execute(T context);
}
public static <T extends LoopContext> void runSafeDoWhileLoop(int maxIterations, T context, Predicate<T> condition, LoopBody<T> body) {
int count = 0;
do {
if (++count > maxIterations) {
throw new IllegalStateException("Loop exceeded " + maxIterations + " iterations. Possible infinite loop.");
}
body.execute(context);
} while (condition.test(context));
}
public static <T extends LoopContext> void runSafeWhileLoop(int maxIterations, T context, Predicate<T> condition, LoopBody<T> body) {
int count = 0;
while (condition.test(context)) {
if (++count > maxIterations) {
throw new IllegalStateException("Loop exceeded " + maxIterations + " iterations. Possible infinite loop.");
}
body.execute(context);
}
}
}
@@ -0,0 +1,134 @@
/**
* 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.util;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
public class LoopGuardTest {
static class TestContext implements LoopContext {
int iteration;
}
@ParameterizedTest
// target value, max iterations
@CsvSource({ "3, 5", //
"5, 6", //
"2, 10", //
"2, 2", //
})
void testSafeDoWhileLoopExecutesCorrectly(int targetValue, int maxIterations) {
TestContext context = new TestContext();
Predicate<TestContext> condition = ctx -> ctx.iteration < targetValue;
LoopGuard.LoopBody<TestContext> body = ctx -> ctx.iteration++;
LoopGuard.runSafeDoWhileLoop(maxIterations, context, condition, body);
Assertions.assertEquals(targetValue, context.iteration);
}
@ParameterizedTest
// target value, max iterations
@CsvSource({ "2, 5", //
"4, 5", //
"6, 10", //
"2, 2" //
})
void testSafeWhileLoopExecutesCorrectly(int targetValue, int maxIterations) {
TestContext context = new TestContext();
Predicate<TestContext> condition = ctx -> ctx.iteration < targetValue;
LoopGuard.LoopBody<TestContext> body = ctx -> ctx.iteration++;
LoopGuard.runSafeWhileLoop(maxIterations, context, condition, body);
Assertions.assertEquals(targetValue, context.iteration);
}
@ParameterizedTest
// max iterations
@CsvSource({ "10", //
"5", //
"1", //
"0", //
"-1" //
})
void testSafeDoWhileLoopThrowsOnExceedingMaxIterations(int maxIterations) {
TestContext context = new TestContext();
Predicate<TestContext> condition = ctx -> true; // infinite
LoopGuard.LoopBody<TestContext> body = ctx -> ctx.iteration++;
IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class,
() -> LoopGuard.runSafeDoWhileLoop(maxIterations, context, condition, body));
Assertions.assertTrue(exception.getMessage().contains("Loop exceeded " + maxIterations));
}
@ParameterizedTest
// max iterations
@CsvSource({ "10", //
"3", //
"2" //
})
void testSafeWhileLoopThrowsOnExceedingMaxIterations(int maxIterations) {
TestContext context = new TestContext();
Predicate<TestContext> condition = ctx -> true; // infinite
LoopGuard.LoopBody<TestContext> body = ctx -> ctx.iteration++;
IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class,
() -> LoopGuard.runSafeWhileLoop(maxIterations, context, condition, body));
Assertions.assertTrue(exception.getMessage().contains("Loop exceeded " + maxIterations));
}
@Test
void testSafeDoWhileLoopRunsAtLeastOnce() {
TestContext context = new TestContext();
AtomicInteger counter = new AtomicInteger();
Predicate<TestContext> condition = ctx -> false;
LoopGuard.LoopBody<TestContext> body = ctx -> counter.incrementAndGet();
LoopGuard.runSafeDoWhileLoop(3, context, condition, body);
Assertions.assertEquals(1, counter.get(), "Do-while loop should execute at least once");
}
@Test
void testSafeWhileLoopSkipsIfConditionFalseInitially() {
TestContext context = new TestContext();
AtomicInteger counter = new AtomicInteger();
Predicate<TestContext> condition = ctx -> false;
LoopGuard.LoopBody<TestContext> body = ctx -> counter.incrementAndGet();
LoopGuard.runSafeWhileLoop(3, context, condition, body);
Assertions.assertEquals(0, counter.get(), "While loop should skip execution if condition is false");
}
}
@@ -103,6 +103,8 @@ import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocat
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType;
import org.apache.fineract.util.LoopContext;
import org.apache.fineract.util.LoopGuard;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -858,7 +860,6 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransaction> remainingTransactions = new ArrayList<>(overpaidTransactions);
MonetaryCurrency currency = ctx.getCurrency();
MoneyHolder overpaymentHolder = ctx.getOverpaymentHolder();
Set<LoanCharge> charges = ctx.getCharges();
Money zero = Money.zero(currency);
for (LoanTransaction transaction : overpaidTransactions) {
Money overpayment = transaction.getOverPaymentPortion(currency);
@@ -880,7 +881,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Balances balances = new Balances(zero, zero, zero, zero);
Money unprocessed = processPeriods(processTransaction, processAmount, charges, transactionMappings, balances, ctx);
Money unprocessed = processPeriods(processTransaction, processAmount, transactionMappings, balances, ctx);
processTransaction.setOverPayments(MathUtil.plus(overpayment, unprocessed));
overpaymentHolder.setMoneyObject(MathUtil.plus(ctxOverpayment, unprocessed));
@@ -1139,8 +1140,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Money zero = Money.zero(currency);
Balances balances = new Balances(zero, zero, zero, zero);
LoanPaymentAllocationRule defaultAllocationRule = getDefaultAllocationRule(loanTransaction.getLoan());
Money transactionAmountUnprocessed = processPeriods(loanTransaction, overpayment, defaultAllocationRule, Set.of(),
transactionMappings, balances, transactionCtx);
Money transactionAmountUnprocessed = processPeriods(loanTransaction, overpayment, defaultAllocationRule, transactionMappings,
balances, transactionCtx);
overpaymentHolder.setMoneyObject(transactionAmountUnprocessed);
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings);
@@ -1609,164 +1610,198 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private Money refundTransactionHorizontally(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed,
List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
MonetaryCurrency currency = ctx.getCurrency();
Money zero = Money.zero(currency);
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
Set<LoanCharge> charges = ctx.getCharges();
Money refundedPortion;
outerLoop: do {
LoanRepaymentScheduleInstallment latestPastDueInstallment = getLatestPastDueInstallmentForRefund(loanTransaction, currency,
installments, zero);
LoanRepaymentScheduleInstallment dueInstallment = getDueInstallmentForRefund(loanTransaction, currency, installments, zero);
HorizontalPaymentAllocationContext paymentAllocationContext = new HorizontalPaymentAllocationContext(ctx, loanTransaction,
paymentAllocationTypes, futureInstallmentAllocationRule, transactionMappings, balances);
paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = getFutureInstallmentsForRefund(loanTransaction, currency,
installments, futureInstallmentAllocationRule, zero);
LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
paymentAllocationContext, //
(HorizontalPaymentAllocationContext context) -> !context.isExitCondition()
&& context.getCtx().getInstallments().stream()
.anyMatch(installment -> installment.getTotalPaid(context.getCtx().getCurrency()).isGreaterThanZero())
&& context.getTransactionAmountUnprocessed().isGreaterThanZero(), //
context -> {
LoanRepaymentScheduleInstallment latestPastDueInstallment = getLatestPastDueInstallmentForRefund(
context.getLoanTransaction(), context.getCtx().getCurrency(), context.getCtx().getInstallments());
LoanRepaymentScheduleInstallment dueInstallment = getDueInstallmentForRefund(context.getLoanTransaction(),
context.getCtx().getCurrency(), context.getCtx().getInstallments());
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments);
for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) {
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = getFutureInstallmentsForRefund(
context.getLoanTransaction(), context.getCtx().getCurrency(), context.getCtx().getInstallments(),
context.getFutureInstallmentAllocationRule());
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(context.getCtx().getInstallments());
for (PaymentAllocationType paymentAllocationType : context.getPaymentAllocationTypes()) {
switch (paymentAllocationType.getDueType()) {
case PAST_DUE -> {
if (latestPastDueInstallment != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, latestPastDueInstallment,
firstNormalInstallmentNumber);
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), latestPastDueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, latestPastDueInstallment, currency);
refundedPortion = processPaymentAllocation(paymentAllocationType, latestPastDueInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion);
context.getTransactionMappings(), context.getLoanTransaction(), latestPastDueInstallment,
context.getCtx().getCurrency());
context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, latestPastDueInstallment,
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else {
break outerLoop;
context.setExitCondition(true);
}
}
case DUE -> {
if (dueInstallment != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, dueInstallment,
firstNormalInstallmentNumber);
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
dueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, dueInstallment, currency);
refundedPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges,
balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion);
context.getTransactionMappings(), context.getLoanTransaction(), dueInstallment,
context.getCtx().getCurrency());
context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, dueInstallment,
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else {
break outerLoop;
context.setExitCondition(true);
}
}
case IN_ADVANCE -> {
int numberOfInstallments = inAdvanceInstallments.size();
if (numberOfInstallments > 0) {
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext());
Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments));
Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
MoneyHelper.getMathContext());
Money balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) {
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment,
firstNormalInstallmentNumber);
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), inAdvanceInstallment, firstNormalInstallmentNumber);
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, inAdvanceInstallment, currency);
refundedPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion);
context.getTransactionMappings(), context.getLoanTransaction(), inAdvanceInstallment,
context.getCtx().getCurrency());
context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, inAdvanceInstallment,
context.getLoanTransaction(), evenPortion, loanTransactionToRepaymentScheduleMapping,
inAdvanceInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
}
} else {
break outerLoop;
context.setExitCondition(true);
}
}
}
}
} while (installments.stream().anyMatch(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
&& transactionAmountUnprocessed.isGreaterThanZero());
return transactionAmountUnprocessed;
});
return paymentAllocationContext.getTransactionAmountUnprocessed();
}
private Money refundTransactionVertically(LoanTransaction loanTransaction, TransactionCtx ctx,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Money transactionAmountUnprocessed,
FutureInstallmentAllocationRule futureInstallmentAllocationRule, Balances balances,
PaymentAllocationType paymentAllocationType) {
MonetaryCurrency currency = ctx.getCurrency();
Money zero = Money.zero(currency);
Money refundedPortion = zero;
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
Set<LoanCharge> charges = ctx.getCharges();
LoanRepaymentScheduleInstallment currentInstallment = null;
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments);
do {
switch (paymentAllocationType.getDueType()) {
VerticalPaymentAllocationContext paymentAllocationContext = new VerticalPaymentAllocationContext(ctx, loanTransaction,
futureInstallmentAllocationRule, transactionMappings, balances);
paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
paymentAllocationContext.setPaymentAllocationType(paymentAllocationType);
LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
paymentAllocationContext, //
(VerticalPaymentAllocationContext context) -> context.getInstallment() != null
&& context.getTransactionAmountUnprocessed().isGreaterThanZero()
&& context.getAllocatedAmount().isGreaterThanZero(), //
context -> {
switch (context.getPaymentAllocationType().getDueType()) {
case PAST_DUE -> {
currentInstallment = getLatestPastDueInstallmentForRefund(loanTransaction, currency, installments, zero);
if (currentInstallment != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment,
firstNormalInstallmentNumber);
context.setInstallment(getLatestPastDueInstallmentForRefund(context.getLoanTransaction(),
context.getCtx().getCurrency(), context.getCtx().getInstallments()));
if (context.getInstallment() != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency);
refundedPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion);
context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
context.getCtx().getCurrency());
context.setAllocatedAmount(processPaymentAllocation(context.getPaymentAllocationType(),
context.getInstallment(), context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
}
}
case DUE -> {
currentInstallment = getDueInstallmentForRefund(loanTransaction, currency, installments, zero);
if (currentInstallment != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment,
firstNormalInstallmentNumber);
context.setInstallment(getDueInstallmentForRefund(context.getLoanTransaction(), context.getCtx().getCurrency(),
context.getCtx().getInstallments()));
if (context.getInstallment() != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency);
refundedPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion);
context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
context.getCtx().getCurrency());
context.setAllocatedAmount(processPaymentAllocation(context.getPaymentAllocationType(),
context.getInstallment(), context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
}
}
case IN_ADVANCE -> {
List<LoanRepaymentScheduleInstallment> currentInstallments = getFutureInstallmentsForRefund(loanTransaction, currency,
installments, futureInstallmentAllocationRule, zero);
List<LoanRepaymentScheduleInstallment> currentInstallments = getFutureInstallmentsForRefund(
context.getLoanTransaction(), context.getCtx().getCurrency(), context.getCtx().getInstallments(),
context.getFutureInstallmentAllocationRule());
int numberOfInstallments = currentInstallments.size();
refundedPortion = zero;
context.setAllocatedAmount(Money.zero(context.getCtx().getCurrency()));
if (numberOfInstallments > 0) {
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext());
Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments));
Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
MoneyHelper.getMathContext());
Money balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) {
currentInstallment = internalCurrentInstallment;
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment,
firstNormalInstallmentNumber);
context.setInstallment(internalCurrentInstallment);
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency);
Money internalUnpaidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment,
loanTransaction, evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges,
balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
context.getCtx().getCurrency());
Money internalUnpaidPortion = processPaymentAllocation(context.getPaymentAllocationType(),
context.getInstallment(), context.getLoanTransaction(), evenPortion,
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
if (internalUnpaidPortion.isGreaterThanZero()) {
refundedPortion = internalUnpaidPortion;
context.setAllocatedAmount(internalUnpaidPortion);
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(internalUnpaidPortion);
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(internalUnpaidPortion));
}
} else {
currentInstallment = null;
context.setInstallment(null);
}
}
}
} while (currentInstallment != null && transactionAmountUnprocessed.isGreaterThanZero() && refundedPortion.isGreaterThanZero());
return transactionAmountUnprocessed;
});
return paymentAllocationContext.getTransactionAmountUnprocessed();
}
@Nullable
private static LoanRepaymentScheduleInstallment getDueInstallmentForRefund(LoanTransaction loanTransaction, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Money zero) {
return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
List<LoanRepaymentScheduleInstallment> installments) {
return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(installment -> loanTransaction.isOn(installment.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
}
@Nullable
private static LoanRepaymentScheduleInstallment getLatestPastDueInstallmentForRefund(LoanTransaction loanTransaction,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Money zero) {
return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments) {
return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isAfter(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
}
@@ -1774,28 +1809,28 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
@NotNull
private static List<LoanRepaymentScheduleInstallment> getFutureInstallmentsForRefund(LoanTransaction loanTransaction,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
FutureInstallmentAllocationRule futureInstallmentAllocationRule, Money zero) {
FutureInstallmentAllocationRule futureInstallmentAllocationRule) {
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isBefore(e.getDueDate())).toList();
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
// try to resolve as current installment ( not due )
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isBefore(e.getDueDate())).filter(f -> loanTransaction.isAfter(f.getFromDate())
|| (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1))
.toList();
// if there is no current installment, resolve similar to LAST_INSTALLMENT
if (inAdvanceInstallments.isEmpty()) {
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero))
inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
}
@@ -1807,8 +1842,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Money zero = Money.zero(transactionCtx.getCurrency());
Balances balances = new Balances(zero, zero, zero, zero);
transactionAmountUnprocessed = processPeriods(loanTransaction, transactionAmountUnprocessed, transactionCtx.getCharges(),
transactionMappings, balances, transactionCtx);
transactionAmountUnprocessed = processPeriods(loanTransaction, transactionAmountUnprocessed, transactionMappings, balances,
transactionCtx);
loanTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(),
balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion());
@@ -1817,31 +1852,28 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx);
}
private Money processPeriods(LoanTransaction transaction, Money processAmount, Set<LoanCharge> charges,
private Money processPeriods(LoanTransaction transaction, Money processAmount,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances, TransactionCtx transactionCtx) {
LoanPaymentAllocationRule allocationRule = getAllocationRule(transaction);
return processPeriods(transaction, processAmount, allocationRule, charges, transactionMappings, balances, transactionCtx);
return processPeriods(transaction, processAmount, allocationRule, transactionMappings, balances, transactionCtx);
}
private Money processPeriods(LoanTransaction transaction, Money processAmount, LoanPaymentAllocationRule allocationRule,
Set<LoanCharge> charges, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances,
TransactionCtx transactionCtx) {
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances, TransactionCtx transactionCtx) {
LoanScheduleProcessingType scheduleProcessingType = transaction.getLoan().getLoanProductRelatedDetail()
.getLoanScheduleProcessingType();
if (scheduleProcessingType.isHorizontal()) {
return processPeriodsHorizontally(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, charges,
balances);
return processPeriodsHorizontally(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, balances);
}
if (scheduleProcessingType.isVertical()) {
return processPeriodsVertically(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, charges,
balances);
return processPeriodsVertically(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, balances);
}
return processAmount;
}
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
LinkedHashMap<DueType, List<PaymentAllocationType>> paymentAllocationsMap = paymentAllocationRule.getAllocationTypes().stream()
.collect(Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new,
mapping(Function.identity(), toList())));
@@ -1849,180 +1881,205 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
for (Map.Entry<DueType, List<PaymentAllocationType>> paymentAllocationsEntry : paymentAllocationsMap.entrySet()) {
transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationsEntry.getValue(), paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings,
charges, balances);
balances);
}
return transactionAmountUnprocessed;
}
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, List<PaymentAllocationType> paymentAllocationTypes,
FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed,
List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
if (MathUtil.isEmpty(transactionAmountUnprocessed)) {
return transactionAmountUnprocessed;
}
MonetaryCurrency currency = transactionCtx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = transactionCtx.getInstallments();
Money paidPortion;
boolean exit = false;
Predicate<LoanRepaymentScheduleInstallment> inAdvanceInstallmentsFilteringRules;
HorizontalPaymentAllocationContext paymentAllocationContext = new HorizontalPaymentAllocationContext(ctx, loanTransaction,
paymentAllocationTypes, futureInstallmentAllocationRule, transactionMappings, balances);
paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
boolean interestBearingAndInterestRecalculationEnabled = loanTransaction.getLoan()
.isInterestBearingAndInterestRecalculationEnabled();
boolean isProgressiveCtx = transactionCtx instanceof ProgressiveTransactionCtx;
boolean isProgressiveCtx = ctx instanceof ProgressiveTransactionCtx;
if (isProgressiveCtx && interestBearingAndInterestRecalculationEnabled) {
ProgressiveTransactionCtx ctx = (ProgressiveTransactionCtx) transactionCtx;
ProgressiveTransactionCtx progressiveTransactionCtx = (ProgressiveTransactionCtx) ctx;
// Clear any previously skipped installments before re-evaluating
ctx.getSkipRepaymentScheduleInstallments().clear();
inAdvanceInstallmentsFilteringRules = installment -> loanTransaction.isBefore(installment.getDueDate())
&& (installment.isNotFullyPaidOff()
|| (installment.isDueBalanceZero() && !ctx.getSkipRepaymentScheduleInstallments().contains(installment)));
progressiveTransactionCtx.getSkipRepaymentScheduleInstallments().clear();
paymentAllocationContext
.setInAdvanceInstallmentsFilteringRules(installment -> loanTransaction.isBefore(installment.getDueDate())
&& (installment.isNotFullyPaidOff() || (installment.isDueBalanceZero()
&& !progressiveTransactionCtx.getSkipRepaymentScheduleInstallments().contains(installment))));
} else {
inAdvanceInstallmentsFilteringRules = installment -> loanTransaction.isBefore(installment.getDueDate())
&& installment.isNotFullyPaidOff();
paymentAllocationContext.setInAdvanceInstallmentsFilteringRules(
installment -> loanTransaction.isBefore(installment.getDueDate()) && installment.isNotFullyPaidOff());
}
do {
LoanRepaymentScheduleInstallment oldestPastDueInstallment = installments.stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff).filter(e -> loanTransaction.isAfter(e.getDueDate()))
LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
paymentAllocationContext, //
(HorizontalPaymentAllocationContext context) -> !context.isExitCondition()
&& context.getCtx().getInstallments().stream().anyMatch(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
&& context.getTransactionAmountUnprocessed().isGreaterThanZero(), //
context -> {
LoanRepaymentScheduleInstallment oldestPastDueInstallment = context.getCtx().getInstallments().stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> context.getLoanTransaction().isAfter(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
LoanRepaymentScheduleInstallment dueInstallment = installments.stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff).filter(e -> loanTransaction.isOn(e.getDueDate()))
LoanRepaymentScheduleInstallment dueInstallment = context.getCtx().getInstallments().stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> context.getLoanTransaction().isOn(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
// For having similar logic we are populating installment list even when the future installment
// allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one element.
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> loanTransaction.isBefore(e.getDueDate())) //
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(context.getFutureInstallmentAllocationRule())) {
inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) //
.toList(); //
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> loanTransaction.isBefore(e.getDueDate())) //
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(context.getFutureInstallmentAllocationRule())) {
inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) //
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() //
.toList(); //
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
inAdvanceInstallments = installments.stream().filter(inAdvanceInstallmentsFilteringRules)
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(context.getFutureInstallmentAllocationRule())) {
inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(context.getInAdvanceInstallmentsFilteringRules())
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() //
.toList(); //
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(context.getFutureInstallmentAllocationRule())) {
// try to resolve as current installment ( not due )
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> loanTransaction.isBefore(e.getDueDate())) //
.filter(f -> loanTransaction.isAfter(f.getFromDate())
|| (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1)) //
inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) //
.filter(f -> context.getLoanTransaction().isAfter(f.getFromDate())
|| (context.getLoanTransaction().isOn(f.getFromDate()) && f.getInstallmentNumber() == 1)) //
.toList(); //
// if there is no current installment, resolve similar to LAST_INSTALLMENT
if (inAdvanceInstallments.isEmpty()) {
inAdvanceInstallments = installments.stream().filter(inAdvanceInstallmentsFilteringRules)
inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(context.getInAdvanceInstallmentsFilteringRules())
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() //
.toList(); //
}
}
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments);
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(context.getCtx().getInstallments());
for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) {
for (PaymentAllocationType paymentAllocationType : context.getPaymentAllocationTypes()) {
switch (paymentAllocationType.getDueType()) {
case PAST_DUE -> {
if (oldestPastDueInstallment != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, oldestPastDueInstallment,
firstNormalInstallmentNumber);
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), oldestPastDueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, oldestPastDueInstallment, currency);
Loan loan = loanTransaction.getLoan();
if (transactionCtx instanceof ProgressiveTransactionCtx ctx
&& loan.isInterestBearingAndInterestRecalculationEnabled() && !ctx.isChargedOff()) {
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction,
transactionAmountUnprocessed, balances, paymentAllocationType, oldestPastDueInstallment, ctx,
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges);
context.getTransactionMappings(), context.getLoanTransaction(), oldestPastDueInstallment,
context.getCtx().getCurrency());
Loan loan = context.getLoanTransaction().getLoan();
if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx
&& loan.isInterestBearingAndInterestRecalculationEnabled()
&& !progressiveTransactionCtx.isChargedOff()) {
context.setAllocatedAmount(
handlingPaymentAllocationForInterestBearingProgressiveLoan(context.getLoanTransaction(),
context.getTransactionAmountUnprocessed(), context.getBalances(),
paymentAllocationType, oldestPastDueInstallment, progressiveTransactionCtx,
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges));
} else {
paidPortion = processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment,
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.PAY));
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else {
exit = true;
context.setExitCondition(true);
}
}
case DUE -> {
if (dueInstallment != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, dueInstallment,
firstNormalInstallmentNumber);
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
dueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, dueInstallment, currency);
Loan loan = loanTransaction.getLoan();
if (transactionCtx instanceof ProgressiveTransactionCtx ctx
&& loan.isInterestBearingAndInterestRecalculationEnabled() && !ctx.isChargedOff()) {
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction,
transactionAmountUnprocessed, balances, paymentAllocationType, dueInstallment, ctx,
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges);
context.getTransactionMappings(), context.getLoanTransaction(), dueInstallment,
context.getCtx().getCurrency());
Loan loan = context.getLoanTransaction().getLoan();
if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx
&& loan.isInterestBearingAndInterestRecalculationEnabled()
&& !progressiveTransactionCtx.isChargedOff()) {
context.setAllocatedAmount(handlingPaymentAllocationForInterestBearingProgressiveLoan(
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
context.getBalances(), paymentAllocationType, dueInstallment, progressiveTransactionCtx,
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges));
} else {
paidPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges,
balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, dueInstallment,
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.PAY));
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else {
exit = true;
context.setExitCondition(true);
}
}
case IN_ADVANCE -> {
int numberOfInstallments = inAdvanceInstallments.size();
if (numberOfInstallments > 0) {
// This will be the same amount as transactionAmountUnprocessed in case of the future
// This will be the same amount as transactionAmountUnprocessed in case of the
// future
// installment allocation is NEXT_INSTALLMENT or LAST_INSTALLMENT
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext());
Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
MoneyHelper.getMathContext());
// Adjustment might be needed due to the divide operation and the rounding mode
Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments));
Money balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
if (evenPortion.add(balanceAdjustment).isLessThanZero()) {
// Note: Rounding mode DOWN grants that evenPortion cant pay more than unprocessed
// Note: Rounding mode DOWN grants that evenPortion cant pay more than
// unprocessed
// transaction amount.
evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments,
evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
new MathContext(MoneyHelper.getMathContext().getPrecision(), RoundingMode.DOWN));
balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments));
balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
}
for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) {
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment,
firstNormalInstallmentNumber);
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), inAdvanceInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, inAdvanceInstallment, currency);
context.getTransactionMappings(), context.getLoanTransaction(), inAdvanceInstallment,
context.getCtx().getCurrency());
Loan loan = loanTransaction.getLoan();
Loan loan = context.getLoanTransaction().getLoan();
// Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
if (transactionCtx instanceof ProgressiveTransactionCtx ctx
&& loan.isInterestBearingAndInterestRecalculationEnabled() && !ctx.isChargedOff()) {
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction, evenPortion,
balances, paymentAllocationType, inAdvanceInstallment, ctx,
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges);
if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx
&& loan.isInterestBearingAndInterestRecalculationEnabled()
&& !progressiveTransactionCtx.isChargedOff()) {
context.setAllocatedAmount(handlingPaymentAllocationForInterestBearingProgressiveLoan(
context.getLoanTransaction(), evenPortion, context.getBalances(), paymentAllocationType,
inAdvanceInstallment, progressiveTransactionCtx,
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges));
} else {
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.PAY);
context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, inAdvanceInstallment,
context.getLoanTransaction(), evenPortion, loanTransactionToRepaymentScheduleMapping,
inAdvanceInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.PAY));
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
}
} else {
exit = true;
context.setExitCondition(true);
}
}
}
}
}
// We are allocating till there is no pending installment or there is no more unprocessed transaction amount
// or there is no more outstanding balance of the allocation type
while (!exit && installments.stream().anyMatch(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
&& transactionAmountUnprocessed.isGreaterThanZero());
return transactionAmountUnprocessed;
});
return paymentAllocationContext.getTransactionAmountUnprocessed();
}
private Money handlingPaymentAllocationForInterestBearingProgressiveLoan(LoanTransaction loanTransaction,
@@ -2115,115 +2172,146 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private Money processPeriodsVertically(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed,
LoanPaymentAllocationRule paymentAllocationRule, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges, Balances balances) {
MonetaryCurrency currency = ctx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments);
Balances balances) {
VerticalPaymentAllocationContext paymentAllocationContext = new VerticalPaymentAllocationContext(ctx, loanTransaction,
paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings, balances);
paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
for (PaymentAllocationType paymentAllocationType : paymentAllocationRule.getAllocationTypes()) {
FutureInstallmentAllocationRule futureInstallmentAllocationRule = paymentAllocationRule.getFutureInstallmentAllocationRule();
LoanRepaymentScheduleInstallment currentInstallment = null;
Money paidPortion = Money.zero(currency);
do {
Predicate<LoanRepaymentScheduleInstallment> predicate = getFilterPredicate(paymentAllocationType, currency);
switch (paymentAllocationType.getDueType()) {
paymentAllocationContext.setAllocatedAmount(Money.zero(ctx.getCurrency()));
paymentAllocationContext.setInstallment(null);
paymentAllocationContext.setPaymentAllocationType(paymentAllocationType);
LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
paymentAllocationContext, //
(VerticalPaymentAllocationContext context) -> context.getInstallment() != null
&& context.getTransactionAmountUnprocessed().isGreaterThanZero()
&& context.getAllocatedAmount().isGreaterThanZero(), //
context -> {
Predicate<LoanRepaymentScheduleInstallment> predicate = getFilterPredicate(context.getPaymentAllocationType(),
context.getCtx().getCurrency());
switch (context.getPaymentAllocationType().getDueType()) {
case PAST_DUE -> {
currentInstallment = installments.stream().filter(predicate).filter(e -> loanTransaction.isAfter(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
if (currentInstallment != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment,
firstNormalInstallmentNumber);
context.setInstallment(context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isAfter(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null));
if (context.getInstallment() != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), context.getInstallment(),
context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency);
paidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
context.getCtx().getCurrency());
context.setAllocatedAmount(
processPaymentAllocation(context.getPaymentAllocationType(), context.getInstallment(),
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.PAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
}
}
case DUE -> {
currentInstallment = installments.stream().filter(predicate).filter(e -> loanTransaction.isOn(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
if (currentInstallment != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment,
firstNormalInstallmentNumber);
context.setInstallment(context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isOn(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null));
if (context.getInstallment() != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency);
paidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges,
balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
context.getCtx().getCurrency());
context.setAllocatedAmount(
processPaymentAllocation(context.getPaymentAllocationType(), context.getInstallment(),
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.PAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
}
}
case IN_ADVANCE -> {
// For having similar logic we are populating installment list even when the future installment
// allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one element.
// For having similar logic we are populating installment list even when the future
// installment
// allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one
// element.
List<LoanRepaymentScheduleInstallment> currentInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) {
currentInstallments = installments.stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate())).toList();
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
currentInstallments = installments.stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
currentInstallments = installments.stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
// get current installment where from date < transaction date < to date OR transaction date
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(context.getFutureInstallmentAllocationRule())) {
currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())).toList();
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT
.equals(context.getFutureInstallmentAllocationRule())) {
currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream()
.toList();
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT
.equals(context.getFutureInstallmentAllocationRule())) {
currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream()
.toList();
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT
.equals(context.getFutureInstallmentAllocationRule())) {
// get current installment where from date < transaction date < to date OR
// transaction date
// is on first installment's first day ( from day )
currentInstallments = installments.stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
.filter(f -> loanTransaction.isAfter(f.getFromDate())
|| (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1))
currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
.filter(f -> context.getLoanTransaction().isAfter(f.getFromDate())
|| (context.getLoanTransaction().isOn(f.getFromDate())
&& f.getInstallmentNumber() == 1))
.toList();
// if there is no current in advance installment resolve similar to LAST_INSTALLMENT
if (currentInstallments.isEmpty()) {
currentInstallments = installments.stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate()))
currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream()
.toList();
}
}
int numberOfInstallments = currentInstallments.size();
paidPortion = Money.zero(currency);
context.setAllocatedAmount(Money.zero(context.getCtx().getCurrency()));
if (numberOfInstallments > 0) {
// This will be the same amount as transactionAmountUnprocessed in case of the future
// This will be the same amount as transactionAmountUnprocessed in case of the
// future
// installment allocation is NEXT_INSTALLMENT or LAST_INSTALLMENT
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext());
Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
MoneyHelper.getMathContext());
// Adjustment might be needed due to the divide operation and the rounding mode
Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments));
Money balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) {
currentInstallment = internalCurrentInstallment;
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment,
firstNormalInstallmentNumber);
context.setInstallment(internalCurrentInstallment);
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), context.getInstallment(),
context.getFirstNormalInstallmentNumber());
// Adjust the portion for the last installment
if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency);
Money internalPaidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment,
loanTransaction, evenPortion, loanTransactionToRepaymentScheduleMapping,
inAdvanceInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
// Some extra logic to allocate as much as possible across the installments if the
context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
context.getCtx().getCurrency());
Money internalPaidPortion = processPaymentAllocation(context.getPaymentAllocationType(),
context.getInstallment(), context.getLoanTransaction(), evenPortion,
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.PAY);
// Some extra logic to allocate as much as possible across the installments if
// the
// outstanding balances are different
if (internalPaidPortion.isGreaterThanZero()) {
paidPortion = internalPaidPortion;
context.setAllocatedAmount(internalPaidPortion);
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(internalPaidPortion);
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(internalPaidPortion));
}
} else {
currentInstallment = null;
context.setInstallment(null);
}
}
}
});
}
// We are allocating till there is no pending installment or there is no more unprocessed transaction amount
// or there is no more outstanding balance of the allocation type
while (currentInstallment != null && transactionAmountUnprocessed.isGreaterThanZero() && paidPortion.isGreaterThanZero());
}
return transactionAmountUnprocessed;
return paymentAllocationContext.getTransactionAmountUnprocessed();
}
private Predicate<LoanRepaymentScheduleInstallment> getFilterPredicate(PaymentAllocationType paymentAllocationType,
@@ -2454,4 +2542,63 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
ctx.getChangedTransactionDetail().addNewTransactionChangeBeforeExistingOne(new TransactionChangeData(null, newAccrualTransaction),
chargeOffTransaction);
}
@Getter
@Setter
private static class VerticalPaymentAllocationContext implements LoopContext {
private final TransactionCtx ctx;
private final LoanTransaction loanTransaction;
private final FutureInstallmentAllocationRule futureInstallmentAllocationRule;
private final List<LoanTransactionToRepaymentScheduleMapping> transactionMappings;
private final Balances balances;
private final int firstNormalInstallmentNumber;
private LoanRepaymentScheduleInstallment installment;
private Money transactionAmountUnprocessed;
private Money allocatedAmount;
private PaymentAllocationType paymentAllocationType;
VerticalPaymentAllocationContext(TransactionCtx ctx, LoanTransaction loanTransaction,
FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
this.ctx = ctx;
this.loanTransaction = loanTransaction;
this.futureInstallmentAllocationRule = futureInstallmentAllocationRule;
this.transactionMappings = transactionMappings;
this.balances = balances;
firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(getCtx().getInstallments());
}
}
@Getter
@Setter
private static class HorizontalPaymentAllocationContext implements LoopContext {
private final TransactionCtx ctx;
private final LoanTransaction loanTransaction;
private final List<PaymentAllocationType> paymentAllocationTypes;
private final FutureInstallmentAllocationRule futureInstallmentAllocationRule;
private final List<LoanTransactionToRepaymentScheduleMapping> transactionMappings;
private final Balances balances;
private final int firstNormalInstallmentNumber;
private LoanRepaymentScheduleInstallment installment;
private Money transactionAmountUnprocessed;
private Money allocatedAmount;
private boolean exitCondition;
private Predicate<LoanRepaymentScheduleInstallment> inAdvanceInstallmentsFilteringRules;
HorizontalPaymentAllocationContext(TransactionCtx ctx, LoanTransaction loanTransaction,
List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
this.ctx = ctx;
this.loanTransaction = loanTransaction;
this.paymentAllocationTypes = paymentAllocationTypes;
this.futureInstallmentAllocationRule = futureInstallmentAllocationRule;
this.transactionMappings = transactionMappings;
this.balances = balances;
firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(getCtx().getInstallments());
}
}
}