diff --git a/fineract-core/src/main/java/org/apache/fineract/util/LoopContext.java b/fineract-core/src/main/java/org/apache/fineract/util/LoopContext.java new file mode 100644 index 000000000..4636bd418 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/util/LoopContext.java @@ -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 {} diff --git a/fineract-core/src/main/java/org/apache/fineract/util/LoopGuard.java b/fineract-core/src/main/java/org/apache/fineract/util/LoopGuard.java new file mode 100644 index 000000000..522aa3d1b --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/util/LoopGuard.java @@ -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 { + + void execute(T context); + } + + public static void runSafeDoWhileLoop(int maxIterations, T context, Predicate condition, LoopBody 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 void runSafeWhileLoop(int maxIterations, T context, Predicate condition, LoopBody body) { + int count = 0; + while (condition.test(context)) { + if (++count > maxIterations) { + throw new IllegalStateException("Loop exceeded " + maxIterations + " iterations. Possible infinite loop."); + } + body.execute(context); + } + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/util/LoopGuardTest.java b/fineract-core/src/test/java/org/apache/fineract/util/LoopGuardTest.java new file mode 100644 index 000000000..ab0794652 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/util/LoopGuardTest.java @@ -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 condition = ctx -> ctx.iteration < targetValue; + LoopGuard.LoopBody 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 condition = ctx -> ctx.iteration < targetValue; + LoopGuard.LoopBody 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 condition = ctx -> true; // infinite + LoopGuard.LoopBody 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 condition = ctx -> true; // infinite + LoopGuard.LoopBody 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 condition = ctx -> false; + LoopGuard.LoopBody 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 condition = ctx -> false; + LoopGuard.LoopBody body = ctx -> counter.incrementAndGet(); + + LoopGuard.runSafeWhileLoop(3, context, condition, body); + + Assertions.assertEquals(0, counter.get(), "While loop should skip execution if condition is false"); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 2feff9bd7..c7ca89416 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -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 remainingTransactions = new ArrayList<>(overpaidTransactions); MonetaryCurrency currency = ctx.getCurrency(); MoneyHolder overpaymentHolder = ctx.getOverpaymentHolder(); - Set 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 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 paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule, List transactionMappings, Balances balances) { - MonetaryCurrency currency = ctx.getCurrency(); - Money zero = Money.zero(currency); - List installments = ctx.getInstallments(); - Set 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 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) { - switch (paymentAllocationType.getDueType()) { - case PAST_DUE -> { - if (latestPastDueInstallment != null) { - Set oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, 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); - } else { - break outerLoop; - } - } - case DUE -> { - if (dueInstallment != null) { - Set dueInstallmentCharges = getLoanChargesOfInstallment(charges, 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); - } else { - break outerLoop; - } - } - case IN_ADVANCE -> { - int numberOfInstallments = inAdvanceInstallments.size(); - if (numberOfInstallments > 0) { - Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext()); - Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); - for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { - Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment, - firstNormalInstallmentNumber); - if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { - evenPortion = evenPortion.add(balanceAdjustment); + List 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 oldestPastDueInstallmentCharges = getLoanChargesOfInstallment( + context.getCtx().getCharges(), latestPastDueInstallment, firstNormalInstallmentNumber); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 { + context.setExitCondition(true); + } + } + case DUE -> { + if (dueInstallment != null) { + Set dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(), + dueInstallment, firstNormalInstallmentNumber); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 { + context.setExitCondition(true); + } + } + case IN_ADVANCE -> { + int numberOfInstallments = inAdvanceInstallments.size(); + if (numberOfInstallments > 0) { + Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments, + MoneyHelper.getMathContext()); + Money balanceAdjustment = context.getTransactionAmountUnprocessed() + .minus(evenPortion.multipliedBy(numberOfInstallments)); + for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { + Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment( + context.getCtx().getCharges(), inAdvanceInstallment, firstNormalInstallmentNumber); + if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { + evenPortion = evenPortion.add(balanceAdjustment); + } + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 { + context.setExitCondition(true); } - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, inAdvanceInstallment, currency); - refundedPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, - evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances, - LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); } - } else { - break outerLoop; } } - } - } - } 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 transactionMappings, Money transactionAmountUnprocessed, FutureInstallmentAllocationRule futureInstallmentAllocationRule, Balances balances, PaymentAllocationType paymentAllocationType) { - MonetaryCurrency currency = ctx.getCurrency(); - Money zero = Money.zero(currency); - Money refundedPortion = zero; - List installments = ctx.getInstallments(); - Set charges = ctx.getCharges(); - LoanRepaymentScheduleInstallment currentInstallment = null; - int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments); - do { - switch (paymentAllocationType.getDueType()) { - case PAST_DUE -> { - currentInstallment = getLatestPastDueInstallmentForRefund(loanTransaction, currency, installments, zero); - if (currentInstallment != null) { - Set oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, - firstNormalInstallmentNumber); - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, currentInstallment, currency); - refundedPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, - transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges, - balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); - } - } - case DUE -> { - currentInstallment = getDueInstallmentForRefund(loanTransaction, currency, installments, zero); - if (currentInstallment != null) { - Set dueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, - firstNormalInstallmentNumber); - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, currentInstallment, currency); - refundedPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, - transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, balances, - LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(refundedPortion); - } - } - case IN_ADVANCE -> { - List currentInstallments = getFutureInstallmentsForRefund(loanTransaction, currency, - installments, futureInstallmentAllocationRule, zero); - int numberOfInstallments = currentInstallments.size(); - refundedPortion = zero; - if (numberOfInstallments > 0) { - Money evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, MoneyHelper.getMathContext()); - Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); - for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) { - currentInstallment = internalCurrentInstallment; - Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, - firstNormalInstallmentNumber); - if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) { - evenPortion = evenPortion.add(balanceAdjustment); + 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 -> { + context.setInstallment(getLatestPastDueInstallmentForRefund(context.getLoanTransaction(), + context.getCtx().getCurrency(), context.getCtx().getInstallments())); + if (context.getInstallment() != null) { + Set oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(), + context.getInstallment(), context.getFirstNormalInstallmentNumber()); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 -> { + context.setInstallment(getDueInstallmentForRefund(context.getLoanTransaction(), context.getCtx().getCurrency(), + context.getCtx().getInstallments())); + if (context.getInstallment() != null) { + Set dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(), + context.getInstallment(), context.getFirstNormalInstallmentNumber()); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 currentInstallments = getFutureInstallmentsForRefund( + context.getLoanTransaction(), context.getCtx().getCurrency(), context.getCtx().getInstallments(), + context.getFutureInstallmentAllocationRule()); + int numberOfInstallments = currentInstallments.size(); + context.setAllocatedAmount(Money.zero(context.getCtx().getCurrency())); + if (numberOfInstallments > 0) { + Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments, + MoneyHelper.getMathContext()); + Money balanceAdjustment = context.getTransactionAmountUnprocessed() + .minus(evenPortion.multipliedBy(numberOfInstallments)); + for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) { + context.setInstallment(internalCurrentInstallment); + Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(), + context.getInstallment(), context.getFirstNormalInstallmentNumber()); + if (internalCurrentInstallment.equals(currentInstallments.get(numberOfInstallments - 1))) { + evenPortion = evenPortion.add(balanceAdjustment); + } + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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()) { + context.setAllocatedAmount(internalUnpaidPortion); + } + context.setTransactionAmountUnprocessed( + context.getTransactionAmountUnprocessed().minus(internalUnpaidPortion)); + } + } else { + context.setInstallment(null); } - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, currentInstallment, currency); - Money internalUnpaidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, - loanTransaction, evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, - balances, LoanRepaymentScheduleInstallment.PaymentAction.UNPAY); - if (internalUnpaidPortion.isGreaterThanZero()) { - refundedPortion = internalUnpaidPortion; - } - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(internalUnpaidPortion); } - } else { - currentInstallment = null; } - } - } - } while (currentInstallment != null && transactionAmountUnprocessed.isGreaterThanZero() && refundedPortion.isGreaterThanZero()); - return transactionAmountUnprocessed; + }); + return paymentAllocationContext.getTransactionAmountUnprocessed(); } @Nullable private static LoanRepaymentScheduleInstallment getDueInstallmentForRefund(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, Money zero) { - return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) + List 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 installments, Money zero) { - return installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) + MonetaryCurrency currency, List 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 getFutureInstallmentsForRefund(LoanTransaction loanTransaction, MonetaryCurrency currency, List installments, - FutureInstallmentAllocationRule futureInstallmentAllocationRule, Money zero) { + FutureInstallmentAllocationRule futureInstallmentAllocationRule) { List 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 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 charges, + private Money processPeriods(LoanTransaction transaction, Money processAmount, List 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 charges, List transactionMappings, Balances balances, - TransactionCtx transactionCtx) { + List 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 transactionMappings, Set charges, Balances balances) { + List transactionMappings, Balances balances) { LinkedHashMap> 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> 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 paymentAllocationTypes, - FutureInstallmentAllocationRule futureInstallmentAllocationRule, - List transactionMappings, Set charges, Balances balances) { + private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx ctx, Money transactionAmountUnprocessed, + List paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule, + List transactionMappings, Balances balances) { if (MathUtil.isEmpty(transactionAmountUnprocessed)) { return transactionAmountUnprocessed; } - - MonetaryCurrency currency = transactionCtx.getCurrency(); - List installments = transactionCtx.getInstallments(); - Money paidPortion; - boolean exit = false; - Predicate 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()); } + 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 = context.getCtx().getInstallments().stream() + .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) + .filter(e -> context.getLoanTransaction().isOn(e.getDueDate())) + .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); - do { - LoanRepaymentScheduleInstallment oldestPastDueInstallment = installments.stream() - .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff).filter(e -> loanTransaction.isAfter(e.getDueDate())) - .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); - LoanRepaymentScheduleInstallment dueInstallment = installments.stream() - .filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff).filter(e -> loanTransaction.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 inAdvanceInstallments = new ArrayList<>(); - if (FutureInstallmentAllocationRule.REAMORTIZATION.equals(futureInstallmentAllocationRule)) { - inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) // - .filter(e -> loanTransaction.isBefore(e.getDueDate())) // - .toList(); // - } else if (FutureInstallmentAllocationRule.NEXT_INSTALLMENT.equals(futureInstallmentAllocationRule)) { - inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) // - .filter(e -> loanTransaction.isBefore(e.getDueDate())) // - .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // - .toList(); // - } else if (FutureInstallmentAllocationRule.LAST_INSTALLMENT.equals(futureInstallmentAllocationRule)) { - inAdvanceInstallments = installments.stream().filter(inAdvanceInstallmentsFilteringRules) - .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(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) // - .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(inAdvanceInstallmentsFilteringRules) - .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // - .toList(); // - } - } - - int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments); - - for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) { - switch (paymentAllocationType.getDueType()) { - case PAST_DUE -> { - if (oldestPastDueInstallment != null) { - Set oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, 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); - } else { - paidPortion = processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment, loanTransaction, - transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, - oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); - } - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); - } else { - exit = true; + // 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 inAdvanceInstallments = new ArrayList<>(); + 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(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(context.getFutureInstallmentAllocationRule())) { + inAdvanceInstallments = context.getCtx().getInstallments().stream() + .filter(context.getInAdvanceInstallmentsFilteringRules()) + .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // + .toList(); // + } else if (FutureInstallmentAllocationRule.NEXT_LAST_INSTALLMENT.equals(context.getFutureInstallmentAllocationRule())) { + // try to resolve as current installment ( not due ) + 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 = context.getCtx().getInstallments().stream() + .filter(context.getInAdvanceInstallmentsFilteringRules()) + .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() // + .toList(); // } } - case DUE -> { - if (dueInstallment != null) { - Set dueInstallmentCharges = getLoanChargesOfInstallment(charges, 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); - } else { - paidPortion = processPaymentAllocation(paymentAllocationType, dueInstallment, loanTransaction, - transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, - balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); - } - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); - } else { - exit = true; - } - } - case IN_ADVANCE -> { - int numberOfInstallments = inAdvanceInstallments.size(); - if (numberOfInstallments > 0) { - // 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()); - // Adjustment might be needed due to the divide operation and the rounding mode - Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); - if (evenPortion.add(balanceAdjustment).isLessThanZero()) { - // Note: Rounding mode DOWN grants that evenPortion cant pay more than unprocessed - // transaction amount. - evenPortion = transactionAmountUnprocessed.dividedBy(numberOfInstallments, - new MathContext(MoneyHelper.getMathContext().getPrecision(), RoundingMode.DOWN)); - balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); - } - for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { - Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment, - firstNormalInstallmentNumber); + int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper + .fetchFirstNormalInstallmentNumber(context.getCtx().getInstallments()); - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, inAdvanceInstallment, currency); - - Loan loan = loanTransaction.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); + for (PaymentAllocationType paymentAllocationType : context.getPaymentAllocationTypes()) { + switch (paymentAllocationType.getDueType()) { + case PAST_DUE -> { + if (oldestPastDueInstallment != null) { + Set oldestPastDueInstallmentCharges = getLoanChargesOfInstallment( + context.getCtx().getCharges(), oldestPastDueInstallment, firstNormalInstallmentNumber); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 { + context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, oldestPastDueInstallment, + context.getLoanTransaction(), context.getTransactionAmountUnprocessed(), + loanTransactionToRepaymentScheduleMapping, oldestPastDueInstallmentCharges, + context.getBalances(), LoanRepaymentScheduleInstallment.PaymentAction.PAY)); + } + context.setTransactionAmountUnprocessed( + context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount())); } else { - paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction, - evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances, - LoanRepaymentScheduleInstallment.PaymentAction.PAY); + context.setExitCondition(true); + } + } + case DUE -> { + if (dueInstallment != null) { + Set dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(), + dueInstallment, firstNormalInstallmentNumber); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 { + context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, dueInstallment, + context.getLoanTransaction(), context.getTransactionAmountUnprocessed(), + loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, context.getBalances(), + LoanRepaymentScheduleInstallment.PaymentAction.PAY)); + } + context.setTransactionAmountUnprocessed( + context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount())); + } else { + 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 + // installment allocation is NEXT_INSTALLMENT or LAST_INSTALLMENT + Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments, + MoneyHelper.getMathContext()); + // Adjustment might be needed due to the divide operation and the rounding mode + 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 + // transaction amount. + evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments, + new MathContext(MoneyHelper.getMathContext().getPrecision(), RoundingMode.DOWN)); + balanceAdjustment = context.getTransactionAmountUnprocessed() + .minus(evenPortion.multipliedBy(numberOfInstallments)); + } + + for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) { + Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment( + context.getCtx().getCharges(), inAdvanceInstallment, firstNormalInstallmentNumber); + + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + context.getTransactionMappings(), context.getLoanTransaction(), inAdvanceInstallment, + context.getCtx().getCurrency()); + + Loan loan = context.getLoanTransaction().getLoan(); + // Adjust the portion for the last installment + if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) { + evenPortion = evenPortion.add(balanceAdjustment); + } + if (context.getCtx() instanceof ProgressiveTransactionCtx progressiveTransactionCtx + && loan.isInterestBearingAndInterestRecalculationEnabled() + && !progressiveTransactionCtx.isChargedOff()) { + context.setAllocatedAmount(handlingPaymentAllocationForInterestBearingProgressiveLoan( + context.getLoanTransaction(), evenPortion, context.getBalances(), paymentAllocationType, + inAdvanceInstallment, progressiveTransactionCtx, + loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges)); + } else { + context.setAllocatedAmount(processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, + context.getLoanTransaction(), evenPortion, loanTransactionToRepaymentScheduleMapping, + inAdvanceInstallmentCharges, context.getBalances(), + LoanRepaymentScheduleInstallment.PaymentAction.PAY)); + } + context.setTransactionAmountUnprocessed( + context.getTransactionAmountUnprocessed().minus(context.getAllocatedAmount())); + } + } else { + context.setExitCondition(true); } - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); } - } else { - exit = 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 transactionMappings, - Set charges, Balances balances) { - MonetaryCurrency currency = ctx.getCurrency(); - List 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 predicate = getFilterPredicate(paymentAllocationType, currency); - switch (paymentAllocationType.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 oldestPastDueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, - firstNormalInstallmentNumber); - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, currentInstallment, currency); - paidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, - transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, - oldestPastDueInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); - } - } - case DUE -> { - currentInstallment = installments.stream().filter(predicate).filter(e -> loanTransaction.isOn(e.getDueDate())) - .min(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).orElse(null); - if (currentInstallment != null) { - Set dueInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, - firstNormalInstallmentNumber); - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( - transactionMappings, loanTransaction, currentInstallment, currency); - paidPortion = processPaymentAllocation(paymentAllocationType, currentInstallment, loanTransaction, - transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping, dueInstallmentCharges, - balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY); - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion); - } - } - 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. - List 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 - // 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)) - .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())) - .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() - .toList(); + 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 predicate = getFilterPredicate(context.getPaymentAllocationType(), + context.getCtx().getCurrency()); + switch (context.getPaymentAllocationType().getDueType()) { + case PAST_DUE -> { + 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 oldestPastDueInstallmentCharges = getLoanChargesOfInstallment( + context.getCtx().getCharges(), context.getInstallment(), + context.getFirstNormalInstallmentNumber()); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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 -> { + 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 dueInstallmentCharges = getLoanChargesOfInstallment(context.getCtx().getCharges(), + context.getInstallment(), context.getFirstNormalInstallmentNumber()); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + 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. + List currentInstallments = new ArrayList<>(); + 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 = 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 = context.getCtx().getInstallments().stream().filter(predicate) + .filter(e -> context.getLoanTransaction().isBefore(e.getDueDate())) + .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream() + .toList(); + } + } + int numberOfInstallments = currentInstallments.size(); + context.setAllocatedAmount(Money.zero(context.getCtx().getCurrency())); + if (numberOfInstallments > 0) { + // This will be the same amount as transactionAmountUnprocessed in case of the + // future + // installment allocation is NEXT_INSTALLMENT or LAST_INSTALLMENT + Money evenPortion = context.getTransactionAmountUnprocessed().dividedBy(numberOfInstallments, + MoneyHelper.getMathContext()); + // Adjustment might be needed due to the divide operation and the rounding mode + Money balanceAdjustment = context.getTransactionAmountUnprocessed() + .minus(evenPortion.multipliedBy(numberOfInstallments)); + for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) { + context.setInstallment(internalCurrentInstallment); + Set 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( + 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()) { + context.setAllocatedAmount(internalPaidPortion); + } + context.setTransactionAmountUnprocessed( + context.getTransactionAmountUnprocessed().minus(internalPaidPortion)); + } + } else { + context.setInstallment(null); + } } } - int numberOfInstallments = currentInstallments.size(); - paidPortion = Money.zero(currency); - if (numberOfInstallments > 0) { - // 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()); - // Adjustment might be needed due to the divide operation and the rounding mode - Money balanceAdjustment = transactionAmountUnprocessed.minus(evenPortion.multipliedBy(numberOfInstallments)); - for (LoanRepaymentScheduleInstallment internalCurrentInstallment : currentInstallments) { - currentInstallment = internalCurrentInstallment; - Set inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, currentInstallment, - firstNormalInstallmentNumber); - // 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 - // outstanding balances are different - if (internalPaidPortion.isGreaterThanZero()) { - paidPortion = internalPaidPortion; - } - transactionAmountUnprocessed = transactionAmountUnprocessed.minus(internalPaidPortion); - } - } else { - currentInstallment = 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 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 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 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 paymentAllocationTypes; + private final FutureInstallmentAllocationRule futureInstallmentAllocationRule; + private final List transactionMappings; + private final Balances balances; + private final int firstNormalInstallmentNumber; + private LoanRepaymentScheduleInstallment installment; + private Money transactionAmountUnprocessed; + private Money allocatedAmount; + private boolean exitCondition; + private Predicate inAdvanceInstallmentsFilteringRules; + + HorizontalPaymentAllocationContext(TransactionCtx ctx, LoanTransaction loanTransaction, + List paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule, + List 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()); + } + } }