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,