Skip to content

Commit 37e9d53

Browse files
committed
FINERACT-1659: Prevent duplicate savings interest posting in same period
1 parent ef12056 commit 37e9d53

File tree

2 files changed

+136
-16
lines changed

2 files changed

+136
-16
lines changed

fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,25 @@ public class SavingsSchedularInterestPoster {
6363
private Collection<SavingsAccountData> savingAccounts;
6464
private boolean backdatedTxnsAllowedTill;
6565

66-
@Transactional(isolation = Isolation.READ_UNCOMMITTED, rollbackFor = Exception.class)
66+
@Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class)
6767
public void postInterest() throws JobExecutionException {
6868
if (!savingAccounts.isEmpty()) {
6969
List<Throwable> errors = new ArrayList<>();
70+
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
7071
for (SavingsAccountData savingsAccountData : savingAccounts) {
7172
boolean postInterestAsOn = false;
7273
LocalDate transactionDate = null;
7374
try {
75+
if (isInterestAlreadyPostedForPeriod(savingsAccountData, yesterday)) {
76+
log.debug("Interest already posted for savings account {} up to date {}, skipping", savingsAccountData.getId(),
77+
savingsAccountData.getSummary().getInterestPostedTillDate());
78+
continue;
79+
}
7480
SavingsAccountData savingsAccountDataRet = savingsAccountWritePlatformService.postInterest(savingsAccountData,
7581
postInterestAsOn, transactionDate, backdatedTxnsAllowedTill);
76-
savingsAccountDataList.add(savingsAccountDataRet);
82+
if (hasNewInterestTransactions(savingsAccountDataRet)) {
83+
savingsAccountDataList.add(savingsAccountDataRet);
84+
}
7785
} catch (Exception e) {
7886
errors.add(e);
7987
}
@@ -109,7 +117,6 @@ private void batchUpdateJournalEntries(final List<SavingsAccountData> savingsAcc
109117
for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) {
110118
if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) {
111119
final String key = savingsAccountTransactionData.getRefNo();
112-
final Boolean isOverdraft = savingsAccountTransactionData.getIsOverdraft();
113120
final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key);
114121
savingsAccountTransactionData.setId(dataFromFetch.getId());
115122
if (savingsAccountData.getGlAccountIdForSavingsControl() != 0
@@ -248,4 +255,20 @@ private String batchQueryForTransactionsUpdate() {
248255
+ "SET is_reversed=?, amount=?, overdraft_amount_derived=?, balance_end_date_derived=?, balance_number_of_days_derived=?, running_balance_derived=?, cumulative_balance_derived=?, is_reversal=?, "
249256
+ LAST_MODIFIED_DATE_DB_FIELD + " = ?, " + LAST_MODIFIED_BY_DB_FIELD + " = ? " + "WHERE id=?";
250257
}
258+
259+
private boolean isInterestAlreadyPostedForPeriod(SavingsAccountData savingsAccountData, LocalDate yesterday) {
260+
LocalDate interestPostedTillDate = savingsAccountData.getSummary().getInterestPostedTillDate();
261+
if (interestPostedTillDate == null) {
262+
return false;
263+
}
264+
return interestPostedTillDate.isAfter(yesterday);
265+
}
266+
267+
private boolean hasNewInterestTransactions(SavingsAccountData savingsAccountData) {
268+
if (savingsAccountData.getSavingsAccountTransactionData() == null) {
269+
return false;
270+
}
271+
return savingsAccountData.getSavingsAccountTransactionData().stream()
272+
.anyMatch(tx -> tx.getId() == null && !MathUtil.isZero(tx.getAmount()));
273+
}
251274
}

integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ public void testPostInterestWithOverdraftProduct() {
129129
BigDecimal expected = calcInterestPosting(productHelper, amount, days);
130130

131131
List<HashMap> txs = getInterestTransactions(accountId);
132-
Assertions.assertEquals(expected, BigDecimal.valueOf(((Double) txs.get(0).get("amount"))), "ERROR in expected");
132+
Assertions.assertEquals(expected, toBigDecimal(txs.get(0).get("amount")), "ERROR in expected");
133133

134134
long interestCount = countInterestOnDate(accountId, marchDate.minusDays(1));
135135
long overdraftCount = countOverdraftOnDate(accountId, marchDate.minusDays(1));
@@ -178,7 +178,7 @@ public void testOverdraftInterestWithOverdraftProduct() {
178178
List<HashMap> txs = getInterestTransactions(accountId);
179179
Assertions.assertEquals(expected, BigDecimal.valueOf(((Double) txs.get(0).get("amount"))));
180180

181-
BigDecimal runningBalance = BigDecimal.valueOf(((Double) txs.get(0).get("runningBalance")));
181+
BigDecimal runningBalance = BigDecimal.valueOf(((Number) txs.get(0).get("runningBalance")).doubleValue());
182182
Assertions.assertTrue(MathUtil.isLessThanZero(runningBalance), "Running balance is not less than zero");
183183

184184
long interestCount = countInterestOnDate(accountId, marchDate.minusDays(1));
@@ -230,10 +230,10 @@ public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceLess
230230

231231
List<HashMap> txs = getInterestTransactions(accountId);
232232
for (HashMap tx : txs) {
233-
BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount")));
233+
BigDecimal amt = BigDecimal.valueOf(((Number) tx.get("amount")).doubleValue());
234234
@SuppressWarnings("unchecked")
235235
Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType");
236-
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue());
236+
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Number) typeMap.get("id")).intValue());
237237

238238
if (type.isInterestPosting()) {
239239
long days = ChronoUnit.DAYS.between(startDate, withdrawalDate);
@@ -295,10 +295,10 @@ public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceGrea
295295

296296
List<HashMap> txs = getInterestTransactions(accountId);
297297
for (HashMap tx : txs) {
298-
BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount")));
298+
BigDecimal amt = BigDecimal.valueOf(((Number) tx.get("amount")).doubleValue());
299299
@SuppressWarnings("unchecked")
300300
Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType");
301-
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue());
301+
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Number) typeMap.get("id")).intValue());
302302

303303
if (type.isOverDraftInterestPosting()) {
304304
long days = ChronoUnit.DAYS.between(startDate, depositDate);
@@ -357,12 +357,12 @@ public void testPostInterestNotZero() {
357357

358358
long daysFebruary = ChronoUnit.DAYS.between(startDate, februaryDate);
359359
BigDecimal expectedFebruary = calcInterestPosting(productHelper, amountDeposit, daysFebruary);
360-
Assertions.assertEquals(expectedFebruary, BigDecimal.valueOf(((Double) txsFebruary.get(0).get("amount"))));
360+
Assertions.assertEquals(expectedFebruary, BigDecimal.valueOf(((Number) txsFebruary.get(0).get("amount")).doubleValue()));
361361

362362
final LocalDate withdrawalDate = LocalDate.of(2025, 2, 1);
363363
final String withdrawal = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(withdrawalDate);
364364

365-
BigDecimal runningBalance = new BigDecimal(txsFebruary.get(0).get("runningBalance").toString());
365+
BigDecimal runningBalance = BigDecimal.valueOf(((Number) txsFebruary.get(0).get("runningBalance")).doubleValue());
366366
String withdrawalRunning = runningBalance.setScale(2, RoundingMode.HALF_UP).toString();
367367

368368
savingsAccountHelper.withdrawalFromSavingsAccount(accountId, withdrawalRunning, withdrawal,
@@ -377,13 +377,13 @@ public void testPostInterestNotZero() {
377377
List<HashMap> txs = getInterestTransactions(accountId);
378378

379379
for (HashMap tx : txs) {
380-
BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount")));
380+
BigDecimal amt = BigDecimal.valueOf(((Number) tx.get("amount")).doubleValue());
381381
@SuppressWarnings("unchecked")
382382
Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType");
383-
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue());
383+
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Number) typeMap.get("id")).intValue());
384384
if (type.isOverDraftInterestPosting()) {
385385
long days = ChronoUnit.DAYS.between(withdrawalDate, marchDate);
386-
BigDecimal decimalsss = new BigDecimal(txsFebruary.get(0).get("runningBalance").toString())
386+
BigDecimal decimalsss = BigDecimal.valueOf(((Number) txsFebruary.get(0).get("runningBalance")).doubleValue())
387387
.subtract(runningBalance.setScale(2, RoundingMode.HALF_UP));
388388
BigDecimal withdraw = new BigDecimal(amountWithdrawal);
389389
BigDecimal res = withdraw.subtract(decimalsss);
@@ -444,6 +444,103 @@ public void testPostInterestForDuplicatePrevention() {
444444
});
445445
}
446446

447+
@Test
448+
public void testPostInterestPreventsDuplicateOnSameDay() {
449+
runAt("15 April 2025", () -> {
450+
final String amount = "5000";
451+
452+
final Account assetAccount = accountHelper.createAssetAccount();
453+
final Account incomeAccount = accountHelper.createIncomeAccount();
454+
final Account expenseAccount = accountHelper.createExpenseAccount();
455+
final Account liabilityAccount = accountHelper.createLiabilityAccount();
456+
final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount");
457+
final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control");
458+
final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable");
459+
460+
final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(
461+
interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(),
462+
interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount);
463+
464+
final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025");
465+
final LocalDate startDate = LocalDate.of(2025, 3, 1);
466+
final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate);
467+
468+
final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId,
469+
SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString);
470+
savingsAccountHelper.approveSavingsOnDate(accountId, startDateString);
471+
savingsAccountHelper.activateSavings(accountId, startDateString);
472+
savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID);
473+
474+
schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME);
475+
476+
List<HashMap> txsAfterFirstRun = getInterestTransactions(accountId);
477+
Assertions.assertEquals(1, txsAfterFirstRun.size(), "Expected exactly one interest transaction after first job run");
478+
479+
HashMap summaryAfterFirstRun = savingsAccountHelper.getSavingsSummary(accountId);
480+
BigDecimal balanceAfterFirstRun = BigDecimal.valueOf(((Number) summaryAfterFirstRun.get("accountBalance")).doubleValue());
481+
482+
schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME);
483+
484+
List<HashMap> txsAfterSecondRun = getInterestTransactions(accountId);
485+
Assertions.assertEquals(1, txsAfterSecondRun.size(),
486+
"Expected still only one interest transaction after second job run on same day - duplicate should be prevented");
487+
488+
HashMap summaryAfterSecondRun = savingsAccountHelper.getSavingsSummary(accountId);
489+
BigDecimal balanceAfterSecondRun = BigDecimal.valueOf(((Number) summaryAfterSecondRun.get("accountBalance")).doubleValue());
490+
491+
Assertions.assertEquals(balanceAfterFirstRun, balanceAfterSecondRun,
492+
"Account balance should remain unchanged after second job run - no duplicate interest should be posted");
493+
});
494+
}
495+
496+
@Test
497+
public void testPostInterestSkipsCurrentPeriodButAllowsNewPeriod() {
498+
runAt("20 May 2025", () -> {
499+
final String amount = "8000";
500+
501+
final Account assetAccount = accountHelper.createAssetAccount();
502+
final Account incomeAccount = accountHelper.createIncomeAccount();
503+
final Account expenseAccount = accountHelper.createExpenseAccount();
504+
final Account liabilityAccount = accountHelper.createLiabilityAccount();
505+
final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount");
506+
final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control");
507+
final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable");
508+
509+
final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(
510+
interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(),
511+
interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount);
512+
513+
final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025");
514+
final LocalDate startDate = LocalDate.of(2025, 4, 1);
515+
final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate);
516+
517+
final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId,
518+
SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString);
519+
savingsAccountHelper.approveSavingsOnDate(accountId, startDateString);
520+
savingsAccountHelper.activateSavings(accountId, startDateString);
521+
savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID);
522+
523+
schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME);
524+
525+
List<HashMap> txsAfterFirstPeriod = getInterestTransactions(accountId);
526+
Assertions.assertEquals(1, txsAfterFirstPeriod.size(), "Expected exactly one interest transaction after first posting period");
527+
528+
HashMap summaryAfterFirstPeriod = savingsAccountHelper.getSavingsSummary(accountId);
529+
BigDecimal balanceAfterFirstPeriod = BigDecimal.valueOf(((Number) summaryAfterFirstPeriod.get("accountBalance")).doubleValue());
530+
531+
schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME);
532+
533+
List<HashMap> txsAfterSecondRunSameDay = getInterestTransactions(accountId);
534+
Assertions.assertEquals(1, txsAfterSecondRunSameDay.size(),
535+
"Expected still only one interest transaction after second run on same day - current period should be skipped");
536+
537+
HashMap summaryAfterSecondRun = savingsAccountHelper.getSavingsSummary(accountId);
538+
BigDecimal balanceAfterSecondRun = BigDecimal.valueOf(((Number) summaryAfterSecondRun.get("accountBalance")).doubleValue());
539+
Assertions.assertEquals(balanceAfterFirstPeriod, balanceAfterSecondRun,
540+
"Account balance should remain unchanged after second run on same day - current period posting should be skipped");
541+
});
542+
}
543+
447544
private void cleanupSavingsAccountsFromDuplicatePreventionTest() {
448545
try {
449546
LOG.info("Starting cleanup of savings accounts after duplicate prevention test");
@@ -482,7 +579,7 @@ private List<HashMap> getInterestTransactions(Integer savingsAccountId) {
482579
for (HashMap tx : all) {
483580
@SuppressWarnings("unchecked")
484581
Map<String, Object> txType = (Map<String, Object>) tx.get("transactionType");
485-
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) txType.get("id")).intValue());
582+
SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Number) txType.get("id")).intValue());
486583
if (type.isInterestPosting() || type.isOverDraftInterestPosting()) {
487584
filtered.add(tx);
488585
}
@@ -565,7 +662,7 @@ private boolean isDate(HashMap tx, LocalDate expected) {
565662
@SuppressWarnings("unchecked")
566663
private SavingsAccountTransactionType txType(HashMap tx) {
567664
Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType");
568-
return SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue());
665+
return SavingsAccountTransactionType.fromInt(((Number) typeMap.get("id")).intValue());
569666
}
570667

571668
private long countInterestOnDate(Integer accountId, LocalDate date) {

0 commit comments

Comments
 (0)