Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,25 @@ public class SavingsSchedularInterestPoster {
private Collection<SavingsAccountData> savingAccounts;
private boolean backdatedTxnsAllowedTill;

@Transactional(isolation = Isolation.READ_UNCOMMITTED, rollbackFor = Exception.class)
@Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the SERIALIZABLE here might be better than the READ_UNCOMMITTED, i dont think we are addressing the underlying issue properly.

I would rather recommend a different approach:

  • Is it possible to ensure no two SavingsSchedularInterestPoster got the very same savings account? If we can ensure no two parallel executed postInterest work on the very same savings account, i see no reason why would any double posting and we dont need to enforce SERIALIZABLE isolation which usually means strict and heavy locking of the resource which does not help the performance.

Alternative solution:

  • Is it possible to introduce constraints which ensure no two interest to be posted on the very same date of the very same savings account? We can even build retry strategy too based on this constraints:
  • Read savings account -> post interest -> if fails, refetch the savings account and check again whether interest needed to be posted.

It might go beyond the scope of the original story, but to be frank, this would be the proper way to do it.

TLDR: 1 savings account should be processed by 1 thread entirely and avoid situations where 1 savings account might be processed by many threads.

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);
if (hasNewInterestTransactions(savingsAccountDataRet)) {
savingsAccountDataList.add(savingsAccountDataRet);
}
} catch (Exception e) {
errors.add(e);
}
Expand Down Expand Up @@ -109,7 +117,6 @@ private void batchUpdateJournalEntries(final List<SavingsAccountData> 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
Expand Down Expand Up @@ -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 yesterday) {
LocalDate interestPostedTillDate = savingsAccountData.getSummary().getInterestPostedTillDate();
if (interestPostedTillDate == null) {
return false;
}
return interestPostedTillDate.isAfter(yesterday);
}

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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap> 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<HashMap> 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<HashMap> 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<HashMap> 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");
Expand Down
Loading