From 9d0f2a93737c9d1afb386dfb01acc1133d0f4f77 Mon Sep 17 00:00:00 2001 From: Siddharthan P S Date: Wed, 14 Jan 2026 16:09:56 -0500 Subject: [PATCH 1/2] FINERACT-1659: Prevent duplicate savings interest posting in same period - Add check to skip accounts where interest already posted for current period - Change transaction isolation to SERIALIZABLE to prevent concurrent duplicate postings - Only add accounts with new interest transactions to batch update - Remove unused variable to fix linter warning This fixes issue #2 from FINERACT-1659 where interest was being posted multiple times within the same posting period, causing incorrect calculations and duplicate entries. --- .../SavingsSchedularInterestPoster.java | 29 +++++++++++++++++-- 1 file changed, 26 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()); + } } From 1fcf05b99fd81fe947602d74b9489855e0548f6b Mon Sep 17 00:00:00 2001 From: Siddharthan P S Date: Wed, 14 Jan 2026 17:39:50 -0500 Subject: [PATCH 2/2] Fix duplicate interest posting check to align with query filter logic - Change check to compare against currentDate instead of yesterday - Only skip accounts where interestPostedTillDate >= currentDate - This prevents same-day duplicates while allowing legitimate posting - Accounts with interestPostedTillDate <= yesterday will be processed correctly --- .../savings/service/SavingsSchedularInterestPoster.java | 6 +++--- 1 file changed, 3 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 04b51a606e6..b0908b2123c 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 @@ -72,7 +72,7 @@ public void postInterest() throws JobExecutionException { boolean postInterestAsOn = false; LocalDate transactionDate = null; try { - if (isInterestAlreadyPostedForPeriod(savingsAccountData, currentDate)) { + if (isInterestAlreadyPostedForCurrentPeriod(savingsAccountData, currentDate)) { log.debug("Interest already posted for savings account {} up to date {}, skipping", savingsAccountData.getId(), savingsAccountData.getSummary().getInterestPostedTillDate()); continue; @@ -256,12 +256,12 @@ private String batchQueryForTransactionsUpdate() { + LAST_MODIFIED_DATE_DB_FIELD + " = ?, " + LAST_MODIFIED_BY_DB_FIELD + " = ? " + "WHERE id=?"; } - private boolean isInterestAlreadyPostedForPeriod(SavingsAccountData savingsAccountData, LocalDate currentDate) { + private boolean isInterestAlreadyPostedForCurrentPeriod(SavingsAccountData savingsAccountData, LocalDate currentDate) { LocalDate interestPostedTillDate = savingsAccountData.getSummary().getInterestPostedTillDate(); if (interestPostedTillDate == null) { return false; } - return !interestPostedTillDate.isBefore(currentDate); + return interestPostedTillDate.isAfter(currentDate) || interestPostedTillDate.equals(currentDate); } private boolean hasNewInterestTransactions(SavingsAccountData savingsAccountData) {