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 457373c3c1f..f812b3fe404 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 @@ -94,6 +94,7 @@ public final class GlobalConfigurationConstants { public static final String RETAINED_EARNING_GL_ACCOUNT = "retained-gl-account"; public static final String RETAINED_EARNING_USED_BY_REPORT_NAME = "retained-earning-used-by-report-name"; public static final String OFFICE_ID = "office-id"; + public static final String DISALLOW_BACKDATED_TRANSACTIONS = "disallow-backdated-transactions"; 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 03e88ddafc8..6b3fb8d4d79 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 @@ -179,4 +179,8 @@ public interface ConfigurationDomainService { boolean isAllowCashAndNonCashAccrual(); boolean isBlockTransactionsOnClosedOverpaidLoansEnabled(); + + boolean isBackdatedTransactionsDisallowed(); + + Long retrieveBackdatedTransactionsToleranceDays(); } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/service/BackdatedTransactionValidationService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/service/BackdatedTransactionValidationService.java new file mode 100644 index 00000000000..d86090aa37a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/service/BackdatedTransactionValidationService.java @@ -0,0 +1,62 @@ +/** + * 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.infrastructure.configuration.service; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Service; + +/** + * Enforces the {@code disallow-backdated-transactions} global configuration (FINERACT-1950). + * + * When the configuration is enabled, transactions dated more than N days before the current business date are rejected, + * where N is the configuration's value (0 or unset allows only current-date transactions), unless the authenticated + * user has the {@code BACKDATE_TRANSACTION} permission. + */ +@RequiredArgsConstructor +@Service +public class BackdatedTransactionValidationService { + + public static final String BACKDATE_TRANSACTION_PERMISSION = "BACKDATE_TRANSACTION"; + + private final ConfigurationDomainService configurationDomainService; + private final PlatformSecurityContext context; + + public void validateTransactionDate(final LocalDate transactionDate) { + if (transactionDate == null || !configurationDomainService.isBackdatedTransactionsDisallowed()) { + return; + } + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final Long toleranceDays = configurationDomainService.retrieveBackdatedTransactionsToleranceDays(); + final LocalDate earliestAllowedDate = businessDate.minusDays(toleranceDays != null && toleranceDays > 0 ? toleranceDays : 0); + if (!transactionDate.isBefore(earliestAllowedDate)) { + return; + } + if (context.authenticatedUser().hasNotPermissionForAnyOf(BACKDATE_TRANSACTION_PERMISSION)) { + throw new GeneralPlatformDomainRuleException( + "error.msg.transaction.backdated.not.allowed", "Backdated transactions are disallowed: transaction date " + + transactionDate + " is before the earliest allowed date " + earliestAllowedDate, + transactionDate, earliestAllowedDate); + } + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/infrastructure/configuration/service/BackdatedTransactionValidationServiceTest.java b/fineract-core/src/test/java/org/apache/fineract/infrastructure/configuration/service/BackdatedTransactionValidationServiceTest.java new file mode 100644 index 00000000000..702b1644436 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/infrastructure/configuration/service/BackdatedTransactionValidationServiceTest.java @@ -0,0 +1,133 @@ +/** + * 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.infrastructure.configuration.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class BackdatedTransactionValidationServiceTest { + + @Mock + private ConfigurationDomainService configurationDomainService; + + @Mock + private PlatformSecurityContext context; + + private BackdatedTransactionValidationService underTest; + + @BeforeEach + public void setUp() { + underTest = new BackdatedTransactionValidationService(configurationDomainService, context); + setBusinessDate(LocalDate.of(2026, 7, 11)); + } + + @AfterEach + public void tearDown() { + ThreadLocalContextUtil.reset(); + } + + private void setBusinessDate(final LocalDate date) { + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, date))); + } + + private void mockUserWithBackdatePermission(final boolean hasPermission) { + final AppUser user = mock(AppUser.class); + when(context.authenticatedUser()).thenReturn(user); + when(user.hasNotPermissionForAnyOf(BackdatedTransactionValidationService.BACKDATE_TRANSACTION_PERMISSION)) + .thenReturn(!hasPermission); + } + + @Test + public void whenConfigurationDisabledAnyDateIsAllowed() { + when(configurationDomainService.isBackdatedTransactionsDisallowed()).thenReturn(false); + + assertDoesNotThrow(() -> underTest.validateTransactionDate(LocalDate.of(2020, 1, 1))); + verify(context, never()).authenticatedUser(); + } + + @Test + public void nullTransactionDateIsIgnored() { + assertDoesNotThrow(() -> underTest.validateTransactionDate(null)); + } + + @Test + public void currentBusinessDateIsAllowedByDefault() { + when(configurationDomainService.isBackdatedTransactionsDisallowed()).thenReturn(true); + + assertDoesNotThrow(() -> underTest.validateTransactionDate(LocalDate.of(2026, 7, 11))); + verify(context, never()).authenticatedUser(); + } + + @Test + public void previousDayIsRejectedByDefaultWithoutPermission() { + when(configurationDomainService.isBackdatedTransactionsDisallowed()).thenReturn(true); + mockUserWithBackdatePermission(false); + + assertThrows(GeneralPlatformDomainRuleException.class, () -> underTest.validateTransactionDate(LocalDate.of(2026, 7, 10))); + } + + @Test + public void previousDayIsAllowedWithPermission() { + when(configurationDomainService.isBackdatedTransactionsDisallowed()).thenReturn(true); + mockUserWithBackdatePermission(true); + + assertDoesNotThrow(() -> underTest.validateTransactionDate(LocalDate.of(2026, 7, 10))); + } + + @Test + public void toleranceDaysExtendTheAllowedWindow() { + when(configurationDomainService.isBackdatedTransactionsDisallowed()).thenReturn(true); + when(configurationDomainService.retrieveBackdatedTransactionsToleranceDays()).thenReturn(7L); + mockUserWithBackdatePermission(false); + + // business date 2026-07-11 with a 7-day window: earliest allowed date is 2026-07-04 + assertDoesNotThrow(() -> underTest.validateTransactionDate(LocalDate.of(2026, 7, 4))); + assertThrows(GeneralPlatformDomainRuleException.class, () -> underTest.validateTransactionDate(LocalDate.of(2026, 7, 3))); + } + + @Test + public void permissionOverridesRollingWindow() { + when(configurationDomainService.isBackdatedTransactionsDisallowed()).thenReturn(true); + when(configurationDomainService.retrieveBackdatedTransactionsToleranceDays()).thenReturn(7L); + mockUserWithBackdatePermission(true); + + assertDoesNotThrow(() -> underTest.validateTransactionDate(LocalDate.of(2026, 7, 3))); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index 5289f1be722..28fdee714ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -65,6 +65,7 @@ import org.apache.fineract.accounting.rule.exception.AccountingRuleNotFoundException; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.infrastructure.configuration.service.BackdatedTransactionValidationService; import org.apache.fineract.infrastructure.configuration.service.ConfigurationReadPlatformService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; @@ -140,6 +141,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa private final ExternalAssetOwnerRepository externalAssetOwnerRepository; private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository; private final LoanTransactionRepository loanTransactionRepository; + private final BackdatedTransactionValidationService backdatedTransactionValidationService; @Transactional @Override @@ -638,6 +640,8 @@ private void validateBusinessRulesForJournalEntries(final JournalEntryCommand co null, null); } } + // shouldn't be backdated beyond the configured window (FINERACT-1950) + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); // check if credits and debits are valid final SingleDebitOrCreditEntryCommand[] credits = command.getCredits(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java index 57c1a1f2a0f..816200c21d3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java @@ -35,6 +35,7 @@ import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; import org.apache.fineract.accounting.rule.domain.AccountingRuleRepository; +import org.apache.fineract.infrastructure.configuration.service.BackdatedTransactionValidationService; import org.apache.fineract.infrastructure.configuration.service.ConfigurationReadPlatformService; import org.apache.fineract.infrastructure.core.service.PaginationHelper; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; @@ -98,12 +99,14 @@ public JournalEntryWritePlatformService journalEntryWritePlatformService(GLClosu ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService, ExternalAssetOwnerRepository externalAssetOwnerRepository, LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository, - LoanTransactionRepository loanTransactionRepository) { + LoanTransactionRepository loanTransactionRepository, + BackdatedTransactionValidationService backdatedTransactionValidationService) { return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository, officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory, accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository, glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService, financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService, - accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository); + accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository, + backdatedTransactionValidationService); } } 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 a488d6ad508..6de43b12edd 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 @@ -634,6 +634,16 @@ public String getRetainedEarningUsedByReportName() { return property.getStringValue(); } + @Override + public boolean isBackdatedTransactionsDisallowed() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.DISALLOW_BACKDATED_TRANSACTIONS).isEnabled(); + } + + @Override + public Long retrieveBackdatedTransactionsToleranceDays() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.DISALLOW_BACKDATED_TRANSACTIONS).getValue(); + } + @Override public Long getOfficeId() { final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(GlobalConfigurationConstants.OFFICE_ID); 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 a2795e600fd..1ef2fcb2901 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 @@ -38,6 +38,7 @@ 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.configuration.service.BackdatedTransactionValidationService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; @@ -113,6 +114,7 @@ public class LoanTransactionValidatorImpl implements LoanTransactionValidator { private final LoanDisbursementValidator loanDisbursementValidator; private final CodeValueRepository codeValueRepository; private final ConfigurationDomainService configurationDomainService; + private final BackdatedTransactionValidationService backdatedTransactionValidationService; private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { @@ -348,6 +350,7 @@ public void validateTransaction(final String json) { final JsonElement element = this.fromApiJsonHelper.parse(json); final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element); baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().zeroOrPositiveAmount(); @@ -423,6 +426,7 @@ public void validateTransactionWithNoAmount(final String json) { final JsonElement element = this.fromApiJsonHelper.parse(json); final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final String note = this.fromApiJsonHelper.extractStringNamed("note", element); baseDataValidator.reset().parameter("note").value(note).notExceedingLengthOf(1000); @@ -455,6 +459,7 @@ public void validateChargeOffTransaction(final String json) { final JsonElement element = fromApiJsonHelper.parse(json); final LocalDate transactionDate = fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final String note = fromApiJsonHelper.extractStringNamed("note", element); baseDataValidator.reset().parameter("note").value(note).ignoreIfNull().notExceedingLengthOf(1000); @@ -609,6 +614,7 @@ public void validateNewRefundTransaction(final String json) { final JsonElement element = this.fromApiJsonHelper.parse(json); final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element); baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().positiveAmount(); @@ -642,6 +648,7 @@ public void validateLoanForeclosure(final String json) { final JsonElement element = this.fromApiJsonHelper.parse(json); final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final String note = this.fromApiJsonHelper.extractStringNamed("note", element); baseDataValidator.reset().parameter("note").value(note).notExceedingLengthOf(1000); @@ -992,6 +999,7 @@ private void validatePaymentTransaction(String json) { final JsonElement element = this.fromApiJsonHelper.parse(json); final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element); baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().positiveAmount(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java index 2e7f3c4f800..fce8ab15e82 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java @@ -48,6 +48,7 @@ import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.configuration.service.BackdatedTransactionValidationService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; @@ -79,6 +80,7 @@ public class SavingsAccountTransactionDataValidator { Arrays.asList(SavingsApiConstants.dateFormatParamName, SavingsApiConstants.localeParamName, transactionDateParamName, externalIdParamName, IS_POST_INTEREST_AS_ON_PARAM_NAME, POST_INTEREST_MANUAL_OR_AUTOMATIC_PARAM_NAME)); private final ConfigurationDomainService configurationDomainService; + private final BackdatedTransactionValidationService backdatedTransactionValidationService; private static Set createReleaseAmountRequestDataParameters() { final Set requestDataParameters = new HashSet<>(SavingsAccountConstant.SAVINGS_ACCOUNT_TRANSACTION_REQUEST_DATA_PARAMETERS); @@ -87,6 +89,7 @@ private static Set createReleaseAmountRequestDataParameters() { } public void validateTransactionWithPivotDate(final LocalDate transactionDate, final SavingsAccount savingsAccount) { + this.backdatedTransactionValidationService.validateTransactionDate(transactionDate); final boolean backdatedTxnsAllowedTill = this.configurationDomainService.retrievePivotDateConfig(); final boolean isRelaxingDaysConfigOn = this.configurationDomainService.isRelaxingDaysConfigForPivotDateEnabled(); 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 c603611fb2e..24db110c3eb 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 @@ -259,4 +259,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_add_disallow_backdated_transactions_configuration.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_add_disallow_backdated_transactions_configuration.xml new file mode 100644 index 00000000000..88e7cea0478 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_add_disallow_backdated_transactions_configuration.xml @@ -0,0 +1,51 @@ + + + + + + select count(1) from c_configuration where name = 'disallow-backdated-transactions' + + + + + + + + + + + + + + select count(1) from m_permission where code = 'BACKDATE_TRANSACTION' + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DisallowBackdatedTransactionsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DisallowBackdatedTransactionsIntegrationTest.java new file mode 100644 index 00000000000..c50ab353885 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DisallowBackdatedTransactionsIntegrationTest.java @@ -0,0 +1,134 @@ +/** + * 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.assertEquals; +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.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.infrastructure.configuration.service.BackdatedTransactionValidationService; +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.organisation.StaffHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; +import org.apache.fineract.integrationtests.useradministration.roles.RolesHelper; +import org.apache.fineract.integrationtests.useradministration.users.UserHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for the {@code disallow-backdated-transactions} global configuration (FINERACT-1950). + */ +@SuppressWarnings({ "rawtypes", "unchecked" }) +public class DisallowBackdatedTransactionsIntegrationTest { + + private static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; + private static final String LIMITED_USER_PASSWORD = "A1b2c3d4e5f$"; + + private RequestSpecification requestSpec; + private ResponseSpecification responseSpec; + private GlobalConfigurationHelper globalConfigurationHelper; + private SavingsAccountHelper savingsAccountHelper; + + @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.globalConfigurationHelper = new GlobalConfigurationHelper(); + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + } + + @Test + public void backdatedTransactionsAreRejectedUnlessPermittedOrConfigDisabled() { + // active savings account with history in the past (helper defaults submit/approve/activate in 2013) + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + final String savingsProductJSON = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() + .withInterestPostingPeriodTypeAsQuarterly().withInterestCalculationPeriodTypeAsDailyBalance() + .withMinimumOpenningBalance("100").build(); + final Integer savingsProductID = SavingsProductHelper.createSavingsProduct(savingsProductJSON, this.requestSpec, this.responseSpec); + final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductID, ACCOUNT_TYPE_INDIVIDUAL); + this.savingsAccountHelper.approveSavings(savingsId); + this.savingsAccountHelper.activateSavings(savingsId); + + // limited user who can deposit but not backdate + final Integer roleId = RolesHelper.createRole(this.requestSpec, this.responseSpec); + RolesHelper.addPermissionsToRole(this.requestSpec, this.responseSpec, roleId, + new HashMap<>(Map.of("DEPOSIT_SAVINGSACCOUNT", true))); + final Integer staffId = StaffHelper.createStaff(this.requestSpec, this.responseSpec); + final String username = Utils.uniqueRandomStringGenerator("backdt", 8); + UserHelper.createUser(this.requestSpec, this.responseSpec, roleId, staffId, username, LIMITED_USER_PASSWORD, "resourceId"); + + final RequestSpecification limitedUserSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + limitedUserSpec.header("Authorization", + "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey(username, LIMITED_USER_PASSWORD)); + + final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(CommonConstants.DATE_FORMAT, Locale.US); + // the allowed window is anchored to the tenant's business date, so "today" must use the tenant timezone + final LocalDate today = Utils.getLocalDateOfTenant(); + final String backdatedDate = dateFormatter.format(today.minusMonths(2)); + final String currentDate = dateFormatter.format(today); + + this.globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.DISALLOW_BACKDATED_TRANSACTIONS, + new PutGlobalConfigurationsRequest().enabled(true)); + try { + // backdated deposit by limited user is rejected + final ResponseSpecification domainRuleErrorSpec = new ResponseSpecBuilder().expectStatusCode(403).build(); + final SavingsAccountHelper limitedUserRejectedHelper = new SavingsAccountHelper(limitedUserSpec, domainRuleErrorSpec); + final List error = (List) limitedUserRejectedHelper.depositToSavingsAccount(savingsId, "100", backdatedDate, + CommonConstants.RESPONSE_ERROR); + assertEquals("error.msg.transaction.backdated.not.allowed", error.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + // current-date deposit by the same user is allowed + final SavingsAccountHelper limitedUserHelper = new SavingsAccountHelper(limitedUserSpec, this.responseSpec); + assertNotNull(limitedUserHelper.depositToSavingsAccount(savingsId, "100", currentDate, "resourceId")); + + // granting the override permission allows backdating + RolesHelper.addPermissionsToRole(this.requestSpec, this.responseSpec, roleId, + new HashMap<>(Map.of(BackdatedTransactionValidationService.BACKDATE_TRANSACTION_PERMISSION, true))); + assertNotNull(limitedUserHelper.depositToSavingsAccount(savingsId, "100", backdatedDate, "resourceId")); + + // superusers are never blocked + assertNotNull(this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", backdatedDate, "resourceId")); + } finally { + this.globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.DISALLOW_BACKDATED_TRANSACTIONS, + new PutGlobalConfigurationsRequest().enabled(false)); + } + + // with the configuration disabled again, backdated deposits pass without any permission + final SavingsAccountHelper limitedUserHelper = new SavingsAccountHelper(limitedUserSpec, this.responseSpec); + assertNotNull(limitedUserHelper.depositToSavingsAccount(savingsId, "100", backdatedDate, "resourceId")); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsTest.java index 97b13f652ad..504329e1db7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsTest.java @@ -18,13 +18,20 @@ */ package org.apache.fineract.integrationtests; +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.time.format.DateTimeFormatter; import org.apache.fineract.client.models.PostSavingsAccountsAccountIdRequest; import org.apache.fineract.client.models.PostSavingsAccountsAccountIdResponse; import org.apache.fineract.client.models.PostSavingsAccountsRequest; import org.apache.fineract.client.models.PostSavingsAccountsResponse; import org.apache.fineract.integrationtests.client.IntegrationTest; +import org.apache.fineract.integrationtests.common.ClientHelper; import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; import org.apache.fineract.integrationtests.common.savings.SavingsTestLifecycleExtension; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; @@ -47,15 +54,28 @@ public class SavingsAccountsTest extends IntegrationTest { private final String locale = "en"; private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(dateFormat); private final String formattedDate = dateFormatter.format(Utils.getLocalDateOfTenant()); - private int savingId = 1; + // static: JUnit uses a new test instance per method, so instance fields do not carry between the ordered tests + private static long clientId; + private static long productId; + private static int savingId; @Test @Order(1) void submitSavingsAccountsApplication() { LOG.info("------------------------------ CREATING NEW SAVINGS ACCOUNT APPLICATION ---------------------------------------"); + // create a dedicated active client and savings product instead of relying on entities created by other test + // classes in the same shard + final RequestSpecification requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + final ResponseSpecification responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + clientId = ClientHelper.createClient(requestSpec, responseSpec); + final String savingsProductJSON = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() + .withInterestPostingPeriodTypeAsQuarterly().withInterestCalculationPeriodTypeAsDailyBalance().build(); + productId = SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + PostSavingsAccountsRequest request = new PostSavingsAccountsRequest(); - request.setClientId(1L); - request.setProductId(1L); + request.setClientId(clientId); + request.setProductId(productId); request.setLocale(locale); request.setDateFormat(dateFormat); request.submittedOnDate(formattedDate); 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 734a170a24b..df0f7f8528a 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 @@ -694,6 +694,13 @@ private static ArrayList getAllDefaultGlobalConfigurations() { retainedEarningUsedByReportName.put("string_value", "Trial Balance Summary Report with Asset Owner"); defaults.add(retainedEarningUsedByReportName); + HashMap isDisallowBackdatedTransactions = new HashMap<>(); + isDisallowBackdatedTransactions.put("name", GlobalConfigurationConstants.DISALLOW_BACKDATED_TRANSACTIONS); + isDisallowBackdatedTransactions.put("value", 0L); + isDisallowBackdatedTransactions.put("enabled", false); + isDisallowBackdatedTransactions.put("trapDoor", false); + defaults.add(isDisallowBackdatedTransactions); + return defaults; }