This is an automated email from the ASF dual-hosted git repository.
arnold pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 35243ee70 FINERACT-1971: Fix for not using installmentInMultiplesOf
parameter in case of flat interest for loans
35243ee70 is described below
commit 35243ee70960b22901a18d2ac5795be439976e6b
Author: Arnold Galovics <[email protected]>
AuthorDate: Mon Nov 13 12:17:15 2023 +0100
FINERACT-1971: Fix for not using installmentInMultiplesOf parameter in case
of flat interest for loans
---
.../loanschedule/domain/LoanApplicationTerms.java | 20 ++
.../integrationtests/BaseLoanIntegrationTest.java | 118 +++++++++--
.../LoanInstallmentMultiplesOfTest.java | 227 +++++++++++++++++++++
3 files changed, 346 insertions(+), 19 deletions(-)
diff --git
a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java
b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java
index e08ca42d6..52e43ac1e 100644
---
a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java
+++
b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java
@@ -867,6 +867,26 @@ public final class LoanApplicationTerms {
principalPerPeriod =
this.principal.zero().plus(currentPeriodFixedPrincipalAmount);
}
+
+ if (this.installmentAmountInMultiplesOf != null) {
+ Money roundedPrincipalPerPeriod =
Money.roundToMultiplesOf(principalPerPeriod,
this.installmentAmountInMultiplesOf);
+ if (interestForThisInstallment != null) {
+ Money roundedInterestForThisInstallment =
Money.roundToMultiplesOf(interestForThisInstallment,
+ this.installmentAmountInMultiplesOf);
+
+ /*
+ * Thinking is
+ *
+ * principalPerPeriod 416.67 -> 417
interestForThisInstallment 12.50 -> 13
+ *
+ * Sum: 417 + 13 - 12.5 = 417.5 as principal so the total
outstanding amount is in line with the
+ * installmentAmountInMultiplesOf setting
+ */
+ principalPerPeriod =
roundedPrincipalPerPeriod.add(roundedInterestForThisInstallment).minus(interestForThisInstallment);
+ } else {
+ principalPerPeriod = roundedPrincipalPerPeriod;
+ }
+ }
} else {
principalPerPeriod = Money.of(this.getCurrency(),
getFixedEmiAmount()).minus(interestForThisInstallment);
return principalPerPeriod;
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 5fae515d8..5a8bfd240 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
@@ -43,6 +43,7 @@ import lombok.ToString;
import org.apache.fineract.client.models.AllowAttributeOverrides;
import org.apache.fineract.client.models.BusinessDateRequest;
import
org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse;
+import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
@@ -183,6 +184,21 @@ public abstract class BaseLoanIntegrationTest {
.incomeFromChargeOffPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue());
}
+ protected PostLoanProductsRequest
create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct(
+ int interestType, int amortizationType) {
+ return
createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct().multiDisburseLoan(false)//
+ .disallowExpectedDisbursements(false)//
+ .allowApprovedDisbursedAmountsOverApplied(false)//
+ .overAppliedCalculationType(null)//
+ .overAppliedNumber(null)//
+ .principal(1250.0)//
+ .numberOfRepayments(4)//
+ .repaymentEvery(1)//
+
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue())//
+ .interestType(interestType)//
+ .amortizationType(amortizationType);
+ }
+
private static RequestSpecification createRequestSpecification() {
RequestSpecification request = new
RequestSpecBuilder().setContentType(ContentType.JSON).build();
request.header("Authorization", "Basic " +
Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
@@ -250,15 +266,35 @@ public abstract class BaseLoanIntegrationTest {
"Expected installments are not matching with the installments
configured on the loan");
for (int i = 1; i < installments.length; i++) {
+ GetLoansLoanIdRepaymentPeriod period =
loanResponse.getRepaymentSchedule().getPeriods().get(i);
+ Double principalDue = period.getPrincipalDue();
+ Double amount = installments[i].principalAmount;
+
if (installments[i].completed == null) { // this is for the
disbursement
- Assertions.assertEquals(installments[i].amount,
-
loanResponse.getRepaymentSchedule().getPeriods().get(i).getPrincipalLoanBalanceOutstanding());
+ Assertions.assertEquals(amount,
period.getPrincipalLoanBalanceOutstanding(),
+ "%d. installment's principal due is different,
expected: %.2f, actual: %.2f".formatted(i, amount,
+ period.getPrincipalLoanBalanceOutstanding()));
} else {
- Assertions.assertEquals(installments[i].amount,
loanResponse.getRepaymentSchedule().getPeriods().get(i).getPrincipalDue());
+ Assertions.assertEquals(amount, principalDue,
+ "%d. installment's principal due is different,
expected: %.2f, actual: %.2f".formatted(i, amount, principalDue));
+
+ Double interestAmount = installments[i].interestAmount;
+ Double interestDue = period.getInterestDue();
+ if (interestAmount != null) {
+ Assertions.assertEquals(interestAmount, interestDue,
+ "%d. installment's interest due is different,
expected: %.2f, actual: %.2f".formatted(i, interestAmount,
+ interestDue));
+ }
+ Double outstandingAmount =
installments[i].totalOutstandingAmount;
+ Double totalOutstanding =
period.getTotalOutstandingForPeriod();
+ if (outstandingAmount != null) {
+ Assertions.assertEquals(outstandingAmount,
totalOutstanding,
+ "%d. installment's total outstanding is different,
expected: %.2f, actual: %.2f".formatted(i, outstandingAmount,
+ totalOutstanding));
+ }
}
- Assertions.assertEquals(installments[i].completed,
loanResponse.getRepaymentSchedule().getPeriods().get(i).getComplete());
- Assertions.assertEquals(LocalDate.parse(installments[i].dueDate,
dateTimeFormatter),
-
loanResponse.getRepaymentSchedule().getPeriods().get(i).getDueDate());
+ Assertions.assertEquals(installments[i].completed,
period.getComplete());
+ Assertions.assertEquals(LocalDate.parse(installments[i].dueDate,
dateTimeFormatter), period.getDueDate());
}
}
@@ -275,19 +311,29 @@ public abstract class BaseLoanIntegrationTest {
}
}
- protected Long applyAndApproveLoan(Long clientId, Long loanProductId,
String loanDisbursementDate, Double amount,
+ protected PostLoansRequest applyLoanRequest(Long clientId, Long
loanProductId, String loanDisbursementDate, Double amount,
int numberOfRepayments) {
- PostLoansResponse postLoansResponse =
loanTransactionHelper.applyLoan(new PostLoansRequest().clientId(clientId)
-
.productId(loanProductId).expectedDisbursementDate(loanDisbursementDate).dateFormat(DATETIME_PATTERN)
+ return new
PostLoansRequest().clientId(clientId).productId(loanProductId).expectedDisbursementDate(loanDisbursementDate)
+ .dateFormat(DATETIME_PATTERN)
.transactionProcessingStrategyCode(DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY)
.locale("en").submittedOnDate(loanDisbursementDate).amortizationType(1).interestRatePerPeriod(0)
.interestCalculationPeriodType(1).interestType(0).repaymentFrequencyType(0).repaymentEvery(30).repaymentFrequencyType(0)
.numberOfRepayments(numberOfRepayments).loanTermFrequency(numberOfRepayments *
30).loanTermFrequencyType(0)
-
.maxOutstandingLoanBalance(BigDecimal.valueOf(amount)).principal(BigDecimal.valueOf(amount)).loanType("individual"));
+
.maxOutstandingLoanBalance(BigDecimal.valueOf(amount)).principal(BigDecimal.valueOf(amount)).loanType("individual");
+ }
+
+ protected PostLoansLoanIdRequest approveLoanRequest(Double amount) {
+ return new
PostLoansLoanIdRequest().approvedLoanAmount(BigDecimal.valueOf(amount)).dateFormat(DATETIME_PATTERN)
+ .approvedOnDate("01 January 2023").locale("en");
+ }
+
+ protected Long applyAndApproveLoan(Long clientId, Long loanProductId,
String loanDisbursementDate, Double amount,
+ int numberOfRepayments) {
+ PostLoansResponse postLoansResponse = loanTransactionHelper
+ .applyLoan(applyLoanRequest(clientId, loanProductId,
loanDisbursementDate, amount, numberOfRepayments));
PostLoansLoanIdResponse approvedLoanResult =
loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
- new
PostLoansLoanIdRequest().approvedLoanAmount(BigDecimal.valueOf(amount)).dateFormat(DATETIME_PATTERN)
- .approvedOnDate("01 January 2023").locale("en"));
+ approveLoanRequest(amount));
return approvedLoanResult.getLoanId();
}
@@ -302,16 +348,21 @@ public abstract class BaseLoanIntegrationTest {
.transactionDate(date).locale("en").transactionAmount(amount).externalId(firstRepaymentUUID));
}
- protected JournalEntry journalEntry(double amount, Account account, String
type) {
- return new JournalEntry(amount, account, type);
+ protected JournalEntry journalEntry(double principalAmount, Account
account, String type) {
+ return new JournalEntry(principalAmount, account, type);
}
- protected Transaction transaction(double amount, String type, String date)
{
- return new Transaction(amount, type, date);
+ protected Transaction transaction(double principalAmount, String type,
String date) {
+ return new Transaction(principalAmount, type, date);
}
- protected Installment installment(double amount, Boolean completed, String
dueDate) {
- return new Installment(amount, completed, dueDate);
+ protected Installment installment(double principalAmount, Boolean
completed, String dueDate) {
+ return new Installment(principalAmount, null, null, completed,
dueDate);
+ }
+
+ protected Installment installment(double principalAmount, double
interestAmount, double totalOutstandingAmount, Boolean completed,
+ String dueDate) {
+ return new Installment(principalAmount, interestAmount,
totalOutstandingAmount, completed, dueDate);
}
@ToString
@@ -336,8 +387,37 @@ public abstract class BaseLoanIntegrationTest {
@AllArgsConstructor
public static class Installment {
- Double amount;
+ Double principalAmount;
+ Double interestAmount;
+ Double totalOutstandingAmount;
Boolean completed;
String dueDate;
}
+
+ public static class AmortizationType {
+
+ public static final Integer EQUAL_INSTALLMENTS = 1;
+ }
+
+ public static class InterestType {
+
+ public static final Integer DECLINING_BALANCE = 0;
+ public static final Integer FLAT = 1;
+ }
+
+ public static class RepaymentFrequencyType {
+
+ public static final Integer MONTHS = 2;
+ }
+
+ public static class InterestCalculationPeriodType {
+
+ public static final Integer SAME_AS_REPAYMENT_PERIOD = 1;
+ }
+
+ public static class InterestRateFrequencyType {
+
+ public static final Integer MONTHS = 2;
+ public static final Integer YEARS = 3;
+ }
}
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java
new file mode 100644
index 000000000..bfdccced0
--- /dev/null
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInstallmentMultiplesOfTest.java
@@ -0,0 +1,227 @@
+/**
+ * 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 static
org.apache.fineract.integrationtests.BaseLoanIntegrationTest.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD;
+
+import java.math.BigDecimal;
+import java.util.stream.Stream;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.client.models.PostLoanProductsResponse;
+import org.apache.fineract.client.models.PostLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostLoansRequest;
+import org.apache.fineract.client.models.PostLoansResponse;
+import org.apache.fineract.integrationtests.common.ClientHelper;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class LoanInstallmentMultiplesOfTest extends BaseLoanIntegrationTest {
+
+ private final ClientHelper clientHelper = new
ClientHelper(this.requestSpec, this.responseSpec);
+
+ private static Stream<Arguments> interestTypes() {
+ return Stream.of(Arguments.of(Named.of("DECLINING_BALANCE",
InterestType.DECLINING_BALANCE)), //
+ Arguments.of(Named.of("FLAT", InterestType.FLAT)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("interestTypes")
+ public void
test_LoanRepaymentScheduleIsEquallyDistributed_WhenNoInterest_ButInterestTypeIs(int
interestType) {
+ runAt("01 January 2023", () -> {
+ int amortizationType = AmortizationType.EQUAL_INSTALLMENTS;
+
+ // Create Client
+ Long clientId =
clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
+
+ // Create Loan Product
+ PostLoanProductsRequest product =
create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct(
+ interestType, amortizationType);
+
+ PostLoanProductsResponse loanProductResponse =
loanProductHelper.createLoanProduct(product);
+ Long loanProductId = loanProductResponse.getResourceId();
+
+ // Apply and Approve Loan
+ double amount = 1250.0;
+
+ PostLoansRequest applicationRequest = applyLoanRequest(clientId,
loanProductId, "01 January 2023", amount, 4)//
+ .repaymentEvery(1)//
+ .loanTermFrequency(4)//
+ .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)//
+ .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)//
+ .interestType(interestType)//
+ .amortizationType(amortizationType);
+
+ PostLoansResponse postLoansResponse =
loanTransactionHelper.applyLoan(applicationRequest);
+
+ PostLoansLoanIdResponse approvedLoanResult =
loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
+ approveLoanRequest(amount));
+
+ Long loanId = approvedLoanResult.getLoanId();
+
+ // Verify Repayment Schedule
+ verifyRepaymentSchedule(loanId, //
+ installment(0, null, "01 January 2023"), //
+ installment(313.0, false, "01 February 2023"), //
+ installment(313.0, false, "01 March 2023"), //
+ installment(313.0, false, "01 April 2023"), //
+ installment(311.0, false, "01 May 2023") //
+ );
+
+ // disburse Loan
+ disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January
2023");
+
+ // Verify Repayment Schedule
+ verifyRepaymentSchedule(loanId, //
+ installment(0, null, "01 January 2023"), //
+ installment(313.0, false, "01 February 2023"), //
+ installment(313.0, false, "01 March 2023"), //
+ installment(313.0, false, "01 April 2023"), //
+ installment(311.0, false, "01 May 2023") //
+ );
+ });
+ }
+
+ @Test
+ public void
test_LoanRepaymentScheduleIsEquallyDistributed_WhenInterestIsPresent_AndInterestTypeIsFlat()
{
+ runAt("01 January 2023", () -> {
+ int amortizationType = AmortizationType.EQUAL_INSTALLMENTS;
+ int interestType = InterestType.FLAT;
+
+ // Create Client
+ Long clientId =
clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
+
+ // Create Loan Product
+ PostLoanProductsRequest product =
create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct(
+ interestType, amortizationType)//
+ .maxInterestRatePerPeriod(12.0)//
+ .minInterestRatePerPeriod(12.0)//
+ .interestRatePerPeriod(12.0)//
+
.interestRateFrequencyType(InterestRateFrequencyType.YEARS).interestCalculationPeriodType(SAME_AS_REPAYMENT_PERIOD);
+
+ PostLoanProductsResponse loanProductResponse =
loanProductHelper.createLoanProduct(product);
+ Long loanProductId = loanProductResponse.getResourceId();
+
+ // Apply and Approve Loan
+ double amount = 1250.0;
+ int numberOfRepayments = 3;
+
+ PostLoansRequest applicationRequest = applyLoanRequest(clientId,
loanProductId, "01 January 2023", amount, numberOfRepayments)//
+ .repaymentEvery(1)//
+ .loanTermFrequency(numberOfRepayments)//
+ .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)//
+ .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)//
+ .interestType(interestType)//
+ .amortizationType(amortizationType)//
+ .interestCalculationPeriodType(SAME_AS_REPAYMENT_PERIOD)//
+ .interestRatePerPeriod(12);
+
+ PostLoansResponse postLoansResponse =
loanTransactionHelper.applyLoan(applicationRequest);
+
+ PostLoansLoanIdResponse approvedLoanResult =
loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
+ approveLoanRequest(amount));
+
+ Long loanId = approvedLoanResult.getLoanId();
+
+ // Verify Repayment Schedule
+ verifyRepaymentSchedule(loanId, //
+ installment(0, null, "01 January 2023"), //
+ installment(417.5, 12.5, 430.0, false, "01 February
2023"), //
+ installment(417.5, 12.5, 430.0, false, "01 March 2023"), //
+ installment(415.0, 12.5, 427.5, false, "01 April 2023") //
+ );
+
+ // disburse Loan
+ disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January
2023");
+
+ // Verify Repayment Schedule
+ verifyRepaymentSchedule(loanId, //
+ installment(0, null, "01 January 2023"), //
+ installment(417.5, 12.5, 430.0, false, "01 February
2023"), //
+ installment(417.5, 12.5, 430.0, false, "01 March 2023"), //
+ installment(415.0, 12.5, 427.5, false, "01 April 2023") //
+ );
+ });
+ }
+
+ @Test
+ public void
test_LoanRepaymentScheduleIsEquallyDistributed_WhenInterestIsPresent_AndInterestTypeIsFlat_AndMultiplesOfIs20()
{
+ runAt("01 January 2023", () -> {
+ int amortizationType = AmortizationType.EQUAL_INSTALLMENTS;
+ int interestType = InterestType.FLAT;
+
+ // Create Client
+ Long clientId =
clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
+
+ // Create Loan Product
+ PostLoanProductsRequest product =
create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct(
+ interestType, amortizationType)//
+ .maxInterestRatePerPeriod(12.0)//
+ .minInterestRatePerPeriod(12.0)//
+ .interestRatePerPeriod(12.0)//
+
.installmentAmountInMultiplesOf(20).interestRateFrequencyType(InterestRateFrequencyType.YEARS)
+ .interestCalculationPeriodType(SAME_AS_REPAYMENT_PERIOD);
+
+ PostLoanProductsResponse loanProductResponse =
loanProductHelper.createLoanProduct(product);
+ Long loanProductId = loanProductResponse.getResourceId();
+
+ // Apply and Approve Loan
+ double amount = 1250.0;
+ int numberOfRepayments = 3;
+
+ PostLoansRequest applicationRequest = applyLoanRequest(clientId,
loanProductId, "01 January 2023", amount, numberOfRepayments)//
+ .repaymentEvery(1)//
+ .loanTermFrequency(numberOfRepayments)//
+ .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)//
+ .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)//
+ .interestType(interestType)//
+ .amortizationType(amortizationType)//
+ .interestCalculationPeriodType(SAME_AS_REPAYMENT_PERIOD)//
+ .interestRatePerPeriod(12);
+
+ PostLoansResponse postLoansResponse =
loanTransactionHelper.applyLoan(applicationRequest);
+
+ PostLoansLoanIdResponse approvedLoanResult =
loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
+ approveLoanRequest(amount));
+
+ Long loanId = approvedLoanResult.getLoanId();
+
+ // Verify Repayment Schedule
+ verifyRepaymentSchedule(loanId, //
+ installment(0, null, "01 January 2023"), //
+ installment(427.5, 12.5, 440.0, false, "01 February
2023"), //
+ installment(427.5, 12.5, 440.0, false, "01 March 2023"), //
+ installment(395.0, 12.5, 407.5, false, "01 April 2023") //
+ );
+
+ // disburse Loan
+ disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January
2023");
+
+ // Verify Repayment Schedule
+ verifyRepaymentSchedule(loanId, //
+ installment(0, null, "01 January 2023"), //
+ installment(427.5, 12.5, 440.0, false, "01 February
2023"), //
+ installment(427.5, 12.5, 440.0, false, "01 March 2023"), //
+ installment(395.0, 12.5, 407.5, false, "01 April 2023") //
+ );
+ });
+ }
+}