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
The following commit(s) were added to refs/heads/develop by this push:
new bf55b2c120 FINERACT-2523: Fix inconsistent interest recalculation for
cumulative loans with floating rates
bf55b2c120 is described below
commit bf55b2c120f3fdf4cb06cf88700ccc8c4aeedb45
Author: DeathGun44 <[email protected]>
AuthorDate: Fri Mar 6 22:17:45 2026 +0530
FINERACT-2523: Fix inconsistent interest recalculation for cumulative loans
with floating rates
---
.../loanschedule/domain/LoanApplicationTerms.java | 3 +
.../FloatingRateInterestRecalculationTest.java | 227 +++++++++++++++++++++
2 files changed, 230 insertions(+)
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 6841798a01..8b6a9b4693 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
@@ -1876,6 +1876,9 @@ public final class LoanApplicationTerms {
public void updateAnnualNominalInterestRate(BigDecimal
annualNominalInterestRate) {
if (annualNominalInterestRate != null) {
+ if (this.annualNominalInterestRate == null ||
annualNominalInterestRate.compareTo(this.annualNominalInterestRate) != 0) {
+ this.fixedEmiAmount = null;
+ }
this.annualNominalInterestRate = annualNominalInterestRate;
}
}
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/FloatingRateInterestRecalculationTest.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/FloatingRateInterestRecalculationTest.java
new file mode 100644
index 0000000000..e8475a98db
--- /dev/null
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/FloatingRateInterestRecalculationTest.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.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.restassured.builder.RequestSpecBuilder;
+import io.restassured.builder.ResponseSpecBuilder;
+import io.restassured.http.ContentType;
+import io.restassured.specification.RequestSpecification;
+import io.restassured.specification.ResponseSpecification;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.util.HashMap;
+import java.util.List;
+import org.apache.fineract.client.models.FloatingRatePeriodRequest;
+import org.apache.fineract.client.models.FloatingRateRequest;
+import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
+import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostFloatingRatesResponse;
+import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
+import org.apache.fineract.client.util.Calls;
+import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
+import
org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants;
+import org.apache.fineract.integrationtests.common.BusinessDateHelper;
+import org.apache.fineract.integrationtests.common.ClientHelper;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.apache.fineract.integrationtests.common.accounting.Account;
+import org.apache.fineract.integrationtests.common.accounting.AccountHelper;
+import
org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder;
+import
org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
+import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration test for Inconsistent Interest Recalculation between exact
repayment and over-payment for Cumulative Loan
+ * with Floating Rates.
+ *
+ * @see
org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms#updateAnnualNominalInterestRate
+ */
+public class FloatingRateInterestRecalculationTest extends
BaseLoanIntegrationTest {
+
+ private ResponseSpecification responseSpec;
+ private RequestSpecification requestSpec;
+ private LoanTransactionHelper loanTransactionHelper;
+ private ClientHelper clientHelper;
+ private AccountHelper accountHelper;
+ private final DateTimeFormatter dateFormatter = new
DateTimeFormatterBuilder().appendPattern("dd MMMM yyyy").toFormatter();
+
+ private static final BigDecimal INITIAL_INTEREST_RATE = new
BigDecimal("12");
+ private static final BigDecimal CHANGED_INTEREST_RATE = new
BigDecimal("6");
+
+ @BeforeEach
+ public void setup() {
+ Utils.initializeRESTAssured();
+ this.requestSpec = new
RequestSpecBuilder().setContentType(ContentType.JSON).build();
+ this.requestSpec.header("Authorization", "Basic " +
Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
+ this.responseSpec = new
ResponseSpecBuilder().expectStatusCode(200).build();
+ this.loanTransactionHelper = new
LoanTransactionHelper(this.requestSpec, this.responseSpec);
+ this.clientHelper = new ClientHelper(this.requestSpec,
this.responseSpec);
+ this.accountHelper = new AccountHelper(this.requestSpec,
this.responseSpec);
+
+
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE,
+ new PutGlobalConfigurationsRequest().enabled(true));
+ }
+
+ @AfterEach
+ public void tearDown() {
+
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE,
+ new PutGlobalConfigurationsRequest().enabled(false));
+ }
+
+ @Test
+ public void testExactRepaymentRecalculatesEmiOnFloatingRateChange() {
+ runFloatingRateRecalculationScenario(false);
+ }
+
+ @Test
+ public void testOverPaymentRecalculatesEmiOnFloatingRateChange() {
+ runFloatingRateRecalculationScenario(true);
+ }
+
+ private void runFloatingRateRecalculationScenario(boolean overPayment) {
+ LocalDate setupDate = LocalDate.of(2024, 2, 1);
+ BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec,
BusinessDateType.BUSINESS_DATE, setupDate);
+
+ Long floatingRateId = createFloatingRate();
+
+ final Account assetAccount = this.accountHelper.createAssetAccount();
+ final Account incomeAccount = this.accountHelper.createIncomeAccount();
+ final Account expenseAccount =
this.accountHelper.createExpenseAccount();
+ final Account overpaymentAccount =
this.accountHelper.createLiabilityAccount();
+
+ Integer loanProductId =
createCumulativeFloatingRateLoanProduct(floatingRateId, assetAccount,
incomeAccount, expenseAccount,
+ overpaymentAccount);
+ assertNotNull(loanProductId);
+
+ final Integer clientId =
clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue();
+
+ LocalDate disbursementDate = LocalDate.of(2024, 3, 15);
+ BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec,
BusinessDateType.BUSINESS_DATE, disbursementDate);
+
+ final Integer loanId = createAndDisburseLoan(clientId, loanProductId,
disbursementDate);
+ assertNotNull(loanId);
+
+ GetLoansLoanIdResponse initialLoan =
loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
+ assertNotNull(initialLoan.getRepaymentSchedule());
+ List<GetLoansLoanIdRepaymentPeriod> initialPeriods =
initialLoan.getRepaymentSchedule().getPeriods();
+
+ BigDecimal initialEmi = null;
+ for (GetLoansLoanIdRepaymentPeriod period : initialPeriods) {
+ if (period.getPeriod() != null && period.getPeriod() == 1) {
+ initialEmi = period.getTotalDueForPeriod();
+ break;
+ }
+ }
+ assertNotNull(initialEmi, "Could not find initial EMI for period 1");
+ assertTrue(initialEmi.compareTo(BigDecimal.ZERO) > 0, "Initial EMI
should be greater than zero");
+
+ LocalDate postRateChangeDate = LocalDate.of(2024, 4, 10);
+ BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec,
BusinessDateType.BUSINESS_DATE, postRateChangeDate);
+
+ String repaymentDate = dateFormatter.format(postRateChangeDate);
+ float repaymentAmount = overPayment ? initialEmi.floatValue() + 0.01f
: initialEmi.floatValue();
+ loanTransactionHelper.makeRepayment(repaymentDate, repaymentAmount,
loanId);
+
+ GetLoansLoanIdResponse updatedLoan =
loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
+ assertNotNull(updatedLoan.getRepaymentSchedule());
+ List<GetLoansLoanIdRepaymentPeriod> updatedPeriods =
updatedLoan.getRepaymentSchedule().getPeriods();
+
+ boolean foundRecalculatedPeriod = false;
+ for (GetLoansLoanIdRepaymentPeriod period : updatedPeriods) {
+ if (period.getPeriod() != null && period.getPeriod() > 1) {
+ BigDecimal updatedEmi = period.getTotalDueForPeriod();
+ assertNotNull(updatedEmi, "EMI for period " +
period.getPeriod() + " should not be null");
+ assertTrue(updatedEmi.compareTo(initialEmi) < 0,
+ "Period " + period.getPeriod() + " EMI (" + updatedEmi
+ ") should be lower than initial EMI (" + initialEmi
+ + ") after rate drop from " +
INITIAL_INTEREST_RATE + "% to " + CHANGED_INTEREST_RATE + "%");
+ foundRecalculatedPeriod = true;
+ }
+ }
+ assertTrue(foundRecalculatedPeriod, "Should have found at least one
recalculated period after the rate change");
+ }
+
+ private Long createFloatingRate() {
+ FloatingRatePeriodRequest initialPeriod = new
FloatingRatePeriodRequest().fromDate("01 March 2024")
+
.interestRate(INITIAL_INTEREST_RATE).isDifferentialToBaseLendingRate(false).locale("en").dateFormat("dd
MMMM yyyy");
+
+ FloatingRatePeriodRequest changedPeriod = new
FloatingRatePeriodRequest().fromDate("01 April 2024")
+
.interestRate(CHANGED_INTEREST_RATE).isDifferentialToBaseLendingRate(false).locale("en").dateFormat("dd
MMMM yyyy");
+
+ FloatingRateRequest floatingRateRequest = new
FloatingRateRequest().name(Utils.uniqueRandomStringGenerator("FLOAT_RATE_", 6))
+
.isBaseLendingRate(false).isActive(true).ratePeriods(List.of(initialPeriod,
changedPeriod));
+
+ PostFloatingRatesResponse response =
Calls.ok(fineractClient().floatingRates.createFloatingRate(floatingRateRequest));
+ assertNotNull(response);
+ assertNotNull(response.getResourceId());
+ return response.getResourceId();
+ }
+
+ private Integer createCumulativeFloatingRateLoanProduct(Long
floatingRateId, Account... accounts) {
+ final HashMap<String, Object> loanProductMap = new
LoanProductTestBuilder().withPrincipal("10000").withNumberOfRepayments("12")
+
.withRepaymentTypeAsMonth().withRepaymentAfterEvery("1").withInterestTypeAsDecliningBalance()
+
.withAmortizationTypeAsEqualInstallments().withInterestCalculationPeriodTypeAsRepaymentPeriod(true)
+
.withInterestRecalculationDetails(LoanProductTestBuilder.RECALCULATION_COMPOUNDING_METHOD_NONE,
+
LoanProductTestBuilder.RECALCULATION_STRATEGY_REDUCE_EMI_AMOUN,
+
LoanProductTestBuilder.INTEREST_APPLICABLE_STRATEGY_ON_PRE_CLOSE_DATE)
+
.withInterestRecalculationRestFrequencyDetails(LoanProductTestBuilder.RECALCULATION_FREQUENCY_TYPE_DAILY,
"1", null, null)
+
.withDaysInMonth("30").withDaysInYear("360").withAccountingRulePeriodicAccrual(accounts).build(null,
null);
+
+ loanProductMap.remove("interestRatePerPeriod");
+ loanProductMap.remove("interestRateFrequencyType");
+
+ loanProductMap.put("isLinkedToFloatingInterestRates", true);
+ loanProductMap.put("floatingRatesId", floatingRateId);
+ loanProductMap.put("interestRateDifferential", "0");
+ loanProductMap.put("isFloatingInterestRateCalculationAllowed", true);
+ loanProductMap.put("minDifferentialLendingRate", "0");
+ loanProductMap.put("defaultDifferentialLendingRate", "0");
+ loanProductMap.put("maxDifferentialLendingRate", "50");
+
+ return
loanTransactionHelper.getLoanProductId(Utils.convertToJson(loanProductMap));
+ }
+
+ private Integer createAndDisburseLoan(Integer clientId, Integer
loanProductId, LocalDate disbursementDate) {
+ String disburseDateStr = dateFormatter.format(disbursementDate);
+
+ String loanApplicationJSON = new
LoanApplicationTestBuilder().withPrincipal("10000").withLoanTermFrequency("12")
+
.withLoanTermFrequencyAsMonths().withNumberOfRepayments("12").withRepaymentEveryAfter("1")
+
.withRepaymentFrequencyTypeAsMonths().withAmortizationTypeAsEqualInstallments()
+
.withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withInterestTypeAsDecliningBalance()
+
.withExpectedDisbursementDate(disburseDateStr).withSubmittedOnDate(disburseDateStr).withLoanType("individual")
+ .build(clientId.toString(), loanProductId.toString(), null);
+
+ com.google.gson.JsonObject jsonObject =
com.google.gson.JsonParser.parseString(loanApplicationJSON).getAsJsonObject();
+ jsonObject.remove("interestRatePerPeriod");
+ jsonObject.addProperty("interestRateDifferential", "0");
+ jsonObject.addProperty("isFloatingInterestRate", true);
+ loanApplicationJSON = jsonObject.toString();
+
+ final Integer loanId =
loanTransactionHelper.getLoanId(loanApplicationJSON);
+ loanTransactionHelper.approveLoan(disburseDateStr, "10000", loanId,
null);
+
loanTransactionHelper.disburseLoanWithNetDisbursalAmount(disburseDateStr,
loanId, "10000");
+ return loanId;
+ }
+}