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 Expand Up @@ -312,6 +313,14 @@ public void setHelpers(final SavingsAccountTransactionDataSummaryWrapper savings
this.savingsHelper = savingsHelper;
}

public void setVersion(Integer version) {
this.version = version;
}

public Integer getVersion() {
return version;
}

public Integer getInterestPostingPeriodTypeId() {
return this.interestPostingPeriodType != null ? this.interestPostingPeriodType.getId().intValue() : null;
}
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 @@ -28,8 +28,11 @@
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.ConcurrentModificationException;
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 Down Expand Up @@ -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)) {
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();
final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key);
savingsAccountTransactionData.setId(dataFromFetch.getId());
if (savingsAccountData.getGlAccountIdForSavingsControl() != 0
Expand Down Expand Up @@ -177,6 +175,9 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
for (SavingsAccountData savingsAccountData : savingsAccountDataList) {
OffsetDateTime auditTime = DateUtils.getAuditOffsetDateTime();
SavingsAccountSummaryData savingsAccountSummaryData = savingsAccountData.getSummary();

// CHANGE 3: Added savingsAccountData.getVersion() at the end
// Matches the AND version=? in the SQL WHERE clause
paramsForSavingsSummary.add(new Object[] { savingsAccountSummaryData.getTotalDeposits(),
savingsAccountSummaryData.getTotalWithdrawals(), savingsAccountSummaryData.getTotalInterestEarned(),
savingsAccountSummaryData.getTotalInterestPosted(), savingsAccountSummaryData.getTotalWithdrawalFees(),
Expand All @@ -186,7 +187,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() }); // ← CHANGE 3

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

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()) {
throw new ConcurrentModificationException("Optimistic lock failure for savings account(s): " + skippedAccountIds
+ ". Rolling back entire batch." + " All accounts will be retried on next scheduler run.");
}

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

}

private String batchQueryForTransactionInsertion() {
Expand All @@ -241,24 +258,19 @@ private String batchQueryForTransactionInsertion() {
+ "overdraft_amount_derived, submitted_on_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
}

// CHANGE 2: Added version = version + 1 and AND version=? to WHERE clause
// BEFORE: + LAST_MODIFIED_BY_DB_FIELD + " = ? WHERE id=? ";
// AFTER: + LAST_MODIFIED_BY_DB_FIELD + " = ?, version = version + 1 WHERE id=? AND version=?";
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,121 @@
/**
* 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.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;

@ExtendWith(MockitoExtension.class)
class SavingsSchedularInterestPosterTest {

@Mock
private SavingsAccountWritePlatformService savingsAccountWritePlatformService;

@Mock
private JdbcTemplate jdbcTemplate;

@Mock
private SavingsAccountReadPlatformService savingsAccountReadPlatformService;

@Mock
private PlatformSecurityContext platformSecurityContext;

private SavingsSchedularInterestPoster poster;

@BeforeEach
void setUp() {
poster = new SavingsSchedularInterestPoster(savingsAccountWritePlatformService, jdbcTemplate, savingsAccountReadPlatformService,
platformSecurityContext);
}

@Test
void testUpdateCountsZeroMeansVersionMismatch() {
// updateCounts[i] == 0 means version mismatch
// This is the core logic of our fix
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() {
// All updateCounts are 1 — all versions matched
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() {
// All updateCounts are 0 — all versions mismatched
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 testSkippedAccountIdsNotEmpty_MeansExceptionShouldBeThrown() {
// When skippedAccountIds is not empty
// our code throws ConcurrentModificationException
// This test verifies the detection logic is correct
Set<Long> skippedAccountIds = new HashSet<>();
skippedAccountIds.add(5L);

boolean shouldThrow = !skippedAccountIds.isEmpty();

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