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 ---------------------------------------");