Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,8 @@ public interface ConfigurationDomainService {
boolean isAllowCashAndNonCashAccrual();

boolean isBlockTransactionsOnClosedOverpaidLoansEnabled();

boolean isBackdatedTransactionsDisallowed();

Long retrieveBackdatedTransactionsToleranceDays();
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading