This is an automated email from the ASF dual-hosted git repository.

arnold pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git


The following commit(s) were added to refs/heads/develop by this push:
     new 42197b88e9 FINERACT-1659: prevent duplicate savings interest posting 
and add regression test
42197b88e9 is described below

commit 42197b88e9c825418d398eb1c7b94428c92c1bc1
Author: Siddharthan P S <[email protected]>
AuthorDate: Tue Mar 17 11:55:13 2026 -0400

    FINERACT-1659: prevent duplicate savings interest posting and add 
regression test
---
 .../service/SavingsSchedularInterestPoster.java    | 15 ++++-
 .../SavingsInterestPostingTest.java                | 73 ++++++++++++++++++++++
 2 files changed, 87 insertions(+), 1 deletion(-)

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 6928f730b6..b5fe65048d 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
@@ -67,10 +67,16 @@ public class SavingsSchedularInterestPoster {
     public void postInterest() throws JobExecutionException {
         if (!savingAccounts.isEmpty()) {
             List<Throwable> errors = new ArrayList<>();
+            LocalDate yesterday = 
DateUtils.getBusinessLocalDate().minusDays(1);
             for (SavingsAccountData savingsAccountData : savingAccounts) {
                 boolean postInterestAsOn = false;
                 LocalDate transactionDate = null;
                 try {
+                    if (isInterestAlreadyPostedForPeriod(savingsAccountData, 
yesterday)) {
+                        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);
@@ -109,7 +115,6 @@ public class SavingsSchedularInterestPoster {
             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 +253,12 @@ public class SavingsSchedularInterestPoster {
                 + "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 yesterday) {
+        LocalDate interestPostedTillDate = 
savingsAccountData.getSummary().getInterestPostedTillDate();
+        if (interestPostedTillDate == null) {
+            return false;
+        }
+        return !interestPostedTillDate.isBefore(yesterday);
+    }
 }
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 5b026c3842..3f436fb107 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,69 @@ public class SavingsInterestPostingTest {
         });
     }
 
+    @Test
+    public void 
testNoDuplicateInterestPostingWhenMixingManualAndJobRunsAcrossBusinessDates() {
+        final Integer[] savingsAccountId = new Integer[1];
+        final int[] interestTxAfterFirstManualPost = new int[1];
+        final int[] interestTxAfterManualAsOnPost = new int[1];
+        final String accountOpeningDate = "01 January 2025";
+        final String firstManualPostingDate = "02 February 2025";
+        final String manualAsOnPostingDate = "15 March 2025";
+        final String schedulerPostingDate = "02 April 2025";
+        final String depositAmount = "10000";
+
+        runAt(accountOpeningDate, () -> {
+            final Integer clientId = ClientHelper.createClient(requestSpec, 
responseSpec, accountOpeningDate);
+
+            final String savingsProductJSON = new SavingsProductHelper() //
+                    .withInterestCompoundingPeriodTypeAsAnnually() //
+                    .withInterestPostingPeriodTypeAsMonthly() //
+                    .withInterestCalculationPeriodTypeAsDailyBalance() //
+                    .build();
+            final Integer productId = 
SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, 
responseSpec);
+
+            final Integer accountId = 
savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId,
+                    SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, 
accountOpeningDate);
+
+            savingsAccountHelper.approveSavingsOnDate(accountId, 
accountOpeningDate);
+            savingsAccountHelper.activateSavings(accountId, 
accountOpeningDate);
+            savingsAccountHelper.depositToSavingsAccount(accountId, 
depositAmount, accountOpeningDate,
+                    CommonConstants.RESPONSE_RESOURCE_ID);
+            savingsAccountId[0] = accountId;
+        });
+
+        runAt(firstManualPostingDate, () -> {
+            savingsAccountHelper.postInterestForSavings(savingsAccountId[0]);
+            interestTxAfterFirstManualPost[0] = 
getActiveInterestTransactions(savingsAccountId[0]).size();
+            Assertions.assertTrue(interestTxAfterFirstManualPost[0] > 0, 
"Expected interest transactions after first manual posting");
+        });
+
+        runAt(manualAsOnPostingDate, () -> {
+            savingsAccountHelper.postInterestAsOnSavings(savingsAccountId[0], 
manualAsOnPostingDate);
+            interestTxAfterManualAsOnPost[0] = 
getActiveInterestTransactions(savingsAccountId[0]).size();
+            Assertions.assertTrue(interestTxAfterManualAsOnPost[0] > 
interestTxAfterFirstManualPost[0],
+                    "Expected additional interest transaction(s) after manual 
post-as-on execution");
+        });
+
+        runAt(schedulerPostingDate, () -> {
+            schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME);
+
+            List<HashMap> activeInterestTransactions = 
getActiveInterestTransactions(savingsAccountId[0]);
+            Assertions.assertTrue(activeInterestTransactions.size() >= 
interestTxAfterManualAsOnPost[0],
+                    "Scheduler run should not reduce number of posted interest 
transactions");
+
+            Map<LocalDate, Integer> interestTransactionCountByDate = new 
HashMap<>();
+            for (HashMap tx : activeInterestTransactions) {
+                LocalDate transactionDate = coerceToLocalDate(tx);
+                Assertions.assertNotNull(transactionDate, "Could not determine 
date of an interest transaction");
+                interestTransactionCountByDate.merge(transactionDate, 1, 
Integer::sum);
+            }
+
+            interestTransactionCountByDate
+                    .forEach((txDate, txCount) -> Assertions.assertEquals(1, 
txCount, "Multiple interest postings found on " + txDate));
+        });
+    }
+
     private void cleanupSavingsAccountsFromDuplicatePreventionTest() {
         try {
             LOG.info("Starting cleanup of savings accounts after duplicate 
prevention test");
@@ -490,6 +553,16 @@ public class SavingsInterestPostingTest {
         return filtered;
     }
 
+    private List<HashMap> getActiveInterestTransactions(Integer 
savingsAccountId) {
+        List<HashMap> activeInterestTransactions = new ArrayList<>();
+        for (HashMap tx : getInterestTransactions(savingsAccountId)) {
+            if (!isReversed(tx)) {
+                activeInterestTransactions.add(tx);
+            }
+        }
+        return activeInterestTransactions;
+    }
+
     public Integer 
createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(final String 
interestPayableAccount,
             final String savingsControlAccount, final String 
interestReceivableAccount, final Account... accounts) {
         LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT 
WITHOUT OVERDRAFT ---------------------------------------");

Reply via email to