This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit b546a43ce9736cbaeba1348fcdda6ae84f0e4059 Author: Soma Sörös <[email protected]> AuthorDate: Mon Feb 24 20:28:24 2025 +0100 FINERACT-2179: Introduce Next/Last in future allocation rule for progressive loans --- .../domain/FutureInstallmentAllocationRule.java | 1 + ...dvancedPaymentScheduleTransactionProcessor.java | 39 +++++++ .../integrationtests/BaseLoanIntegrationTest.java | 1 + .../integrationtests/LoanProductTemplateTest.java | 8 +- ...essiveLoanTransactionProcessorNextLastTest.java | 120 +++++++++++++++++++++ .../common/loans/LoanTransactionHelper.java | 19 +++- 6 files changed, 185 insertions(+), 3 deletions(-) diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/FutureInstallmentAllocationRule.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/FutureInstallmentAllocationRule.java index 854ac2b73..806c67478 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/FutureInstallmentAllocationRule.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/FutureInstallmentAllocationRule.java @@ -31,6 +31,7 @@ public enum FutureInstallmentAllocationRule { NEXT_INSTALLMENT("Next installment"), // LAST_INSTALLMENT("Last installment"), // + NEXT_LAST_INSTALLMENT("Next/Last installment"), // REAMORTIZATION("Reamortization"); // private final String humanReadableName; 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 8d1855b7f..53adf3d5b 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 @@ -1764,6 +1764,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep inAdvanceInstallments = installments.stream().filter(installment -> installment.getTotalPaid(currency).isGreaterThan(zero)) .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)) + .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)) + .filter(e -> loanTransaction.isBefore(e.getDueDate())) + .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); + } } return inAdvanceInstallments; } @@ -1853,6 +1865,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep inAdvanceInstallments = installments.stream().filter(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) .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(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(LoanRepaymentScheduleInstallment::isNotFullyPaidOff) + .filter(e -> loanTransaction.isBefore(e.getDueDate())) + .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).stream().toList(); + } } int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper.fetchFirstNormalInstallmentNumber(installments); @@ -2089,6 +2113,21 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep 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(); + } } int numberOfInstallments = currentInstallments.size(); paidPortion = Money.zero(currency); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index f1d3f15fc..414180cc4 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -1427,6 +1427,7 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { public static final String LAST_INSTALLMENT = "LAST_INSTALLMENT"; public static final String NEXT_INSTALLMENT = "NEXT_INSTALLMENT"; + public static final String NEXT_LAST_INSTALLMENT = "NEXT_LAST_INSTALLMENT"; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTemplateTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTemplateTest.java index 269244416..b159809f0 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTemplateTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTemplateTest.java @@ -93,9 +93,13 @@ public class LoanProductTemplateTest { loanProductsTemplateResponse.getAdvancedPaymentAllocationFutureInstallmentAllocationRules().get(1).getCode()); assertEquals("Last installment", loanProductsTemplateResponse.getAdvancedPaymentAllocationFutureInstallmentAllocationRules().get(1).getValue()); - assertEquals("REAMORTIZATION", + assertEquals("NEXT_LAST_INSTALLMENT", loanProductsTemplateResponse.getAdvancedPaymentAllocationFutureInstallmentAllocationRules().get(2).getCode()); - assertEquals("Reamortization", + assertEquals("Next/Last installment", loanProductsTemplateResponse.getAdvancedPaymentAllocationFutureInstallmentAllocationRules().get(2).getValue()); + assertEquals("REAMORTIZATION", + loanProductsTemplateResponse.getAdvancedPaymentAllocationFutureInstallmentAllocationRules().get(3).getCode()); + assertEquals("Reamortization", + loanProductsTemplateResponse.getAdvancedPaymentAllocationFutureInstallmentAllocationRules().get(3).getValue()); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanTransactionProcessorNextLastTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanTransactionProcessorNextLastTest.java new file mode 100644 index 000000000..73edb99ab --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ProgressiveLoanTransactionProcessorNextLastTest.java @@ -0,0 +1,120 @@ +/** + * 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.integrationtests; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.junit.jupiter.api.Test; + +public class ProgressiveLoanTransactionProcessorNextLastTest extends BaseLoanIntegrationTest { + + private final Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + @Test + public void testPartialEarlyRepaymentWithNextLast() { + AtomicReference<Long> loanIdRef = new AtomicReference<>(); + runAt("1 January 2024", () -> { + Long progressiveLoanInterestRecalculationNextLastId = loanProductHelper + .createLoanProduct(create4IProgressive().isInterestRecalculationEnabled(true).loanScheduleProcessingType("HORIZONTAL") + .paymentAllocation( + List.of(createPaymentAllocation("DEFAULT", FuturePaymentAllocationRule.NEXT_LAST_INSTALLMENT)))) + .getResourceId(); + Long loanId = applyAndApproveProgressiveLoan(clientId, progressiveLoanInterestRecalculationNextLastId, "1 January 2024", 100.0, + 65.7, 6, null); + loanIdRef.set(loanId); + + loanTransactionHelper.disburseLoan(loanId, "1 January 2024", 100.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.52, 5.48, 20.0, false, "01 February 2024"), // + installment(15.32, 4.68, 20.0, false, "01 March 2024"), // + installment(16.16, 3.84, 20.0, false, "01 April 2024"), // + installment(17.04, 2.96, 20.0, false, "01 May 2024"), // + installment(17.98, 2.02, 20.0, false, "01 June 2024"), // + installment(18.98, 1.04, 20.02, false, "01 July 2024")); + + // should pay to first installment - edge case coming from implementation + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "1 January 2024", 5.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), // + installment(14.8, 5.2, 15.0, false, "01 February 2024"), // + installment(15.34, 4.66, 20.0, false, "01 March 2024"), // + installment(16.18, 3.82, 20.0, false, "01 April 2024"), // + installment(17.06, 2.94, 20.0, false, "01 May 2024"), // + installment(18.0, 2.0, 20.0, false, "01 June 2024"), // + installment(18.62, 1.02, 19.64, false, "01 July 2024")); + }); + runAt("31 January 2024", () -> { + Long loanId = loanIdRef.get(); + + // test the repayment before the due date. Should go to 1st installment. + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "31 January 2024", 4.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), // + installment(14.81, 5.19, 11.0, false, "01 February 2024"), // + installment(15.34, 4.66, 20.0, false, "01 March 2024"), // + installment(16.18, 3.82, 20.0, false, "01 April 2024"), // + installment(17.06, 2.94, 20.0, false, "01 May 2024"), // + installment(18.0, 2.0, 20.0, false, "01 June 2024"), // + installment(18.61, 1.02, 19.63, false, "01 July 2024")); + + // test the repayment before the due date. Should go to 1st installment, and rest to last installment. + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "31 January 2024", 20.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.97, 5.03, 0.0, true, "01 February 2024"), installment(15.7, 4.3, 20.0, false, "01 March 2024"), + installment(16.7, 3.3, 20.0, false, "01 April 2024"), installment(17.61, 2.39, 20.0, false, "01 May 2024"), + installment(18.58, 1.42, 20.0, false, "01 June 2024"), installment(16.44, 0.41, 7.85, false, "01 July 2024")); + }); + runAt("1 March 2024", () -> { + Long loanId = loanIdRef.get(); + // test repayment on due date. should repay 2nd installment normally and rest should go to last installment. + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "1 March 2024", 26.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.97, 5.03, 0.0, true, "01 February 2024"), installment(15.7, 4.3, 0.0, true, "01 March 2024"), + installment(17.03, 2.97, 20.0, false, "01 April 2024"), installment(17.96, 2.04, 20.0, false, "01 May 2024"), + installment(18.94, 1.06, 20.0, false, "01 June 2024"), installment(15.4, 0.02, 0.42, false, "01 July 2024")); + }); + runAt("2 March 2024", () -> { + Long loanId = loanIdRef.get(); + // verify multiple partial repayment for "current" installment + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "2 March 2024", 7.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.97, 5.03, 0.0, true, "01 February 2024"), installment(15.7, 4.3, 0.0, true, "01 March 2024"), + installment(17.4, 2.6, 13.0, false, "01 April 2024"), installment(17.98, 2.02, 20.0, false, "01 May 2024"), + installment(18.95, 1.04, 19.99, false, "01 June 2024"), installment(15.0, 0.0, 0.0, true, "01 July 2024")); + // verify multiple partial repayment for "current" installment + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "2 March 2024", 7.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.97, 5.03, 0.0, true, "01 February 2024"), installment(15.7, 4.3, 0.0, true, "01 March 2024"), + installment(17.77, 2.23, 6.0, false, "01 April 2024"), installment(18.0, 2.0, 20.0, false, "01 May 2024"), + installment(18.56, 1.02, 19.58, false, "01 June 2024"), installment(15.0, 0.0, 0.0, true, "01 July 2024")); + // verify next then last installment logic. + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "2 March 2024", 22.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.97, 5.03, 0.0, true, "01 February 2024"), installment(15.7, 4.3, 0.0, true, "01 March 2024"), + installment(19.9, 0.1, 0.0, true, "01 April 2024"), installment(18.02, 1.98, 20.0, false, "01 May 2024"), + installment(16.41, 0.02, 0.43, false, "01 June 2024"), installment(15.0, 0.0, 0.0, true, "01 July 2024")); + // verify last installment logic. + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "2 March 2024", 22.0); + verifyRepaymentSchedule(loanId, installment(100.0, null, "01 January 2024"), + installment(14.97, 5.03, 0.0, true, "01 February 2024"), installment(15.7, 4.3, 0.0, true, "01 March 2024"), + installment(19.9, 0.1, 0.0, true, "01 April 2024"), installment(14.43, 0.0, 0.0, true, "01 May 2024"), + installment(20.0, 0.0, 0.0, true, "01 June 2024"), installment(15.0, 0.0, 0.0, true, "01 July 2024")); + }); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index 41fb81bc3..58b4236c5 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -871,12 +871,13 @@ public class LoanTransactionHelper { @Deprecated(forRemoval = true) public PostLoansLoanIdTransactionsResponse makeLoanRepayment(final String repaymentTypeCommand, final String date, final Float amountToBePaid, final Integer loanID) { - log.info("Repayment with amount {} in {} for Loan {}", amountToBePaid, date, loanID); + log.info("{} with amount {} in {} for Loan {}", repaymentTypeCommand, amountToBePaid, date, loanID); return postLoanTransaction(createLoanTransactionURL(repaymentTypeCommand, loanID), getRepaymentBodyAsJSON(date, amountToBePaid)); } public PostLoansLoanIdTransactionsResponse makeLoanRepayment(final Long loanId, final String command, final String date, final Double amountToBePaid) { + log.info("Make loan transaction. Command - {} with amount {} in {} for Loan {}", command, amountToBePaid, date, loanId); return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, new PostLoansLoanIdTransactionsRequest().transactionAmount(amountToBePaid).transactionDate(date).dateFormat("dd MMMM yyyy") .locale("en"), @@ -2792,6 +2793,22 @@ public class LoanTransactionHelper { return Calls.ok(FineractClientHelper.getFineractClient().loans.stateTransitions(loanId, request, "disburse")); } + /** + * Disburse loan on provided date and amount. + * + * @param loanId + * loan Id + * @param date + * formatted to "d MMMM yyyy" + * @param amount + * amount to disburse + * @return Post Loans Loan Id Response + */ + public PostLoansLoanIdResponse disburseLoan(Long loanId, String date, Double amount) { + return disburseLoan(loanId, new PostLoansLoanIdRequest().actualDisbursementDate(date).dateFormat(DATE_FORMAT) + .transactionAmount(BigDecimal.valueOf(amount)).locale("en")); + } + public PostLoansLoanIdResponse disburseToSavingsLoan(String loanExternalId, PostLoansLoanIdRequest request) { return Calls.ok(FineractClientHelper.getFineractClient().loans.stateTransitions1(loanExternalId, request, "disburseToSavings")); }
