From 812eafc191f609c0b8aff06eb29e9dcc6f3a1819 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 20 Oct 2026 12:20:54 +0530 Subject: [PATCH] fix(accounting): clear savings-to-savings transfers through the product's Transfers In Suspense GL 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) - 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 = ; 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 --- .../service/AccountingProcessorHelper.java | 67 +++++++++++++++++++ ...ountTransfersWritePlatformServiceImpl.java | 60 ++++++++++++----- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 15ee85c43..a591b75d7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -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 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 0ddae7b62..e3c1caa99 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -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,