fix(deposits): stop FD Special interest postings from self-correcting after settlement
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s

FD Special (monthly interest payout) accounts post interest each month and
immediately sweep it to a linked savings account. Two independent interest
recalculation paths were treating that sweep withdrawal as ordinary account
activity and silently reversing + reposting already-settled periods at a
slightly different (rounding-drifted) amount — after the original, correct
amount had already been transferred out. This left the FD short by the
difference every time it happened.

Root causes (two separate engines, same underlying bug pattern):

1. SavingsAccountDomainServiceJpa#handleWithdrawal re-triggers a full
   postInterest()/calculateInterestUsing() after every withdrawal, including
   the sweep withdrawal itself. At that exact moment the withdrawal's own
   m_account_transfer_transaction linking row does not exist yet, so the
   balance-exclusion logic can't recognise it as a transfer leg — it gets
   treated as a real balance-reducing transaction and corrupts that period's
   (and any later period's) interest calculation.

2. SavingsAccountInterestPostingServiceImpl (the "Post Interest For Savings"
   batch job) has its own, separate reverse-and-repost correction loop with
   the same lack of a "was this period already settled" guard.

Fixes, both scoped strictly to FD Special (transfer_interest_to_linked_account
= true) so plain savings and recurring deposit behaviour is provably
unchanged (the new hooks all default to the prior behaviour unless
overridden):

- SavingsAccount: new isInterestPostingCorrectionAllowed(...) extension
  point guarding the postInterest() correction loop; new
  isPostWithdrawalInterestRecalculationRequired(...) hook checked by
  handleWithdrawal before it re-recalculates; new
  refreshBalancesAndSummaryAfterTransfer(...) — the running-balance/summary
  bookkeeping postInterest() always performs, without re-running the
  posting/correction loop, so skipping the full recalculation doesn't leave
  the withdrawal's own running balance and the account summary stale.
- FixedDepositAccount: overrides both hooks using the existing
  interestAlreadyPaidOut(...) date-matched sweep-withdrawal check (already
  used by postMaturityInterest) so an already-swept period is never
  recalculated again, from either the withdrawal path or a later job run.
- SavingsAccountInterestPostingServiceImpl: mirrors the same guard
  (isInterestAlreadySweptOut) in its own correction loop for the batch job.

Unrelated correctness fixes carried in the same files, applicable to any
account type (not FD-Special-specific), found while investigating the above:

- SavingsAccountWritePlatformServiceJpaRepositoryImpl: interest-payable GL
  entries were reading a financial_account_type that never exists in
  acc_product_mapping, always resolving to account id 0. Now reads the
  correct getGlAccountIdForInterestOnSavings().
- DepositAccountInterestRateChart#getApplicableInterestRate: missing break
  in the slab-matching loop meant HashSet's non-deterministic iteration
  order could pick an arbitrary matching slab instead of the intended one.
- SavingsAccountReadPlatformServiceImpl / SavingsAccountData: thread the
  real transfer_interest_to_linked_account flag into the batch interest
  engine's per-account data so it can correctly disable interest
  compounding for FD Special (previously hard-coded false, causing the
  batch job to compound interest that should reset to zero each period).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 18:53:12 +05:30
co-authored by Claude Sonnet 5
parent 1a2a945464
commit a42a874811
8 changed files with 352 additions and 16 deletions
@@ -150,6 +150,18 @@ public class FixedDepositAccount extends SavingsAccount {
}
}
/**
* Re-forces the standing instructions that make FD Special work -- see
* {@link DepositAccountTermAndPreClosure#forceMonthlyPayoutToLinkedSavings}. Called from the modify-application
* write path after this same request's own linked-account resolution has settled, since
* {@link #modifyApplication} (via {@code accountTermAndPreClosure.update}) has no fdType awareness and would
* otherwise let a SPECIAL account's transferInterestToLinkedAccount/onAccountClosureType/transferToSavingsId
* be silently unset by that same request.
*/
public void forceSpecialFdMonthlyPayoutSettings(final Long transferToSavingsId) {
this.accountTermAndPreClosure.forceMonthlyPayoutToLinkedSavings(transferToSavingsId);
}
@Override
protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) {
boolean isPreMatureClosure = false;
@@ -250,7 +262,13 @@ public class FixedDepositAccount extends SavingsAccount {
totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned());
}
final Money depositAmount = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
final Money maturityAmount = depositAmount.plus(totalInterestPayable);
// FD Special pays each period's interest out to the linked savings account every month instead of
// compounding it into this account's own balance (see DepositAccountAssembler and
// TransferInterestToSavingsTasklet) -- so unlike a cumulative FD, this account's own balance never
// actually grows to depositAmount + totalInterestPayable by maturity; it stays at depositAmount
// throughout the term. Showing the compounded projection here would be a customer-facing figure
// that never happens. SINGLE/DOUBLE FDs are unaffected -- they still get the real compounded value.
final Money maturityAmount = "SPECIAL".equalsIgnoreCase(fdType()) ? depositAmount : depositAmount.plus(totalInterestPayable);
this.accountTermAndPreClosure.updateMaturityDetails(maturityAmount.getAmount(), maturityDate);
}
@@ -358,6 +376,91 @@ public class FixedDepositAccount extends SavingsAccount {
return allPostingPeriods;
}
/**
* Read-only period-by-period (e.g. month-wise, when posting is Monthly) projected interest schedule across
* the account's current term -- built with the exact same {@link PostingPeriod} engine as
* {@link #calculateInterestPayable}, but deliberately NOT sharing that method's body: that one also calls
* {@code this.summary.updateFromInterestPeriodSummaries(...)}, which overwrites the account's real
* totalInterestEarned/lastInterestCalculationDate -- fine when it runs once as part of an actual save, but
* corrupting if repeated on every GET request. This variant skips that call entirely, so it is safe to
* invoke as many times as a read-service likes without ever mutating the account. Used to show FD Special
* customers/staff exactly how much interest is projected to post each month across the whole term, since
* that figure otherwise only exists internally as a discarded intermediate value.
*/
public List<PostingPeriod> projectInterestSchedule(final MathContext mc, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
List<SavingsAccountTransaction> transactions = retreiveOrderedNonInterestPostingTransactions();
if (transactions.isEmpty()) {
// Account activated but not yet funded (funding is a separate deposit step) -- project the
// schedule as if the planned deposit amount lands on day one, the same synthetic-transaction
// approach updateMaturityDateAndAmountBeforeAccountActivation uses for the pre-activation
// maturity preview. Without this every period here would show zero interest against a real,
// still-empty transaction history, while the maturity amount (forced to depositAmount for
// SPECIAL FDs) already assumes the account gets funded -- this keeps the two consistent.
final Money transactionAmountMoney = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
final SavingsAccountTransaction syntheticDeposit = SavingsAccountTransaction.deposit(null, office(), null,
this.accountSubmittedOrActivationDate(), transactionAmountMoney, null);
syntheticDeposit.setRunningBalance(transactionAmountMoney);
syntheticDeposit.updateCumulativeBalanceAndDates(this.getCurrency(), interestCalculatedUpto());
transactions = new ArrayList<>();
transactions.add(syntheticDeposit);
}
final LocalDate maturityDate = maturityDate() != null ? maturityDate() : calculateMaturityDate();
// calculateInterestPayable's own "maturityDate" parameter is always fed maturityDate.minusDays(1)
// by its one real caller (see updateMaturityDateAndAmount above) -- the raw maturity date is only
// used for the end-of-day-balance reset either side of the calculation. Missing this -1 here made
// determineInterestPostingPeriods run one day too far, pushing the final period's end date (and
// therefore its amount) past the account's actual maturity date.
final LocalDate interestCalculationUpto = maturityDate.minusDays(1);
final boolean isPreMatureClosure = false;
this.resetAccountTransactionsEndOfDayBalances(transactions, maturityDate);
try {
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType
.fromInt(this.interestPostingPeriodType);
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(this.interestCompoundingPeriodType);
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(this.interestCalculationDaysInYearType);
final List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
depositStartDate(), interestCalculationUpto, postingPeriodType, financialYearBeginningMonth,
postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
: Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType
.fromInt(this.interestCalculationType);
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, interestCalculationUpto, isPreMatureClosure);
final Collection<Long> interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId());
final boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
transactions);
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance,
savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType,
interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), interestCalculationUpto,
interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation,
isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, financialYearBeginningMonth);
periodStartingBalance = postingPeriod.closingBalance();
allPostingPeriods.add(postingPeriod);
}
this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(),
isTransferInterestToOtherAccount());
return allPostingPeriods;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
}
public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
@@ -573,7 +676,13 @@ public class FixedDepositAccount extends SavingsAccount {
interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting());
this.transactions.add(newPostingTransaction);
recalucateDailyBalanceDetails = true;
} else {
} else if (!interestAlreadyPaidOut(postingTransaction)) {
// Once a period's interest has actually been swept out to the linked savings account it is
// settled history: reversing and re-posting it here would rewrite interest the customer was
// already paid and leave this account's books disagreeing with the cash that left it. But a
// posting still sitting IN the account is fair game -- that is how a stale amount (e.g. one
// the batch engine compounded) gets corrected at maturity. Testing the individual period,
// not the account-wide transfer flag, is the distinction that matters here.
final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod);
if (correctionRequired) {
postingTransaction.reverse();
@@ -595,6 +704,57 @@ public class FixedDepositAccount extends SavingsAccount {
this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions);
}
/**
* Whether this period's posted interest has already left the account for the linked savings account.
* <p>
* The sweep withdrawal carries the same transaction date as the interest posting it pays out (see
* DepositAccountReadPlatformService#retrieveDataForInterestTransfer, which pairs them by date), and
* {@link #interestTransferWithdrawalTransactionIds()} returns those WITHDRAWAL ids -- not the posting
* ids -- so the match has to be on date, not on id.
*/
private boolean interestAlreadyPaidOut(final SavingsAccountTransaction postingTransaction) {
final Collection<Long> sweepWithdrawalIds = interestTransferWithdrawalTransactionIds();
if (sweepWithdrawalIds.isEmpty()) {
return false;
}
for (final SavingsAccountTransaction transaction : this.transactions) {
if (transaction.isNotReversed() && sweepWithdrawalIds.contains(transaction.getId())
&& DateUtils.isEqual(transaction.getTransactionDate(), postingTransaction.getTransactionDate())) {
return true;
}
}
return false;
}
/**
* Guards the base {@link SavingsAccount#postInterest} correction loop, which base
* {@code SavingsAccountDomainServiceJpa#handleWithdrawal} re-triggers after every withdrawal -- including the
* sweep withdrawal that transfers a period's interest out to the linked savings account. Without this guard,
* that recalculation reverses and reposts the just-paid-out period at a freshly-recomputed (and slightly
* different, e.g. rounding) amount, leaving the already-executed sweep withdrawal mismatched against the new
* posting.
*/
@Override
protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) {
return !interestAlreadyPaidOut(postingTransaction);
}
/**
* The interest-transfer sweep withdrawal only ever fires for a period whose interest is already posted (see
* DepositAccountReadPlatformService#retrieveDataForInterestTransfer), so there is nothing new for this
* recalculation to find. Worse, at the moment this withdrawal's own post-withdrawal recalculation would run,
* the m_account_transfer_transaction linking row that marks it as a transfer withdrawal does not exist yet
* (it is created by AccountTransfersWritePlatformServiceImpl only after both transfer legs are booked) -- so
* the exclusion in {@link #resetAccountTransactionsEndOfDayBalances} cannot yet recognise it, and the
* recalculation would wrongly treat it as a real balance-reducing transaction for its own and every later
* period, reversing and reposting already-settled interest at a corrupted amount. Skipping the recalculation
* for this specific withdrawal avoids the corruption at its source.
*/
@Override
public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) {
return !isInterestTransfer;
}
public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
@@ -809,6 +969,24 @@ public class FixedDepositAccount extends SavingsAccount {
return uptoMaturityDate;
}
/**
* An FD that pays its interest out monthly (FD Special) carries a transfer-out WITHDRAWAL for every posted
* period, and {@link #retreiveOrderedNonInterestPostingTransactions()} strips those out before the
* interest engine sees them. The balance-segment walk has to strip the same ones: that withdrawal is dated
* the same day as the posting it pays out, so leaving it in ends the funding deposit's balance segment one
* day early while the engine's own list has nothing to cover that day -- costing the first period one
* earning day (e.g. 6 days instead of 25-Jul..31-Jul's real 7).
* <p>
* A no-op for every other account: the excluded-id lookup short-circuits to empty unless
* {@link #isTransferInterestToOtherAccount()} is true.
*/
@Override
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransaction> accountTransactionsSorted,
final LocalDate interestPostingUpToDate) {
super.resetAccountTransactionsEndOfDayBalances(excludeInterestTransferWithdrawals(accountTransactionsSorted),
interestPostingUpToDate);
}
public void validateDomainRules() {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
@@ -114,13 +114,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final LocalDate today = DateUtils.getBusinessLocalDate();
if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) {
account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
if (account.isPostWithdrawalInterestRecalculationRequired(transactionBooleanValues.isInterestTransfer())) {
if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) {
account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
} else {
account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(),
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate,
backdatedTxnsAllowedTill, postReversals);
}
} else {
account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(),
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill,
postReversals);
account.refreshBalancesAndSummaryAfterTransfer(today, backdatedTxnsAllowedTill, postReversals);
}
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
@@ -119,7 +119,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
} else {
correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated());
}
if (correctionRequired) {
if (correctionRequired && !isInterestAlreadySweptOut(interestPostingTransactionDate, savingsAccountData)) {
boolean applyWithHoldTaxForOldTransaction = false;
postingTransaction.reverse();
@@ -185,6 +185,28 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
return savingsAccountData;
}
/**
* Mirrors {@code FixedDepositAccount#interestAlreadyPaidOut}: for an FD Special account whose interest sweeps
* out to a linked savings account, a withdrawal dated the same day as an interest posting means that period's
* interest has already left the account (see DepositAccountReadPlatformService#retrieveDataForInterestTransfer,
* which pairs them by date). Recalculating and reposting it here -- as this batch job otherwise would for
* every account, FD or not -- would silently disagree with money that has already moved. Scoped to FD Special
* only, so plain savings and recurring deposit batch posting is unaffected.
*/
private boolean isInterestAlreadySweptOut(final LocalDate interestPostingTransactionDate, final SavingsAccountData savingsAccountData) {
if (!savingsAccountData.isTransferInterestToLinkedAccount()
|| !DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId())) {
return false;
}
for (final SavingsAccountTransactionData transaction : savingsAccountData.getSavingsAccountTransactionData()) {
if (transaction.isWithdrawal() && transaction.isNotReversed()
&& DateUtils.isEqual(transaction.getTransactionDate(), interestPostingTransactionDate)) {
return true;
}
}
return false;
}
protected SavingsAccountTransactionData findTransactionFor(final LocalDate postingDate,
final List<SavingsAccountTransactionData> transactions) {
SavingsAccountTransactionData transaction = null;
@@ -341,8 +363,19 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
}
}
// A deposit that sweeps its interest out to a linked savings account every period (FD Special)
// must never compound: the interest does not stay in the account, so each period earns on the
// principal alone. This used to be hard-coded false here, which made this batch engine disagree
// with the domain engine (SavingsAccount#calculateInterestUsing, which passes the real flag) --
// the same account could be posted a compounded amount by the nightly job and a simple amount by
// its own maturity run, minutes apart. Deliberately scoped to fixed deposits so recurring
// deposits keep their existing behaviour.
// getDepositTypeId() rather than depositAccountType(), which dereferences the enum unguarded --
// this runs per account inside a batch job, so a null deposit type must not take the whole run down.
final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
getLockedInUntilLocalDate(savingsAccountData), false);
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
@@ -349,6 +349,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final StringBuilder sqlBuilder = new StringBuilder(400);
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
sqlBuilder.append("sa.status_enum as statusEnum, ");
@@ -446,6 +447,12 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 ");
sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 ");
sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 ");
// Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a
// cumulative one: interest that is swept out to a linked account each month must never
// compound, and without this flag the engine compounded it. Left join because ordinary
// savings accounts have no term-and-preclosure row at all.
sqlBuilder.append(
"left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id ");
this.schemaSql = sqlBuilder.toString();
}
@@ -668,6 +675,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings);
savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl);
// Monthly-payout deposits must not compound: their interest leaves the account every
// period, so each period earns on the principal alone.
savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount"));
}
if (!transMap.containsValue(transactionId)) {
@@ -1142,8 +1152,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
java.util.Map<String, Object> nomineeMap = gson.fromJson(nomineeDataStr,
new com.google.gson.reflect.TypeToken<java.util.Map<String, Object>>() {}.getType());
accountData.setNomineeData(nomineeMap);
} catch (Exception ignored) {
// intentionally ignored
} catch (Exception ignored) {
// intentionally ignored
}
}
@@ -643,7 +643,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable());
}
} else {
accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestPayable());
accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestOnSavings());
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl());
}
}