From 272eea8387a9128957b4aa7c0059e66b44206e0e Mon Sep 17 00:00:00 2001 From: Siddharthan P S Date: Tue, 20 Jan 2026 18:33:51 -0500 Subject: [PATCH] FINERACT-1659: Prevent duplicate savings interest posting in same period --- .../SavingsSchedularInterestPoster.java | 29 +++++- .../SavingsInterestPostingTest.java | 98 +++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java index 6928f730b66..04b51a606e6 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java @@ -63,17 +63,25 @@ public class SavingsSchedularInterestPoster { private Collection savingAccounts; private boolean backdatedTxnsAllowedTill; - @Transactional(isolation = Isolation.READ_UNCOMMITTED, rollbackFor = Exception.class) + @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class) public void postInterest() throws JobExecutionException { if (!savingAccounts.isEmpty()) { List errors = new ArrayList<>(); + LocalDate currentDate = DateUtils.getBusinessLocalDate(); for (SavingsAccountData savingsAccountData : savingAccounts) { boolean postInterestAsOn = false; LocalDate transactionDate = null; try { + if (isInterestAlreadyPostedForPeriod(savingsAccountData, currentDate)) { + log.debug("Interest already posted for savings account {} up to date {}, skipping", savingsAccountData.getId(), + savingsAccountData.getSummary().getInterestPostedTillDate()); + continue; + } SavingsAccountData savingsAccountDataRet = savingsAccountWritePlatformService.postInterest(savingsAccountData, postInterestAsOn, transactionDate, backdatedTxnsAllowedTill); - savingsAccountDataList.add(savingsAccountDataRet); + if (hasNewInterestTransactions(savingsAccountDataRet)) { + savingsAccountDataList.add(savingsAccountDataRet); + } } catch (Exception e) { errors.add(e); } @@ -109,7 +117,6 @@ private void batchUpdateJournalEntries(final List savingsAcc for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) { if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) { final String key = savingsAccountTransactionData.getRefNo(); - final Boolean isOverdraft = savingsAccountTransactionData.getIsOverdraft(); final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key); savingsAccountTransactionData.setId(dataFromFetch.getId()); if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 @@ -248,4 +255,20 @@ private String batchQueryForTransactionsUpdate() { + "SET is_reversed=?, amount=?, overdraft_amount_derived=?, balance_end_date_derived=?, balance_number_of_days_derived=?, running_balance_derived=?, cumulative_balance_derived=?, is_reversal=?, " + LAST_MODIFIED_DATE_DB_FIELD + " = ?, " + LAST_MODIFIED_BY_DB_FIELD + " = ? " + "WHERE id=?"; } + + private boolean isInterestAlreadyPostedForPeriod(SavingsAccountData savingsAccountData, LocalDate currentDate) { + LocalDate interestPostedTillDate = savingsAccountData.getSummary().getInterestPostedTillDate(); + if (interestPostedTillDate == null) { + return false; + } + return !interestPostedTillDate.isBefore(currentDate); + } + + private boolean hasNewInterestTransactions(SavingsAccountData savingsAccountData) { + if (savingsAccountData.getSavingsAccountTransactionData() == null) { + return false; + } + return savingsAccountData.getSavingsAccountTransactionData().stream() + .anyMatch(tx -> tx.getId() == null && !MathUtil.isZero(tx.getAmount()) && tx.isInterestPosting()); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java index 5b026c38424..0ddbbc77bd6 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java @@ -444,6 +444,104 @@ public void testPostInterestForDuplicatePrevention() { }); } + @Test + public void testPostInterestPreventsDuplicateOnSameDay() { + runAt("15 April 2025", () -> { + final String amount = "5000"; + + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account liabilityAccount = accountHelper.createLiabilityAccount(); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( + interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), + interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + + final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); + final LocalDate startDate = LocalDate.of(2025, 3, 1); + final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); + savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); + savingsAccountHelper.activateSavings(accountId, startDateString); + savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + + schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + List txsAfterFirstRun = getInterestTransactions(accountId); + Assertions.assertEquals(1, txsAfterFirstRun.size(), "Expected exactly one interest transaction after first job run"); + + HashMap summaryAfterFirstRun = savingsAccountHelper.getSavingsSummary(accountId); + BigDecimal balanceAfterFirstRun = BigDecimal.valueOf(((Double) summaryAfterFirstRun.get("accountBalance"))); + + schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + List txsAfterSecondRun = getInterestTransactions(accountId); + Assertions.assertEquals(1, txsAfterSecondRun.size(), + "Expected still only one interest transaction after second job run on same day - duplicate should be prevented"); + + HashMap summaryAfterSecondRun = savingsAccountHelper.getSavingsSummary(accountId); + BigDecimal balanceAfterSecondRun = BigDecimal.valueOf(((Double) summaryAfterSecondRun.get("accountBalance"))); + + Assertions.assertEquals(balanceAfterFirstRun, balanceAfterSecondRun, + "Account balance should remain unchanged after second job run - no duplicate interest should be posted"); + }); + } + + @Test + public void testPostInterestSkipsCurrentPeriodButAllowsNewPeriod() { + runAt("20 May 2025", () -> { + final String amount = "8000"; + + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account liabilityAccount = accountHelper.createLiabilityAccount(); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( + interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), + interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + + final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); + final LocalDate startDate = LocalDate.of(2025, 4, 1); + final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); + savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); + savingsAccountHelper.activateSavings(accountId, startDateString); + savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + + schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + List txsAfterFirstPeriod = getInterestTransactions(accountId); + Assertions.assertEquals(1, txsAfterFirstPeriod.size(), + "Expected exactly one interest transaction after first posting period"); + + HashMap summaryAfterFirstPeriod = savingsAccountHelper.getSavingsSummary(accountId); + BigDecimal balanceAfterFirstPeriod = BigDecimal.valueOf(((Double) summaryAfterFirstPeriod.get("accountBalance"))); + + schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + List txsAfterSecondRunSameDay = getInterestTransactions(accountId); + Assertions.assertEquals(1, txsAfterSecondRunSameDay.size(), + "Expected still only one interest transaction after second run on same day - current period should be skipped"); + + HashMap summaryAfterSecondRun = savingsAccountHelper.getSavingsSummary(accountId); + BigDecimal balanceAfterSecondRun = BigDecimal.valueOf(((Double) summaryAfterSecondRun.get("accountBalance"))); + Assertions.assertEquals(balanceAfterFirstPeriod, balanceAfterSecondRun, + "Account balance should remain unchanged after second run on same day - current period posting should be skipped"); + }); + } + private void cleanupSavingsAccountsFromDuplicatePreventionTest() { try { LOG.info("Starting cleanup of savings accounts after duplicate prevention test");