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
@@ -217,6 +217,21 @@ public final class SavingsAccountData implements Serializable {
private BigDecimal interestPosting;
private BigDecimal overdraftPosting;
/**
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
* Such interest never stays in the account, so it must never compound -- every period earns on the
* principal alone. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean transferInterestToLinkedAccount;
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
}
public boolean isTransferInterestToLinkedAccount() {
return this.transferInterestToLinkedAccount;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -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.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);
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate,
backdatedTxnsAllowedTill, postReversals);
}
} else {
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)) {
@@ -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());
}
}
@@ -151,6 +151,9 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom<L
if (effectiveInterestRate == null || effectiveInterestRate.compareTo(BigDecimal.ZERO) == 0) {
effectiveInterestRate = slab.slabFields().annualInterestRate();
}
// Only one slab should ever match a given period/amount; stop at the first match instead of
// letting a later slab in iteration order silently overwrite it.
break;
}
}
@@ -515,6 +515,52 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return identifiers;
}
/**
* Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto
* reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the
* correction, so plain savings and recurring deposit behaviour is unchanged.
*/
protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) {
return true;
}
/**
* Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an
* interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation
* (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit
* behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called
* instead.
*/
public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) {
return true;
}
/**
* Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest
* recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running
* balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail,
* regardless of whether any posting correction was needed -- without re-running the interest
* posting/correction loop that recalculation drives.
*/
public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill,
final boolean postReversals) {
Money openingAccountBalance = Money.zero(this.currency);
if (backdatedTxnsAllowedTill) {
if (this.summary.getLastInterestCalculationDate() == null) {
openingAccountBalance = Money.zero(this.currency);
} else {
openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate());
}
}
recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals);
if (!backdatedTxnsAllowedTill) {
this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions);
} else {
this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null,
this.savingsAccountTransactions);
}
}
public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth,
final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) {
@@ -581,7 +627,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
} else {
correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated());
}
if (correctionRequired) {
if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) {
boolean applyWithHoldTaxForOldTransaction = false;
postingTransaction.reverse();
SavingsAccountTransaction reversal = null;
@@ -1047,14 +1093,59 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
.sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList());
}
/**
* Transactions that should NOT count as part of this account's own balance history for interest
* calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an
* input to it) AND, for accounts that transfer their interest out to a linked account
* (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later
* sweeps that same interest back out (see TransferInterestToSavingsTasklet).
* <p>
* Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here
* -- the debit half stays, permanently understating this account's balance (and therefore every
* subsequent period's interest) by that amount from the first transfer onward, even though the real
* ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the
* withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this
* account, so excluding those ids here closes that gap.
*/
protected Collection<Long> interestTransferWithdrawalTransactionIds() {
if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) {
return Collections.emptyList();
}
return this.savingsHelper.fetchPostInterestTransactionIds(getId());
}
/**
* The given transactions minus the interest-transfer withdrawal legs, exactly as
* {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever
* sees them.
* <p>
* Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's
* balance segment by walking backwards and ending the previous segment one day before the next
* transaction -- so a transaction present there but stripped out here truncates the preceding segment by a
* day, with nothing left in the engine's own list to account for that day. On an FD Special, whose
* transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently
* cost the very first period one earning day.
*/
protected List<SavingsAccountTransaction> excludeInterestTransferWithdrawals(
final List<SavingsAccountTransaction> transactions) {
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds();
if (interestTransferWithdrawalIds.isEmpty()) {
return transactions;
}
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
.collect(Collectors.toList());
}
protected List<SavingsAccountTransaction> retreiveOrderedNonInterestPostingTransactions() {
final List<SavingsAccountTransaction> listOfTransactionsSorted = retrieveListOfTransactions();
final List<SavingsAccountTransaction> orderedNonInterestPostingTransactions = new ArrayList<>();
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds();
for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) {
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
orderedNonInterestPostingTransactions.add(transaction);
}
}
@@ -1066,10 +1157,12 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
final List<SavingsAccountTransaction> listOfTransactionsSorted = retrieveSortedTransactions();
final List<SavingsAccountTransaction> orderedNonInterestPostingTransactions = new ArrayList<>();
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds();
for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) {
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
orderedNonInterestPostingTransactions.add(transaction);
}
}