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 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") // + ); + }); + } +}