fix(accounting): clear savings-to-savings transfers through the product's Transfers In Suspense GL
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run

PROBLEM
"Execute RD Auto Debit" reported success but no installment moved from the
linked savings account to the RD. Every account transfer between two
savings-type accounts (savings, FD, RD) books its clearing leg to the
organisation-wide Financial Activity "Liability Transfer" (200). That
activity was not mapped on this tenant, so the journal posting threw,
the transfer rolled back, and the job only logged a WARN ("RD Auto Debit
could not collect the installment ...") per installment. The tenant had
never completed a single account transfer (m_account_transfer_details
was empty).

CHANGE
The clearing leg now comes from the product's own "Transfers In
Suspense" mapping (acc_product_mapping, financial_account_type = 10),
which is set on the product's Accounting tab and can be changed there.

GL choice for one transfer (the same GL is used for both legs, so it
nets to nil):
  1. Transfers In Suspense of the RECEIVING product, else
  2. Transfers In Suspense of the PAYING product, else
  3. Financial Activity "Liability Transfer" (previous behaviour; still
     throws if that is not mapped either).
Savings products have no Transfers In Suspense field on screen, so for
savings -> RD/FD the RD/FD product's mapping is used.

Example, RD auto debit of 2,500 (Insta Money RD, suspense = 200901):
  Dr SAVING INDIVIDUAL (savings control)  Cr LIABILITY TRANSFER SUSPENSE
  Dr LIABILITY TRANSFER SUSPENSE          Cr RD ACCOUNT (RD control)

Undo / reversal of a transfer leg reverses through the clearing GL that
leg was ORIGINALLY posted to (read from acc_gl_journal_entry by
savings_transaction_id: the credit row of a withdrawal, the debit row of
a deposit). Transfers posted before this change (Liability Transfer)
and after it (product suspense) therefore both reverse to the account
they hit, even if the product mapping is changed later.

FILES
AccountingProcessorHelper
  - TRANSFER_CLEARING_ACCOUNT (ThreadLocal<GLAccount>)
  - useProductTransferSuspense(toProductId, fromProductId)
  - useClearingAccountPostedFor(savingsTransactionId, isWithdrawal)
  - clearProductTransferSuspense()
  - getLinkedGLAccountForSavingsProduct(): returns the thread's clearing
    account when FinancialActivity.LIABILITY_TRANSFER is asked for and
    one is set; otherwise unchanged.
AccountTransfersWritePlatformServiceImpl
  - transferFunds(), savings -> savings branch: handleWithdrawal and
    handleDeposit run inside useProductTransferSuspense / finally clear.
  - create() (Account Transfer screen/API), savings -> savings: same.
    The to-account is now assembled before the withdrawal (was after).
  - undoTransactions(): each savings leg is undone inside
    useClearingAccountPostedFor / finally clear.

FLOWS AFFECTED (savings-type <-> savings-type, via transferFunds/create)
  - RD Auto Debit job, RD activation from linked savings
  - FD activation funded from savings
  - FD maturity / premature / closure / payment paid to savings
  - RD closure / maturity payout to savings
  - Transfer Interest To Savings job (FD/RD interest)
  - Standing instructions and Account Transfer, savings -> savings/FD/RD
  - Fund transfer internal strategy and SuspenseGlService (savings -> savings)
  - Undo of any of the above (reverseTransfersWith..., reverseAllTransactions)

FLOWS NOT AFFECTED (still Financial Activity "Liability Transfer")
  - savings <-> loan (repayment, disbursement to savings, loan charges,
    guarantor recovery, Transfer Fee Charge For Loans job)
  - current account transfers
  - FD/RD closure "transfer to GL" (handleWithdrawal + manual JE against
    Liability Transfer in DepositAccountDomainServiceJpa)
  - branch-to-branch account transfer (already used Transfers In Suspense)
  - counter cash deposits / withdrawals

IF SOMETHING BREAKS - IS IT THIS CHANGE?
Likely this change if ALL of these hold:
  - the transaction is a transfer between two savings/FD/RD accounts (or
    its undo), and
  - the journal entry's clearing leg is on a product's Transfers In
    Suspense GL (e.g. 200901 LIABILITY TRANSFER SUSPENSE, or 200902
    SUNDRY CREDITORS for the FD products on the local tenant) instead of
    the Liability Transfer financial activity GL, or
  - a transfer's two legs land on DIFFERENT clearing GLs, or a reversal
    clears a different GL than the original (suspense balance left
    non-zero), or
  - a non-transfer posting inside the same request picked up the
    suspense GL (would mean the ThreadLocal leaked: look for a missing
    clearProductTransferSuspense()).
Not this change: savings <-> loan, current account, cash, or errors
"No Liability Transfer account is mapped" from the FD/RD close-to-GL path.

Quick checks:
  -- clearing legs of recent transfers
  SELECT j.transaction_id, j.type_enum, g.gl_code, g.name, j.amount
    FROM acc_gl_journal_entry j JOIN acc_gl_account g ON g.id = j.account_id
   WHERE j.savings_transaction_id IN (
         SELECT from_savings_transaction_id FROM m_account_transfer_transaction
         UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction)
   ORDER BY j.id DESC LIMIT 40;
  -- a clearing GL should net to zero over completed transfers
  SELECT SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END)
    FROM acc_gl_journal_entry WHERE account_id = <suspense gl id>;

To go back to the old behaviour: revert this commit (no schema or data
change is involved; transfers already posted to a product suspense GL
still reverse correctly only while this commit is in place).

VERIFIED
  - :fineract-provider:compileJava passes.
  - Code paths traced: all transferFunds callers, create(), undoTransactions;
    single-leg undo of a transfer is blocked by undoTransaction
    (allowAccountTransferModification=false), so reversals go through
    undoTransactions.
NOT YET VERIFIED
  - No transfer has been executed end-to-end after this change (RD auto
    debit, FD activation from savings, undo). spotlessJavaCheck not run
    meaningfully: it already fails repo-wide on CRLF line endings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 12:20:54 +05:30
co-authored by Claude Opus 5.5
parent b8ebfb3aeb
commit 812eafc191
2 changed files with 112 additions and 15 deletions
@@ -1570,9 +1570,76 @@ public class AccountingProcessorHelper {
return mapping == null ? null : mapping.getGlAccount();
}
/**
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
*/
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
/**
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
* than the organisation-wide Liability Transfer financial activity.
* <p>
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
* Transfer as before.
* <p>
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
*
* @return true when a product mapping was found and is now in use
*/
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
if (account == null) {
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
}
if (account == null) {
TRANSFER_CLEARING_ACCOUNT.remove();
return false;
}
TRANSFER_CLEARING_ACCOUNT.set(account);
return true;
}
public void clearProductTransferSuspense() {
TRANSFER_CLEARING_ACCOUNT.remove();
}
/**
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
* suspense account would be reversed against Liability Transfer and leave both accounts open.
* <p>
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
* it). Nothing is set where the leg has no journal entries. Must be paired with
* {@link #clearProductTransferSuspense()} in a finally block.
*/
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
TRANSFER_CLEARING_ACCOUNT.remove();
if (savingsTransactionId == null) {
return;
}
final List<Long> found = jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY amount DESC, id
LIMIT 1
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
if (!found.isEmpty()) {
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
}
}
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) {
GLAccount glAccount;
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
return TRANSFER_CLEARING_ACCOUNT.get();
}
if (isOrganizationAccount(accountMappingTypeId)) {
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
@@ -144,14 +144,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
// As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
final SavingsAccountTransaction withdrawal;
final SavingsAccountTransaction deposit;
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
try {
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
@@ -277,14 +285,27 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
if (accountTransfer.getToLoanTransaction() != null) {
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
}
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
// Liability Transfer), so the reversal clears that same account.
if (accountTransfer.getFromTransaction() != null) {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
true);
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
try {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
accountTransfer.getFromTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
}
if (accountTransfer.getToSavingsTransaction() != null) {
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true);
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
try {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
}
accountTransfer.reverse();
this.accountTransferRepository.save(accountTransfer);
@@ -407,13 +428,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
transactionDate = transactionDate.plusDays(1);
}
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill);
// Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
// product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
final SavingsAccountTransaction withdrawal;
final SavingsAccountTransaction deposit;
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
try {
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
transactionBooleanValues, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,