From 02a1d2117cf827f52b59337b68eee64aba5c7b7e Mon Sep 17 00:00:00 2001 From: airajena Date: Sun, 8 Feb 2026 23:57:35 +0530 Subject: [PATCH] FINERACT-2399: Add global config to block transactions on closed/overpaid loans --- build.gradle | 10 + .../api/GlobalConfigurationConstants.java | 1 + .../domain/ConfigurationDomainService.java | 2 + .../LoanTransactionValidator.java | 2 + ...ogressiveLoanTransactionValidatorImpl.java | 5 + .../domain/ConfigurationDomainServiceJpa.java | 5 + .../LoanTransactionValidatorImpl.java | 10 + ...WritePlatformServiceJpaRepositoryImpl.java | 5 +- .../db/changelog/tenant/changelog-tenant.xml | 1 + ..._transactions_on_closed_overpaid_loans.xml | 37 ++++ ...TransactionsOnClosedOverpaidLoansTest.java | 174 ++++++++++++++++++ .../common/GlobalConfigurationHelper.java | 7 + 12 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_block_transactions_on_closed_overpaid_loans.xml create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/BlockTransactionsOnClosedOverpaidLoansTest.java diff --git a/build.gradle b/build.gradle index 655e264571f..fd7d18ce1c0 100644 --- a/build.gradle +++ b/build.gradle @@ -901,6 +901,16 @@ cyclonedxBom { includeLicenseText = true } +tasks.withType(org.cyclonedx.gradle.CyclonedxDirectTask).configureEach { + includeMetadataResolution.set(false) +} + +project(':fineract-war') { + tasks.matching { it.name == 'cyclonedxDirectBom' }.configureEach { + enabled = false + } +} + tasks.register('printSourceSetInformation') { doLast { sourceSets.each { srcSet -> diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java index b41bf8a90f0..c422879f6d2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java @@ -80,6 +80,7 @@ public final class GlobalConfigurationConstants { public static final String ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-for-external-asset-transfer"; public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer"; public static final String ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION = "enable-originator-creation-during-loan-application"; + public static final String BLOCK_TRANSACTIONS_ON_CLOSED_OVERPAID_LOANS = "block-transactions-on-closed-overpaid-loans"; private GlobalConfigurationConstants() {} } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index e7a98fb2cc2..34e694041ba 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -151,4 +151,6 @@ public interface ConfigurationDomainService { boolean isImmediateChargeAccrualPostMaturityEnabled(); String getAssetOwnerTransferOustandingInterestStrategy(); + + boolean isBlockTransactionsOnClosedOverpaidLoansEnabled(); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java index 43527f9a335..6fda77e6beb 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java @@ -98,4 +98,6 @@ void validateRefund(Loan loan, LoanTransactionType loanTransactionType, LocalDat void validateManualInterestRefundTransaction(String json); void validateClassificationCodeValue(String codeName, Long transactionClassificationId, DataValidatorBuilder baseDataValidator); + + void validateLoanNotClosedOrOverpaidForTransactions(Loan loan); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java index 385d07220c7..0ffed48ab0b 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java @@ -506,6 +506,11 @@ public void validateLoanGroupIsActive(Loan loan) { loanTransactionValidator.validateLoanGroupIsActive(loan); } + @Override + public void validateLoanNotClosedOrOverpaidForTransactions(Loan loan) { + loanTransactionValidator.validateLoanNotClosedOrOverpaidForTransactions(loan); + } + @Override public void validateActivityNotBeforeLastTransactionDate(Loan loan, LocalDate activityDate, LoanEvent event) { loanTransactionValidator.validateActivityNotBeforeLastTransactionDate(loan, activityDate, event); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index 26b48b2f067..677926b3eba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -548,4 +548,9 @@ public String getAssetOwnerTransferOustandingInterestStrategy() { return getGlobalConfigurationPropertyData( GlobalConfigurationConstants.ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY).getStringValue(); } + + @Override + public boolean isBlockTransactionsOnClosedOverpaidLoansEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.BLOCK_TRANSACTIONS_ON_CLOSED_OVERPAID_LOANS).isEnabled(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java index 65c55ee8c4a..ecd22726fc6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java @@ -37,6 +37,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; @@ -111,6 +112,7 @@ public class LoanTransactionValidatorImpl implements LoanTransactionValidator { private final LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator; private final LoanDisbursementValidator loanDisbursementValidator; private final CodeValueRepository codeValueRepository; + private final ConfigurationDomainService configurationDomainService; private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { @@ -666,6 +668,14 @@ public void validateLoanGroupIsActive(final Loan loan) { } } + public void validateLoanNotClosedOrOverpaidForTransactions(Loan loan) { + boolean blockTransactions = configurationDomainService.isBlockTransactionsOnClosedOverpaidLoansEnabled(); + if (blockTransactions && (loan.isClosed() || loan.getStatus().isOverpaid())) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.transaction.not.allowed.on.closed.or.overpaid", + "Monetary transactions are not allowed on closed or overpaid loan accounts", loan.getId()); + } + } + protected void validateLoanHasNoLaterChargeRefundTransactionToReverseOrCreateATransaction(Loan loan, LocalDate transactionDate, String reversedOrCreated) { for (LoanTransaction txn : loan.getLoanTransactions()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index af7644c7b6e..8961e895da5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -680,7 +680,7 @@ private Loan saveAndFlushLoanWithDataIntegrityViolationChecks(final Loan loan) { final Throwable realCause = e.getCause(); final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.transaction"); - if (realCause.getMessage().toLowerCase().contains("external_id_unique")) { + if (realCause.getMessage().toLowerCase(Locale.ROOT).contains("external_id_unique")) { baseDataValidator.reset().parameter(LoanApiConstants.externalIdParameterName).failWithCode("value.must.be.unique"); } if (!dataValidationErrors.isEmpty()) { @@ -698,7 +698,7 @@ private void saveLoanWithDataIntegrityViolationChecks(final Loan loan) { final Throwable realCause = e.getCause(); final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.transaction"); - if (realCause.getMessage().toLowerCase().contains("external_id_unique")) { + if (realCause.getMessage().toLowerCase(Locale.ROOT).contains("external_id_unique")) { baseDataValidator.reset().parameter(LoanApiConstants.externalIdParameterName).failWithCode("value.must.be.unique"); } if (!dataValidationErrors.isEmpty()) { @@ -1095,6 +1095,7 @@ public CommandProcessingResult makeLoanRepaymentWithChargeRefundChargeType(final changes.put(LoanApiConstants.externalIdParameterName, txnExternalId); } Loan loan = this.loanAssembler.assembleFrom(loanId); + this.loanTransactionValidator.validateLoanNotClosedOrOverpaidForTransactions(loan); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); final Boolean isHolidayValidationDone = false; final HolidayDetailDTO holidayDetailDto = null; diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 34da1913f50..21015193c63 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -228,4 +228,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_block_transactions_on_closed_overpaid_loans.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_block_transactions_on_closed_overpaid_loans.xml new file mode 100644 index 00000000000..aea648110d9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_block_transactions_on_closed_overpaid_loans.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BlockTransactionsOnClosedOverpaidLoansTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BlockTransactionsOnClosedOverpaidLoansTest.java new file mode 100644 index 00000000000..4eb16efcbe0 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BlockTransactionsOnClosedOverpaidLoansTest.java @@ -0,0 +1,174 @@ +/** + * 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 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.util.ArrayList; +import java.util.HashMap; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.CommonConstants; +import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; +import org.apache.fineract.integrationtests.common.Utils; +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.LoanStatusChecker; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(LoanTestLifecycleExtension.class) +@Slf4j +public class BlockTransactionsOnClosedOverpaidLoansTest { + + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + private LoanTransactionHelper loanTransactionHelper; + private LoanTransactionHelper loanTransactionHelperForError; + private GlobalConfigurationHelper globalConfigurationHelper; + private AccountHelper accountHelper; + private ResponseSpecification responseSpecForError; + + @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.responseSpecForError = new ResponseSpecBuilder().expectStatusCode(403).build(); + this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec); + this.loanTransactionHelperForError = new LoanTransactionHelper(this.requestSpec, this.responseSpecForError); + this.globalConfigurationHelper = new GlobalConfigurationHelper(); + this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); + } + + @AfterEach + public void tearDown() { + this.globalConfigurationHelper.manageConfigurations("block-transactions-on-closed-overpaid-loans", false); + } + + @Test + public void testTransactionsOnOverpaidLoan() { + this.globalConfigurationHelper.manageConfigurations("block-transactions-on-closed-overpaid-loans", true); + + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); + + final Integer loanProductID = createLoanProduct(); + final Integer loanID = applyForLoanApplication(clientID, loanProductID, "1000", "01 January 2024"); + + this.loanTransactionHelper.approveLoan("01 January 2024", loanID); + this.loanTransactionHelper.disburseLoanWithNetDisbursalAmount("01 January 2024", loanID, "1000"); + + this.loanTransactionHelper.makeRepayment("01 February 2024", 2000.0f, loanID); + + HashMap loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanID, + "status"); + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + ArrayList repaymentErrors = (ArrayList) this.loanTransactionHelperForError.makeRepaymentTypePayment("repayment", + "02 February 2024", 10.0f, loanID, CommonConstants.RESPONSE_ERROR); + Assertions.assertEquals("error.msg.loan.transaction.not.allowed.on.closed.or.overpaid", + repaymentErrors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + Float totalOverpaid = (Float) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanID, + "totalOverpaid"); + assertNotNull(totalOverpaid); + Assertions.assertTrue(totalOverpaid > 0); + + this.loanTransactionHelper.creditBalanceRefund("03 February 2024", totalOverpaid, null, loanID, ""); + + loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanID, "status"); + LoanStatusChecker.verifyLoanAccountIsClosed(loanStatusHashMap); + } + + @Test + public void testTransactionsOnClosedLoan() { + this.globalConfigurationHelper.manageConfigurations("block-transactions-on-closed-overpaid-loans", true); + + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + final Integer loanProductID = createLoanProduct(); + final Integer loanID = applyForLoanApplication(clientID, loanProductID, "1000", "01 January 2024"); + + this.loanTransactionHelper.approveLoan("01 January 2024", loanID); + this.loanTransactionHelper.disburseLoanWithNetDisbursalAmount("01 January 2024", loanID, "1000"); + + HashMap loanSummary = this.loanTransactionHelper.getLoanSummary(this.requestSpec, this.responseSpec, loanID); + Float totalOutstanding = (Float) loanSummary.get("totalOutstanding"); + this.loanTransactionHelper.makeRepayment("01 February 2024", totalOutstanding, loanID); + + HashMap loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanID, + "status"); + LoanStatusChecker.verifyLoanAccountIsClosed(loanStatusHashMap); + + ArrayList repaymentErrors = (ArrayList) this.loanTransactionHelperForError.makeRepaymentTypePayment("repayment", + "02 February 2024", 10.0f, loanID, CommonConstants.RESPONSE_ERROR); + Assertions.assertEquals("error.msg.loan.transaction.not.allowed.on.closed.or.overpaid", + repaymentErrors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + this.globalConfigurationHelper.manageConfigurations("block-transactions-on-closed-overpaid-loans", false); + this.loanTransactionHelper.makeRepayment("03 February 2024", 10.0f, loanID); + } + + private Integer createLoanProduct() { + final String principal = "1000.00"; + LoanProductTestBuilder loanProductTestBuilder = new LoanProductTestBuilder() // + .withPrincipal(principal) // + .withShortName(Utils.uniqueRandomStringGenerator("", 4)) // + .withNumberOfRepayments("4") // + .withRepaymentAfterEvery("1") // + .withRepaymentTypeAsMonth() // + .withinterestRatePerPeriod("1") // + .withInterestRateFrequencyTypeAsMonths() // + .withAmortizationTypeAsEqualInstallments() // + .withInterestTypeAsDecliningBalance(); + + final String loanProductJSON = loanProductTestBuilder.build(null); + return this.loanTransactionHelper.getLoanProductId(loanProductJSON); + } + + private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID, String principal, String submitDate) { + final String loanApplicationJSON = new LoanApplicationTestBuilder() // + .withPrincipal(principal) // + .withLoanTermFrequency("4") // + .withLoanTermFrequencyAsMonths() // + .withNumberOfRepayments("4") // + .withRepaymentEveryAfter("1") // + .withRepaymentFrequencyTypeAsMonths() // + .withInterestRatePerPeriod("1") // + .withAmortizationTypeAsEqualInstallments() // + .withInterestTypeAsDecliningBalance() // + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod() // + .withExpectedDisbursementDate(submitDate) // + .withSubmittedOnDate(submitDate) // + .build(clientID.toString(), loanProductID.toString(), null); + return this.loanTransactionHelper.getLoanId(loanApplicationJSON); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java index 10f3290aa32..408191ebc66 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java @@ -557,6 +557,13 @@ private static ArrayList getAllDefaultGlobalConfigurations() { enableImmediateChargeAccrualPostMaturity.put("trapDoor", false); defaults.add(enableImmediateChargeAccrualPostMaturity); + HashMap blockTransactionsOnClosedOverpaidLoans = new HashMap<>(); + blockTransactionsOnClosedOverpaidLoans.put("name", GlobalConfigurationConstants.BLOCK_TRANSACTIONS_ON_CLOSED_OVERPAID_LOANS); + blockTransactionsOnClosedOverpaidLoans.put("value", 0L); + blockTransactionsOnClosedOverpaidLoans.put("enabled", false); + blockTransactionsOnClosedOverpaidLoans.put("trapDoor", false); + defaults.add(blockTransactionsOnClosedOverpaidLoans); + HashMap assetOwnerTransferInterestOutstandingStrategy = new HashMap<>(); assetOwnerTransferInterestOutstandingStrategy.put("name", GlobalConfigurationConstants.ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY);