FINERACT-2179: Introduce Next/Last in future allocation rule for progressive loans

This commit is contained in:
Soma Sörös
2025-03-03 16:39:57 +01:00
committed by Adam Saghy
parent f0cc697c67
commit b546a43ce9
6 changed files with 185 additions and 3 deletions
@@ -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;
@@ -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);
@@ -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";
}
@@ -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());
}
}
@@ -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"));
});
}
}
@@ -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"));
}