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.LoanPreCloseInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; 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.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -858,7 +860,6 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransaction> remainingTransactions = new ArrayList<>(overpaidTransactions); List<LoanTransaction> remainingTransactions = new ArrayList<>(overpaidTransactions);
MonetaryCurrency currency = ctx.getCurrency(); MonetaryCurrency currency = ctx.getCurrency();
MoneyHolder overpaymentHolder = ctx.getOverpaymentHolder(); MoneyHolder overpaymentHolder = ctx.getOverpaymentHolder();
Set<LoanCharge> charges = ctx.getCharges();
Money zero = Money.zero(currency); Money zero = Money.zero(currency);
for (LoanTransaction transaction : overpaidTransactions) { for (LoanTransaction transaction : overpaidTransactions) {
Money overpayment = transaction.getOverPaymentPortion(currency); Money overpayment = transaction.getOverPaymentPortion(currency);
@@ -880,7 +881,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>(); List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Balances balances = new Balances(zero, zero, zero, zero); 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)); processTransaction.setOverPayments(MathUtil.plus(overpayment, unprocessed));
overpaymentHolder.setMoneyObject(MathUtil.plus(ctxOverpayment, unprocessed)); overpaymentHolder.setMoneyObject(MathUtil.plus(ctxOverpayment, unprocessed));
@@ -1139,8 +1140,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Money zero = Money.zero(currency); Money zero = Money.zero(currency);
Balances balances = new Balances(zero, zero, zero, zero); Balances balances = new Balances(zero, zero, zero, zero);
LoanPaymentAllocationRule defaultAllocationRule = getDefaultAllocationRule(loanTransaction.getLoan()); LoanPaymentAllocationRule defaultAllocationRule = getDefaultAllocationRule(loanTransaction.getLoan());
Money transactionAmountUnprocessed = processPeriods(loanTransaction, overpayment, defaultAllocationRule, Set.of(), Money transactionAmountUnprocessed = processPeriods(loanTransaction, overpayment, defaultAllocationRule, transactionMappings,
transactionMappings, balances, transactionCtx); balances, transactionCtx);
overpaymentHolder.setMoneyObject(transactionAmountUnprocessed); overpaymentHolder.setMoneyObject(transactionAmountUnprocessed);
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings);
@@ -1609,164 +1610,198 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private Money refundTransactionHorizontally(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed, private Money refundTransactionHorizontally(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed,
List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule, List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) { List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
MonetaryCurrency currency = ctx.getCurrency(); HorizontalPaymentAllocationContext paymentAllocationContext = new HorizontalPaymentAllocationContext(ctx, loanTransaction,
Money zero = Money.zero(currency); paymentAllocationTypes, futureInstallmentAllocationRule, transactionMappings, balances);
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments(); paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
Set<LoanCharge> charges = ctx.getCharges();
Money refundedPortion;
outerLoop: do {
LoanRepaymentScheduleInstallment latestPastDueInstallment = getLatestPastDueInstallmentForRefund(loanTransaction, currency,
installments, zero);
LoanRepaymentScheduleInstallment dueInstallment = getDueInstallmentForRefund(loanTransaction, currency, installments, zero);
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = getFutureInstallmentsForRefund(loanTransaction, currency, LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
installments, futureInstallmentAllocationRule, zero); 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); List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = getFutureInstallmentsForRefund(
for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) { 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()) { switch (paymentAllocationType.getDueType()) {
case PAST_DUE -> { case PAST_DUE -> {
if (latestPastDueInstallment != null) { if (latestPastDueInstallment != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, latestPastDueInstallment, Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(
firstNormalInstallmentNumber); context.getCtx().getCharges(), latestPastDueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, latestPastDueInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), latestPastDueInstallment,
refundedPortion = processPaymentAllocation(paymentAllocationType, latestPastDueInstallment, loanTransaction, context.getCtx().getCurrency());
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, latestPastDueInstallment,
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else { } else {
break outerLoop; context.setExitCondition(true);
} }
} }
case DUE -> { case DUE -> {
if (dueInstallment != null) { if (dueInstallment != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, dueInstallment, Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
firstNormalInstallmentNumber); dueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, dueInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), dueInstallment,
refundedPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction, context.getCtx().getCurrency());
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, dueInstallment,
balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else { } else {
break outerLoop; context.setExitCondition(true);
} }
} }
case IN_ADVANCE -> { case IN_ADVANCE -> {
int numberOfInstallments = inAdvanceInstallments.size(); int numberOfInstallments = inAdvanceInstallments.size();
if (numberOfInstallments > 0) { if (numberOfInstallments > 0) {
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext()); Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); MoneyHelper.getMathContext());
Money balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) {
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment, Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(
firstNormalInstallmentNumber); context.getCtx().getCharges(), inAdvanceInstallment, firstNormalInstallmentNumber);
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment); evenPortion = evenPortion.add(balanceAdjustment);
} }
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, inAdvanceInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), inAdvanceInstallment,
refundedPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, context.getCtx().getCurrency());
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances, context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, inAdvanceInstallment,
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); context.getLoanTransaction(), evenPortion, loanTransactionToRepaymentScheduleMapping,
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); inAdvanceInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} }
} else { } else {
break outerLoop; context.setExitCondition(true);
} }
} }
} }
} }
} while (installments.stream().anyMatch(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) });
&& transactionAmountUnprocessed.isGreaterThanZero()); return paymentAllocationContext.getTransactionAmountUnprocessed();
return transactionAmountUnprocessed;
} }
private Money refundTransactionVertically(LoanTransaction loanTransaction, TransactionCtx ctx, private Money refundTransactionVertically(LoanTransaction loanTransaction, TransactionCtx ctx,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Money transactionAmountUnprocessed,
FutureInstallmentAllocationRule futureInstallmentAllocationRule, Balances balances, FutureInstallmentAllocationRule futureInstallmentAllocationRule, Balances balances,
PaymentAllocationType paymentAllocationType) { PaymentAllocationType paymentAllocationType) {
MonetaryCurrency currency = ctx.getCurrency(); VerticalPaymentAllocationContext paymentAllocationContext = new VerticalPaymentAllocationContext(ctx, loanTransaction,
Money zero = Money.zero(currency); futureInstallmentAllocationRule, transactionMappings, balances);
Money refundedPortion = zero; paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments(); paymentAllocationContext.setPaymentAllocationType(paymentAllocationType);
Set<LoanCharge> charges = ctx.getCharges(); LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
LoanRepaymentScheduleInstallment currentInstallment = null; paymentAllocationContext, //
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments); (VerticalPaymentAllocationContext context) -> context.getInstallment() != null
do { && context.getTransactionAmountUnprocessed().isGreaterThanZero()
switch (paymentAllocationType.getDueType()) { && context.getAllocatedAmount().isGreaterThanZero(), //
context -> {
switch (context.getPaymentAllocationType().getDueType()) {
case PAST_DUE -> { case PAST_DUE -> {
currentInstallment = getLatestPastDueInstallmentForRefund(loanTransaction, currency, installments, zero); context.setInstallment(getLatestPastDueInstallmentForRefund(context.getLoanTransaction(),
if (currentInstallment != null) { context.getCtx().getCurrency(), context.getCtx().getInstallments()));
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, if (context.getInstallment() != null) {
firstNormalInstallmentNumber); Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
refundedPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, context.getCtx().getCurrency());
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges, context.setAllocatedAmount(processPaymentAllocation(context.getPaymentAllocationType(),
balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); context.getInstallment(), context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} }
} }
case DUE -> { case DUE -> {
currentInstallment = getDueInstallmentForRefund(loanTransaction, currency, installments, zero); context.setInstallment(getDueInstallmentForRefund(context.getLoanTransaction(), context.getCtx().getCurrency(),
if (currentInstallment != null) { context.getCtx().getInstallments()));
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, if (context.getInstallment() != null) {
firstNormalInstallmentNumber); Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
refundedPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, context.getCtx().getCurrency());
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, balances, context.setAllocatedAmount(processPaymentAllocation(context.getPaymentAllocationType(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); context.getInstallment(), context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} }
} }
case IN_ADVANCE -> { case IN_ADVANCE -> {
List<LoanRepaymentScheduleInstallment> currentInstallments = getFutureInstallmentsForRefund(loanTransaction, currency, List<LoanRepaymentScheduleInstallment> currentInstallments = getFutureInstallmentsForRefund(
installments, futureInstallmentAllocationRule, zero); context.getLoanTransaction(), context.getCtx().getCurrency(), context.getCtx().getInstallments(),
context.getFutureInstallmentAllocationRule());
int numberOfInstallments = currentInstallments.size(); int numberOfInstallments = currentInstallments.size();
refundedPortion = zero; context.setAllocatedAmount(Money.zero(context.getCtx().getCurrency()));
if (numberOfInstallments > 0) { if (numberOfInstallments > 0) {
Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext()); Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); MoneyHelper.getMathContext());
Money balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) { for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) {
currentInstallment = internalCurrentInstallment; context.setInstallment(internalCurrentInstallment);
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
firstNormalInstallmentNumber); context.getInstallment(), context.getFirstNormalInstallmentNumber());
if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) { if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment); evenPortion = evenPortion.add(balanceAdjustment);
} }
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
Money internalUnpaidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, context.getCtx().getCurrency());
loanTransaction, evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, Money internalUnpaidPortion = processPaymentAllocation(context.getPaymentAllocationType(),
balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); context.getInstallment(), context.getLoanTransaction(), evenPortion,
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.UNPAY);
if (internalUnpaidPortion.isGreaterThanZero()) { if (internalUnpaidPortion.isGreaterThanZero()) {
refundedPortion = internalUnpaidPortion; context.setAllocatedAmount(internalUnpaidPortion);
} }
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(internalUnpaidPortion); context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(internalUnpaidPortion));
} }
} else { } else {
currentInstallment = null; context.setInstallment(null);
} }
} }
} }
} while (currentInstallment != null && transactionAmountUnprocessed.isGreaterThanZero() && refundedPortion.isGreaterThanZero()); });
return transactionAmountUnprocessed; return paymentAllocationContext.getTransactionAmountUnprocessed();
} }
@Nullable @Nullable
private static LoanRepaymentScheduleInstallment getDueInstallmentForRefund(LoanTransaction loanTransaction, MonetaryCurrency currency, private static LoanRepaymentScheduleInstallment getDueInstallmentForRefund(LoanTransaction loanTransaction, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Money zero) { List<LoanRepaymentScheduleInstallment> installments) {
return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(installment -> loanTransaction.isOn(installment.getDueDate())) .filter(installment -> loanTransaction.isOn(installment.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
} }
@Nullable @Nullable
private static LoanRepaymentScheduleInstallment getLatestPastDueInstallmentForRefund(LoanTransaction loanTransaction, private static LoanRepaymentScheduleInstallment getLatestPastDueInstallmentForRefund(LoanTransaction loanTransaction,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Money zero) { MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments) {
return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThanZero())
.filter(e -> loanTransaction.isAfter(e.getDueDate())) .filter(e -> loanTransaction.isAfter(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
} }
@@ -1774,28 +1809,28 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
@NotNull @NotNull
private static List<LoanRepaymentScheduleInstallment> getFutureInstallmentsForRefund(LoanTransaction loanTransaction, private static List<LoanRepaymentScheduleInstallment> getFutureInstallmentsForRefund(LoanTransaction loanTransaction,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
FutureInstallmentAllocationRule futureInstallmentAllocationRule, Money zero) { FutureInstallmentAllocationRule futureInstallmentAllocationRule) {
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>(); List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) { 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(); .filter(e -> loanTransaction.isBefore(e.getDueDate())).toList();
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) { } 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())) .filter(e -> loanTransaction.isBefore(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) { } 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())) .filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) { } else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) {
// try to resolve as current installment ( not due ) // 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()) .filter(e -> loanTransaction.isBefore(e.getDueDate())).filter(f -> loanTransaction.isAfter(f.getFromDate())
|| (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1)) || (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1))
.toList(); .toList();
// if there is no current installment, resolve similar to LAST_INSTALLMENT // if there is no current installment, resolve similar to LAST_INSTALLMENT
if (inAdvanceInstallments.isEmpty()) { 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())) .filter(e -> loanTransaction.isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList();
} }
@@ -1807,8 +1842,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>(); List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Money zero = Money.zero(transactionCtx.getCurrency()); Money zero = Money.zero(transactionCtx.getCurrency());
Balances balances = new Balances(zero, zero, zero, zero); Balances balances = new Balances(zero, zero, zero, zero);
transactionAmountUnprocessed = processPeriods(loanTransaction, transactionAmountUnprocessed, transactionCtx.getCharges(), transactionAmountUnprocessed = processPeriods(loanTransaction, transactionAmountUnprocessed, transactionMappings, balances,
transactionMappings, balances, transactionCtx); transactionCtx);
loanTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(), loanTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(),
balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion()); balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion());
@@ -1817,31 +1852,28 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx); 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) { List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances, TransactionCtx transactionCtx) {
LoanPaymentAllocationRule allocationRule = getAllocationRule(transaction); 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, private Money processPeriods(LoanTransaction transaction, Money processAmount, LoanPaymentAllocationRule allocationRule,
Set<LoanCharge> charges, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances, TransactionCtx transactionCtx) {
TransactionCtx transactionCtx) {
LoanScheduleProcessingType scheduleProcessingType = transaction.getLoan().getLoanProductRelatedDetail() LoanScheduleProcessingType scheduleProcessingType = transaction.getLoan().getLoanProductRelatedDetail()
.getLoanScheduleProcessingType(); .getLoanScheduleProcessingType();
if (scheduleProcessingType.isHorizontal()) { if (scheduleProcessingType.isHorizontal()) {
return processPeriodsHorizontally(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, charges, return processPeriodsHorizontally(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, balances);
balances);
} }
if (scheduleProcessingType.isVertical()) { if (scheduleProcessingType.isVertical()) {
return processPeriodsVertically(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, charges, return processPeriodsVertically(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, balances);
balances);
} }
return processAmount; return processAmount;
} }
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx, private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule, 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() LinkedHashMap<DueType, List<PaymentAllocationType>> paymentAllocationsMap = paymentAllocationRule.getAllocationTypes().stream()
.collect(Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new, .collect(Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new,
mapping(Function.identity(), toList()))); mapping(Function.identity(), toList())));
@@ -1849,180 +1881,205 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
for (Map.Entry<DueType, List<PaymentAllocationType>> paymentAllocationsEntry : paymentAllocationsMap.entrySet()) { for (Map.Entry<DueType, List<PaymentAllocationType>> paymentAllocationsEntry : paymentAllocationsMap.entrySet()) {
transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed, transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationsEntry.getValue(), paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings, paymentAllocationsEntry.getValue(), paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings,
charges, balances); balances);
} }
return transactionAmountUnprocessed; return transactionAmountUnprocessed;
} }
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx, private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed,
Money transactionAmountUnprocessed, List<PaymentAllocationType> paymentAllocationTypes, List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
FutureInstallmentAllocationRule futureInstallmentAllocationRule, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances) {
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
if (MathUtil.isEmpty(transactionAmountUnprocessed)) { if (MathUtil.isEmpty(transactionAmountUnprocessed)) {
return transactionAmountUnprocessed; return transactionAmountUnprocessed;
} }
HorizontalPaymentAllocationContext paymentAllocationContext = new HorizontalPaymentAllocationContext(ctx, loanTransaction,
MonetaryCurrency currency = transactionCtx.getCurrency(); paymentAllocationTypes, futureInstallmentAllocationRule, transactionMappings, balances);
List<LoanRepaymentScheduleInstallment> installments = transactionCtx.getInstallments(); paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
Money paidPortion;
boolean exit = false;
Predicate<LoanRepaymentScheduleInstallment> inAdvanceInstallmentsFilteringRules;
boolean interestBearingAndInterestRecalculationEnabled = loanTransaction.getLoan() boolean interestBearingAndInterestRecalculationEnabled = loanTransaction.getLoan()
.isInterestBearingAndInterestRecalculationEnabled(); .isInterestBearingAndInterestRecalculationEnabled();
boolean isProgressiveCtx = transactionCtx instanceof ProgressiveTransactionCtx; boolean isProgressiveCtx = ctx instanceof ProgressiveTransactionCtx;
if (isProgressiveCtx && interestBearingAndInterestRecalculationEnabled) { if (isProgressiveCtx && interestBearingAndInterestRecalculationEnabled) {
ProgressiveTransactionCtx ctx = (ProgressiveTransactionCtx) transactionCtx; ProgressiveTransactionCtx progressiveTransactionCtx = (ProgressiveTransactionCtx) ctx;
// Clear any previously skipped installments before re-evaluating // Clear any previously skipped installments before re-evaluating
ctx.getSkipRepaymentScheduleInstallments().clear(); progressiveTransactionCtx.getSkipRepaymentScheduleInstallments().clear();
paymentAllocationContext
inAdvanceInstallmentsFilteringRules = installment -> loanTransaction.isBefore(installment.getDueDate()) .setInAdvanceInstallmentsFilteringRules(installment -> loanTransaction.isBefore(installment.getDueDate())
&& (installment.isNotFullyPaidOff() && (installment.isNotFullyPaidOff() || (installment.isDueBalanceZero()
|| (installment.isDueBalanceZero() && !ctx.getSkipRepaymentScheduleInstallments().contains(installment))); && !progressiveTransactionCtx.getSkipRepaymentScheduleInstallments().contains(installment))));
} else { } else {
inAdvanceInstallmentsFilteringRules = installment -> loanTransaction.isBefore(installment.getDueDate()) paymentAllocationContext.setInAdvanceInstallmentsFilteringRules(
&& installment.isNotFullyPaidOff(); installment -> loanTransaction.isBefore(installment.getDueDate()) && installment.isNotFullyPaidOff());
} }
LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
do { paymentAllocationContext, //
LoanRepaymentScheduleInstallment oldestPastDueInstallment = installments.stream() (HorizontalPaymentAllocationContext context) -> !context.isExitCondition()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff).filter(e -> loanTransaction.isAfter(e.getDueDate())) && 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); .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
LoanRepaymentScheduleInstallment dueInstallment = installments.stream() LoanRepaymentScheduleInstallment dueInstallment = context.getCtx().getInstallments().stream()
.filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff).filter(e -> loanTransaction.isOn(e.getDueDate())) .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
.filter(e -> context.getLoanTransaction().isOn(e.getDueDate()))
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null);
// For having similar logic we are populating installment list even when the future installment // 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. // allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one element.
List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>(); List<LoanRepaymentScheduleInstallment> inAdvanceInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) { if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(context.getFutureInstallmentAllocationRule())) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) // inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(e -> loanTransaction.isBefore(e.getDueDate())) // .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) //
.toList(); // .toList(); //
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) { } else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(context.getFutureInstallmentAllocationRule())) {
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) // inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(e -> loanTransaction.isBefore(e.getDueDate())) // .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) //
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() //
.toList(); // .toList(); //
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) { } else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(context.getFutureInstallmentAllocationRule())) {
inAdvanceInstallments = installments.stream().filter(inAdvanceInstallmentsFilteringRules) inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(context.getInAdvanceInstallmentsFilteringRules())
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() //
.toList(); // .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 ) // try to resolve as current installment ( not due )
inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) // inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(e -> loanTransaction.isBefore(e.getDueDate())) // .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) //
.filter(f -> loanTransaction.isAfter(f.getFromDate()) .filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) //
|| (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1)) // .filter(f -> context.getLoanTransaction().isAfter(f.getFromDate())
|| (context.getLoanTransaction().isOn(f.getFromDate()) && f.getInstallmentNumber() == 1)) //
.toList(); // .toList(); //
// if there is no current installment, resolve similar to LAST_INSTALLMENT // if there is no current installment, resolve similar to LAST_INSTALLMENT
if (inAdvanceInstallments.isEmpty()) { if (inAdvanceInstallments.isEmpty()) {
inAdvanceInstallments = installments.stream().filter(inAdvanceInstallmentsFilteringRules) inAdvanceInstallments = context.getCtx().getInstallments().stream()
.filter(context.getInAdvanceInstallmentsFilteringRules())
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() //
.toList(); // .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()) { switch (paymentAllocationType.getDueType()) {
case PAST_DUE -> { case PAST_DUE -> {
if (oldestPastDueInstallment != null) { if (oldestPastDueInstallment != null) {
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, oldestPastDueInstallment, Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(
firstNormalInstallmentNumber); context.getCtx().getCharges(), oldestPastDueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, oldestPastDueInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), oldestPastDueInstallment,
Loan loan = loanTransaction.getLoan(); context.getCtx().getCurrency());
if (transactionCtx instanceof ProgressiveTransactionCtx ctx Loan loan = context.getLoanTransaction().getLoan();
&& loan.isInterestBearingAndInterestRecalculationEnabled() && !ctx.isChargedOff()) { if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction, && loan.isInterestBearingAndInterestRecalculationEnabled()
transactionAmountUnprocessed, balances, paymentAllocationType, oldestPastDueInstallment, ctx, && !progressiveTransactionCtx.isChargedOff()) {
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges); context.setAllocatedAmount(
handlingPaymentAllocationForInterestBearingProgressiveLoan(context.getLoanTransaction(),
context.getTransactionAmountUnprocessed(), context.getBalances(),
paymentAllocationType, oldestPastDueInstallment, progressiveTransactionCtx,
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges));
} else { } else {
paidPortion = processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment, loanTransaction, context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.PAY));
} }
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else { } else {
exit = true; context.setExitCondition(true);
} }
} }
case DUE -> { case DUE -> {
if (dueInstallment != null) { if (dueInstallment != null) {
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, dueInstallment, Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
firstNormalInstallmentNumber); dueInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, dueInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), dueInstallment,
Loan loan = loanTransaction.getLoan(); context.getCtx().getCurrency());
if (transactionCtx instanceof ProgressiveTransactionCtx ctx Loan loan = context.getLoanTransaction().getLoan();
&& loan.isInterestBearingAndInterestRecalculationEnabled() && !ctx.isChargedOff()) { if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction, && loan.isInterestBearingAndInterestRecalculationEnabled()
transactionAmountUnprocessed, balances, paymentAllocationType, dueInstallment, ctx, && !progressiveTransactionCtx.isChargedOff()) {
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges); context.setAllocatedAmount(handlingPaymentAllocationForInterestBearingProgressiveLoan(
context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
context.getBalances(), paymentAllocationType, dueInstallment, progressiveTransactionCtx,
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges));
} else { } else {
paidPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction, context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, dueInstallment,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.PAY));
} }
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} else { } else {
exit = true; context.setExitCondition(true);
} }
} }
case IN_ADVANCE -> { case IN_ADVANCE -> {
int numberOfInstallments = inAdvanceInstallments.size(); int numberOfInstallments = inAdvanceInstallments.size();
if (numberOfInstallments > 0) { 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 // 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 // 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()) { 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. // transaction amount.
evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments,
new MathContext(MoneyHelper.getMathContext().getPrecision(), RoundingMode.DOWN)); new MathContext(MoneyHelper.getMathContext().getPrecision(), RoundingMode.DOWN));
balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); balanceAdjustment = context.getTransactionAmountUnprocessed()
.minus(evenPortion.multipliedBy(numberOfInstallments));
} }
for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) {
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment, Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(
firstNormalInstallmentNumber); context.getCtx().getCharges(), inAdvanceInstallment, firstNormalInstallmentNumber);
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( 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 // Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment); evenPortion = evenPortion.add(balanceAdjustment);
} }
if (transactionCtx instanceof ProgressiveTransactionCtx ctx if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx
&& loan.isInterestBearingAndInterestRecalculationEnabled() && !ctx.isChargedOff()) { && loan.isInterestBearingAndInterestRecalculationEnabled()
paidPortion = handlingPaymentAllocationForInterestBearingProgressiveLoan(loanTransaction, evenPortion, && !progressiveTransactionCtx.isChargedOff()) {
balances, paymentAllocationType, inAdvanceInstallment, ctx, context.setAllocatedAmount(handlingPaymentAllocationForInterestBearingProgressiveLoan(
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges); context.getLoanTransaction(), evenPortion, context.getBalances(), paymentAllocationType,
inAdvanceInstallment, progressiveTransactionCtx,
loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges));
} else { } else {
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, inAdvanceInstallment,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances, context.getLoanTransaction(), evenPortion, loanTransactionToRepaymentScheduleMapping,
LoanRepaymentScheduleInstallment.PaymentAction.PAY); inAdvanceInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.PAY));
} }
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} }
} else { } else {
exit = true; context.setExitCondition(true);
} }
} }
} }
} }
} });
// We are allocating till there is no pending installment or there is no more unprocessed transaction amount return paymentAllocationContext.getTransactionAmountUnprocessed();
// or there is no more outstanding balance of the allocation type
while (!exit && installments.stream().anyMatch(LoanRepaymentScheduleInstallment::isNotFullyPaidOff)
&& transactionAmountUnprocessed.isGreaterThanZero());
return transactionAmountUnprocessed;
} }
private Money handlingPaymentAllocationForInterestBearingProgressiveLoan(LoanTransaction loanTransaction, private Money handlingPaymentAllocationForInterestBearingProgressiveLoan(LoanTransaction loanTransaction,
@@ -2115,115 +2172,146 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private Money processPeriodsVertically(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed, private Money processPeriodsVertically(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed,
LoanPaymentAllocationRule paymentAllocationRule, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, LoanPaymentAllocationRule paymentAllocationRule, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges, Balances balances) { Balances balances) {
MonetaryCurrency currency = ctx.getCurrency(); VerticalPaymentAllocationContext paymentAllocationContext = new VerticalPaymentAllocationContext(ctx, loanTransaction,
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments(); paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings, balances);
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments); paymentAllocationContext.setTransactionAmountUnprocessed(transactionAmountUnprocessed);
for (PaymentAllocationType paymentAllocationType : paymentAllocationRule.getAllocationTypes()) { for (PaymentAllocationType paymentAllocationType : paymentAllocationRule.getAllocationTypes()) {
FutureInstallmentAllocationRule futureInstallmentAllocationRule = paymentAllocationRule.getFutureInstallmentAllocationRule(); paymentAllocationContext.setAllocatedAmount(Money.zero(ctx.getCurrency()));
LoanRepaymentScheduleInstallment currentInstallment = null; paymentAllocationContext.setInstallment(null);
Money paidPortion = Money.zero(currency); paymentAllocationContext.setPaymentAllocationType(paymentAllocationType);
do { LoopGuard.runSafeDoWhileLoop(paymentAllocationContext.getCtx().getInstallments().size() * 100, //
Predicate<LoanRepaymentScheduleInstallment> predicate = getFilterPredicate(paymentAllocationType, currency); paymentAllocationContext, //
switch (paymentAllocationType.getDueType()) { (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 -> { case PAST_DUE -> {
currentInstallment = installments.stream().filter(predicate).filter(e -> loanTransaction.isAfter(e.getDueDate())) context.setInstallment(context.getCtx().getInstallments().stream().filter(predicate)
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); .filter(e -> context.getLoanTransaction().isAfter(e.getDueDate()))
if (currentInstallment != null) { .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null));
Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, if (context.getInstallment() != null) {
firstNormalInstallmentNumber); Set<LoanCharge> oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(
context.getCtx().getCharges(), context.getInstallment(),
context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
paidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, context.getCtx().getCurrency());
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, context.setAllocatedAmount(
oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); processPaymentAllocation(context.getPaymentAllocationType(), context.getInstallment(),
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges,
context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.PAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} }
} }
case DUE -> { case DUE -> {
currentInstallment = installments.stream().filter(predicate).filter(e -> loanTransaction.isOn(e.getDueDate())) context.setInstallment(context.getCtx().getInstallments().stream().filter(predicate)
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); .filter(e -> context.getLoanTransaction().isOn(e.getDueDate()))
if (currentInstallment != null) { .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null));
Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, if (context.getInstallment() != null) {
firstNormalInstallmentNumber); Set<LoanCharge> dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(),
context.getInstallment(), context.getFirstNormalInstallmentNumber());
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
paidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, context.getCtx().getCurrency());
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.setAllocatedAmount(
balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); processPaymentAllocation(context.getPaymentAllocationType(), context.getInstallment(),
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); context.getLoanTransaction(), context.getTransactionAmountUnprocessed(),
loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(),
LoanRepaymentScheduleInstallment.PaymentAction.PAY));
context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount()));
} }
} }
case IN_ADVANCE -> { case IN_ADVANCE -> {
// For having similar logic we are populating installment list even when the future installment // For having similar logic we are populating installment list even when the future
// allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one element. // installment
// allocation rule is NEXT_INSTALLMENT or LAST_INSTALLMENT hence the list has only one
// element.
List<LoanRepaymentScheduleInstallment> currentInstallments = new ArrayList<>(); List<LoanRepaymentScheduleInstallment> currentInstallments = new ArrayList<>();
if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) { if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(context.getFutureInstallmentAllocationRule())) {
currentInstallments = installments.stream().filter(predicate) currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate())).toList(); .filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())).toList();
} else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) { } else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT
currentInstallments = installments.stream().filter(predicate) .equals(context.getFutureInstallmentAllocationRule())) {
.filter(e -> loanTransaction.isBefore(e.getDueDate())) currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); .filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
} else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) { .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream()
currentInstallments = installments.stream().filter(predicate) .toList();
.filter(e -> loanTransaction.isBefore(e.getDueDate())) } else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); .equals(context.getFutureInstallmentAllocationRule())) {
} else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) { currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
// get current installment where from date < transaction date < to date OR transaction date .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 ) // is on first installment's first day ( from day )
currentInstallments = installments.stream().filter(predicate) currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate())) .filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
.filter(f -> loanTransaction.isAfter(f.getFromDate()) .filter(f -> context.getLoanTransaction().isAfter(f.getFromDate())
|| (loanTransaction.isOn(f.getFromDate()) && f.getInstallmentNumber() == 1)) || (context.getLoanTransaction().isOn(f.getFromDate())
&& f.getInstallmentNumber() == 1))
.toList(); .toList();
// if there is no current in advance installment resolve similar to LAST_INSTALLMENT // if there is no current in advance installment resolve similar to LAST_INSTALLMENT
if (currentInstallments.isEmpty()) { if (currentInstallments.isEmpty()) {
currentInstallments = installments.stream().filter(predicate) currentInstallments = context.getCtx().getInstallments().stream().filter(predicate)
.filter(e -> loanTransaction.isBefore(e.getDueDate())) .filter(e -> context.getLoanTransaction().isBefore(e.getDueDate()))
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream()
.toList(); .toList();
} }
} }
int numberOfInstallments = currentInstallments.size(); int numberOfInstallments = currentInstallments.size();
paidPortion = Money.zero(currency); context.setAllocatedAmount(Money.zero(context.getCtx().getCurrency()));
if (numberOfInstallments > 0) { 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 // 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 // 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) { for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) {
currentInstallment = internalCurrentInstallment; context.setInstallment(internalCurrentInstallment);
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(
firstNormalInstallmentNumber); context.getCtx().getCharges(), context.getInstallment(),
context.getFirstNormalInstallmentNumber());
// Adjust the portion for the last installment // Adjust the portion for the last installment
if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) { if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment); evenPortion = evenPortion.add(balanceAdjustment);
} }
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, currentInstallment, currency); context.getTransactionMappings(), context.getLoanTransaction(), context.getInstallment(),
Money internalPaidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, context.getCtx().getCurrency());
loanTransaction, evenPortion, loanTransactionToRepaymentScheduleMapping, Money internalPaidPortion = processPaymentAllocation(context.getPaymentAllocationType(),
inAdvanceInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); context.getInstallment(), context.getLoanTransaction(), evenPortion,
// Some extra logic to allocate as much as possible across the installments if the 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 // outstanding balances are different
if (internalPaidPortion.isGreaterThanZero()) { if (internalPaidPortion.isGreaterThanZero()) {
paidPortion = internalPaidPortion; context.setAllocatedAmount(internalPaidPortion);
} }
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(internalPaidPortion); context.setTransactionAmountUnprocessed(
context.getTransactionAmountUnprocessed().minus(internalPaidPortion));
} }
} else { } else {
currentInstallment = null; context.setInstallment(null);
} }
} }
} }
});
} }
// We are allocating till there is no pending installment or there is no more unprocessed transaction amount return paymentAllocationContext.getTransactionAmountUnprocessed();
// or there is no more outstanding balance of the allocation type
while (currentInstallment != null && transactionAmountUnprocessed.isGreaterThanZero() && paidPortion.isGreaterThanZero());
}
return transactionAmountUnprocessed;
} }
private Predicate<LoanRepaymentScheduleInstallment> getFilterPredicate(PaymentAllocationType paymentAllocationType, private Predicate<LoanRepaymentScheduleInstallment> getFilterPredicate(PaymentAllocationType paymentAllocationType,
@@ -2454,4 +2542,63 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
ctx.getChangedTransactionDetail().addNewTransactionChangeBeforeExistingOne(new TransactionChangeData(null, newAccrualTransaction), ctx.getChangedTransactionDetail().addNewTransactionChangeBeforeExistingOne(new TransactionChangeData(null, newAccrualTransaction),
chargeOffTransaction); 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());
}
}
} }