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 @@ -141,6 +141,7 @@ public final class SavingsAccountData implements Serializable {
private transient List<SavingsAccountTransactionData> newSavingsAccountTransactionData = new ArrayList<>();
private transient GroupGeneralData groupGeneralData;
private transient Long officeId;
private transient Integer version;
private transient Set<Long> existingTransactionIds = new HashSet<>();
private transient Set<Long> existingReversedTransactionIds = new HashSet<>();
private transient Long glAccountIdForSavingsControl;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ private static final class SavingAccountMapperForInterestPosting implements Resu
sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, ");
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
sqlBuilder.append("sa.version as version, ");
sqlBuilder.append("tg.id as taxGroupId, ");
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
sqlBuilder.append("from m_savings_account_transaction as sat ");
Expand Down Expand Up @@ -584,6 +585,8 @@ public List<SavingsAccountData> extractData(final ResultSet rs) throws SQLExcept

savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings);
savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl);
final Integer version = JdbcSupport.getInteger(rs, "version");
savingsAccountData.setVersion(version);
}

if (!transMap.containsValue(transactionId)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
Expand All @@ -43,6 +45,7 @@
import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -67,16 +70,10 @@ 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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why to remove this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@adamsaghy The pre-check was removed because it is not thread-safe under concurrent execution — two job instances can both pass the check before either commits, then both proceed to post. The optimistic lock (WHERE id=? AND version=?) in batchQueryForSavingsSummaryUpdate is the reliable guard. Happy to restore it as an additional early-exit optimization if you prefer, since it would reduce unnecessary processing in the non-concurrent case.

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);
Expand Down Expand Up @@ -115,6 +112,7 @@ 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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems unused...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@adamsaghy You're correct — isOverdraft is declared but never used. I'll remove it in the next commit.

final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key);
savingsAccountTransactionData.setId(dataFromFetch.getId());
if (savingsAccountData.getGlAccountIdForSavingsControl() != 0
Expand Down Expand Up @@ -177,6 +175,7 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
for (SavingsAccountData savingsAccountData : savingsAccountDataList) {
OffsetDateTime auditTime = DateUtils.getAuditOffsetDateTime();
SavingsAccountSummaryData savingsAccountSummaryData = savingsAccountData.getSummary();

paramsForSavingsSummary.add(new Object[] { savingsAccountSummaryData.getTotalDeposits(),
savingsAccountSummaryData.getTotalWithdrawals(), savingsAccountSummaryData.getTotalInterestEarned(),
savingsAccountSummaryData.getTotalInterestPosted(), savingsAccountSummaryData.getTotalWithdrawalFees(),
Expand All @@ -186,7 +185,8 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
savingsAccountSummaryData.getLastInterestCalculationDate(),
savingsAccountSummaryData.getInterestPostedTillDate() != null ? savingsAccountSummaryData.getInterestPostedTillDate()
: savingsAccountSummaryData.getLastInterestCalculationDate(),
auditTime, userId, savingsAccountData.getId() });
auditTime, userId, savingsAccountData.getId(), savingsAccountData.getVersion() });

List<SavingsAccountTransactionData> savingsAccountTransactionDataList = savingsAccountData.getSavingsAccountTransactionData();
for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) {
if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) {
Expand All @@ -213,8 +213,24 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
savingsAccountData.setUpdatedTransactions(savingsAccountTransactionDataList);
}

if (transRefNo.size() > 0) {
this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary);
if (!transRefNo.isEmpty()) {
int[] updateCounts = this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary);

Set<Long> skippedAccountIds = new HashSet<>();
for (int i = 0; i < updateCounts.length; i++) {
if (updateCounts[i] == 0) {
Long accountId = savingsAccountDataList.get(i).getId();
skippedAccountIds.add(accountId);
log.warn("Optimistic lock failure for savings account id={} — concurrent modification detected."
+ " Rolling back. Will retry on next run.", accountId);
}
}

if (!skippedAccountIds.isEmpty()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am wondering why to throw optimistic lock exception if we havent updated anything... i mean we calculated something, but it was not updated... so there is no duplicate posting anyway, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@adamsaghy The exception is thrown to keep the batch atomic. If account A's summary update succeeds but account B's fails the version check, without the exception account A's transactions would still be inserted while account B's summary remains out of sync — inconsistent state within the same batch. The throw causes full rollback so all accounts retry together on the next run. This behaviour is covered in SavingsSchedularInterestPosterTest — testSkippedAccountIdsNotEmptyMeansExceptionShouldBeThrown validates this path.

throw new OptimisticLockingFailureException("Optimistic lock failure for savings account(s): " + skippedAccountIds
+ ". Rolling back entire batch. All accounts will be retried on next scheduler run.");
}

this.jdbcTemplate.batchUpdate(queryForTransactionInsertion, paramsForTransactionInsertion);
this.jdbcTemplate.batchUpdate(queryForTransactionUpdate, paramsForTransactionUpdate);
log.debug("`Total No Of Interest Posting:` {}", transRefNo.size());
Expand All @@ -230,7 +246,6 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
}
batchUpdateJournalEntries(savingsAccountDataList, savingsAccountTransactionMap);
}

}

private String batchQueryForTransactionInsertion() {
Expand All @@ -245,20 +260,12 @@ private String batchQueryForSavingsSummaryUpdate() {
return "update m_savings_account set total_deposits_derived=?, total_withdrawals_derived=?, total_interest_earned_derived=?, total_interest_posted_derived=?, total_withdrawal_fees_derived=?, "
+ "total_fees_charge_derived=?, total_penalty_charge_derived=?, total_annual_fees_derived=?, account_balance_derived=?, total_overdraft_interest_derived=?, total_withhold_tax_derived=?, "
+ "last_interest_calculation_date=?, interest_posted_till_date=?, " + LAST_MODIFIED_DATE_DB_FIELD + " = ?, "
+ LAST_MODIFIED_BY_DB_FIELD + " = ? WHERE id=? ";
+ LAST_MODIFIED_BY_DB_FIELD + " = ?, version = version + 1 WHERE id=? AND version=?";
}

private String batchQueryForTransactionsUpdate() {
return "UPDATE m_savings_account_transaction "
+ "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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;

class SavingsSchedularInterestPosterTest {

@Test
void testUpdateCountsZeroMeansVersionMismatch() {
int[] updateCounts = { 1, 0, 1 };
Set<Long> skippedAccountIds = new HashSet<>();
List<Long> accountIds = List.of(1L, 2L, 3L);

for (int i = 0; i < updateCounts.length; i++) {
if (updateCounts[i] == 0) {
skippedAccountIds.add(accountIds.get(i));
}
}

assertEquals(1, skippedAccountIds.size(), "Exactly one account should be skipped");
assertTrue(skippedAccountIds.contains(2L), "Account 2 should be skipped due to version mismatch");
}

@Test
void testAllVersionsMatchNoSkippedAccounts() {
int[] updateCounts = { 1, 1, 1 };
Set<Long> skippedAccountIds = new HashSet<>();
List<Long> accountIds = List.of(1L, 2L, 3L);

for (int i = 0; i < updateCounts.length; i++) {
if (updateCounts[i] == 0) {
skippedAccountIds.add(accountIds.get(i));
}
}

assertTrue(skippedAccountIds.isEmpty(), "No accounts should be skipped when all versions match");
}

@Test
void testAllVersionsMismatchAllSkipped() {
int[] updateCounts = { 0, 0, 0 };
Set<Long> skippedAccountIds = new HashSet<>();
List<Long> accountIds = List.of(1L, 2L, 3L);

for (int i = 0; i < updateCounts.length; i++) {
if (updateCounts[i] == 0) {
skippedAccountIds.add(accountIds.get(i));
}
}

assertEquals(3, skippedAccountIds.size(), "All 3 accounts should be detected as version mismatched");
assertTrue(skippedAccountIds.containsAll(List.of(1L, 2L, 3L)), "All account IDs should be in skipped set");
}

@Test
void testSkippedAccountIdsNotEmptyMeansExceptionShouldBeThrown() {
Set<Long> skippedAccountIds = new HashSet<>();
skippedAccountIds.add(5L);

boolean shouldThrow = !skippedAccountIds.isEmpty();

assertTrue(shouldThrow, "Exception must be thrown when version mismatch detected");
}
}
Loading
Loading