feat: fix standing instructions GL accounting for current account transfers

- AccountTransfersWritePlatformServiceImpl:
  * Direct GL posting for current↔savings/current transfers:
    DR debit-side account GL (with correct account entity_id)
    CR credit-side account GL (with correct account entity_id)
  * Prefix current account txn_id with CA (not C) so
    JournalEntryReadPlatformServiceImpl joins m_current_account
    and shows correct current account number on GL entries
  * Suppress redundant savings deposit/withdrawal GL entries
    (skipAccountingPostings=true) when current account handles
    the full double-entry directly
  * Separate performCurrentWithdrawalForLoan for Current→Loan
    transfers (loan domain service handles the other GL leg)
  * Reuse existing AccountTransferDetails for SI recurring runs
    so transactions appear in list-account-transactions history

- SavingsAccountDomainService / SavingsAccountDomainServiceJpa:
  * New handleDeposit overload with skipAccountingPostings flag
  * handleWithdrawal respects SavingsTransactionBooleanValues
    .isSkipAccountingPostings() to suppress redundant GL entries

- SavingsTransactionBooleanValues:
  * Added skipAccountingPostings field + overloaded constructor

- AccountingProcessorHelper:
  * New createDirectTransferJournalEntriesForSavings() method
    creates DR and CR entries with separate productId/entityId
    so each GL line shows the correct account number

- JournalEntryReadPlatformServiceImpl:
  * Join both m_savings_account and m_current_account always
  * COALESCE(ca.account_no, sa.account_no) to resolve correct
    account number regardless of transaction_id prefix

- ExecuteStandingInstructionsTasklet:
  * Load existing AccountTransferDetails via repository so
    subsequent SI runs link to the same transfer detail record

- ExecuteStandingInstructionsConfig / AccountConfiguration:
  * Wire AccountTransferDetailRepository and
    AccountingProcessorHelper into the relevant beans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-05-26 16:30:04 +05:30
co-authored by Claude Sonnet 4.6
parent 2251fd2259
commit 7d4f409202
9 changed files with 738 additions and 555 deletions
@@ -600,6 +600,27 @@ public class AccountingProcessorHelper {
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
} }
/**
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
*
* @param debitProductId product ID whose SAVINGS_CONTROL GL is debited
* @param debitEntityId entity (account) ID shown on the debit entry
* @param creditProductId product ID whose SAVINGS_CONTROL GL is credited
* @param creditEntityId entity (account) ID shown on the credit entry
*/
public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode,
final Long debitProductId, final Long debitEntityId,
final Long creditProductId, final Long creditEntityId,
final String transactionId, final LocalDate transactionDate, final BigDecimal amount) {
final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null);
final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null);
createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount);
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount);
}
/** /**
* Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting.
* *
@@ -106,8 +106,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ") .append(" eao.external_id as externalAssetOwner, ")
.append(" CASE ") .append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.transaction_id LIKE 'CA%' THEN ca.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN sa.account_no ")
.append(" ELSE NULL ") .append(" ELSE NULL ")
.append(" END as savingsAccountNo "); .append(" END as savingsAccountNo ");
if (associationParametersData.isRunningBalanceRequired()) { if (associationParametersData.isRunningBalanceRequired()) {
@@ -130,8 +129,8 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" join m_currency curr on curr.code = journalEntry.currency_code ") .append(" join m_currency curr on curr.code = journalEntry.currency_code ")
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ") .append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ") .append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 AND journalEntry.transaction_id NOT LIKE 'CA%' ") .append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 AND journalEntry.transaction_id LIKE 'CA%' "); .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
if (associationParametersData.isTransactionDetailsRequired()) { if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ") .append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.account.jobs.executestandinginstructions;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.jobs.service.JobName; import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.springframework.batch.core.Job; import org.springframework.batch.core.Job;
@@ -49,6 +50,8 @@ public class ExecuteStandingInstructionsConfig {
private DatabaseSpecificSQLGenerator sqlGenerator; private DatabaseSpecificSQLGenerator sqlGenerator;
@Autowired @Autowired
private AccountTransfersWritePlatformService accountTransfersWritePlatformService; private AccountTransfersWritePlatformService accountTransfersWritePlatformService;
@Autowired
private AccountTransferDetailRepository accountTransferDetailRepository;
@Bean @Bean
protected Step executeStandingInstructionsStep() { protected Step executeStandingInstructionsStep() {
@@ -65,6 +68,6 @@ public class ExecuteStandingInstructionsConfig {
@Bean @Bean
public ExecuteStandingInstructionsTasklet executeStandingInstructionsTasklet() { public ExecuteStandingInstructionsTasklet executeStandingInstructionsTasklet() {
return new ExecuteStandingInstructionsTasklet(standingInstructionReadPlatformService, jdbcTemplate, sqlGenerator, return new ExecuteStandingInstructionsTasklet(standingInstructionReadPlatformService, jdbcTemplate, sqlGenerator,
accountTransfersWritePlatformService); accountTransfersWritePlatformService, accountTransferDetailRepository);
} }
} }
@@ -34,6 +34,8 @@ import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO; import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.data.StandingInstructionData; import org.apache.fineract.portfolio.account.data.StandingInstructionData;
import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData; import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetails;
import org.apache.fineract.portfolio.account.domain.AccountTransferRecurrenceType; import org.apache.fineract.portfolio.account.domain.AccountTransferRecurrenceType;
import org.apache.fineract.portfolio.account.domain.StandingInstructionStatus; import org.apache.fineract.portfolio.account.domain.StandingInstructionStatus;
import org.apache.fineract.portfolio.account.domain.StandingInstructionType; import org.apache.fineract.portfolio.account.domain.StandingInstructionType;
@@ -58,6 +60,7 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DatabaseSpecificSQLGenerator sqlGenerator; private final DatabaseSpecificSQLGenerator sqlGenerator;
private final AccountTransfersWritePlatformService accountTransfersWritePlatformService; private final AccountTransfersWritePlatformService accountTransfersWritePlatformService;
private final AccountTransferDetailRepository accountTransferDetailRepository;
@Override @Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
@@ -105,10 +108,15 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet {
final SavingsAccount fromSavingsAccount = null; final SavingsAccount fromSavingsAccount = null;
final boolean isRegularTransaction = true; final boolean isRegularTransaction = true;
final boolean isExceptionForBalanceCheck = false; final boolean isExceptionForBalanceCheck = false;
// Load existing AccountTransferDetails so transactions are linked to
// this standing instruction and appear in its transaction history
final AccountTransferDetails existingDetails = data.getAccountDetailId() != null
? accountTransferDetailRepository.findById(data.getAccountDetailId()).orElse(null)
: null;
AccountTransferDTO accountTransferDTO = new AccountTransferDTO(transactionDate, transactionAmount, AccountTransferDTO accountTransferDTO = new AccountTransferDTO(transactionDate, transactionAmount,
data.fromAccountTypeEnum(), data.toAccountTypeEnum(), data.getFromAccount().getId(), data.getToAccount().getId(), data.fromAccountTypeEnum(), data.toAccountTypeEnum(), data.getFromAccount().getId(), data.getToAccount().getId(),
data.getName() + " Standing instruction trasfer ", null, null, null, null, data.toTransferType(), null, null, data.getName() + " Standing instruction trasfer ", null, null, null, null, data.toTransferType(), null, null,
data.transferTypeEnum().getValue(), null, null, ExternalId.empty(), null, null, fromSavingsAccount, data.transferTypeEnum().getValue(), existingDetails, null, ExternalId.empty(), null, null, fromSavingsAccount,
isRegularTransaction, isExceptionForBalanceCheck); isRegularTransaction, isExceptionForBalanceCheck);
final boolean transferCompleted = transferAmount(errors, accountTransferDTO, data.getId()); final boolean transferCompleted = transferAmount(errors, accountTransferDTO, data.getId());
@@ -34,6 +34,9 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.config.FineractProperties;
@@ -96,6 +99,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final FineractProperties fineractProperties; private final FineractProperties fineractProperties;
private final CurrentAccountRepository currentAccountRepository; private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository; private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
@Transactional @Transactional
@Override @Override
@@ -467,23 +471,29 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
.assembleFrom(accountTransferDTO.getToAccountId(), backdatedTxnsAllowedTill); .assembleFrom(accountTransferDTO.getToAccountId(), backdatedTxnsAllowedTill);
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount, final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate()); accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
toSavingsAccount.productId(), toSavingsAccount.getId());
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
// Savings deposit GL entries would produce a self-cancelling DR+CR on SAVING INDIVIDUAL — suppress them.
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
final AccountTransferDetails newDetails = AccountTransferDetails.currentToSavingsTransfer( if (accountTransferDetails == null) {
fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount, accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
toSavingsAccount.office(), toSavingsAccount.getClient(), toSavingsAccount, fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount,
accountTransferDTO.getTransferType()); toSavingsAccount.office(), toSavingsAccount.getClient(), toSavingsAccount,
accountTransferDTO.getTransferType());
}
final Money amount = Money.of(toSavingsAccount.getCurrency(), accountTransferDTO.getTransactionAmount()); final Money amount = Money.of(toSavingsAccount.getCurrency(), accountTransferDTO.getTransactionAmount());
newDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer( accountTransferDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer(
newDetails, null, deposit, accountTransferDTO.getTransactionDate(), amount, accountTransferDetails, null, deposit, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription())); accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(newDetails); this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = newDetails.getId(); transferTransactionId = accountTransferDetails.getId();
} else if (isSavingsToCurrentAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) { } else if (isSavingsToCurrentAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Savings Account → Current Account --- // --- Savings Account → Current Account ---
@@ -494,27 +504,33 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
.orElseThrow(() -> new GeneralPlatformDomainRuleException( .orElseThrow(() -> new GeneralPlatformDomainRuleException(
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getToAccountId())); "error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getToAccountId()));
// skipAccountingPostings=true: current account deposit leg (C-txn) posts
// DR SAVING INDIVIDUAL / CR CURRENT ACCOUNT directly. Savings withdrawal GL entries
// would produce a self-cancelling DR+CR on the same SAVING INDIVIDUAL GL — suppress them.
final SavingsTransactionBooleanValues txnBooleans = new SavingsTransactionBooleanValues(isAccountTransfer, final SavingsTransactionBooleanValues txnBooleans = new SavingsTransactionBooleanValues(isAccountTransfer,
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), false, isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), false,
accountTransferDTO.isExceptionForBalanceCheck()); accountTransferDTO.isExceptionForBalanceCheck(), true);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
txnBooleans, backdatedTxnsAllowedTill); txnBooleans, backdatedTxnsAllowedTill);
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate()); accountTransferDTO.getTransactionDate(),
fromSavingsAccount.productId(), fromSavingsAccount.getId());
final AccountTransferDetails newDetails = AccountTransferDetails.savingsToCurrentTransfer( if (accountTransferDetails == null) {
fromSavingsAccount.office(), fromSavingsAccount.getClient(), fromSavingsAccount, accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
toCurrentAccount.getOffice(), toCurrentAccount.getClient(), toCurrentAccount, fromSavingsAccount.office(), fromSavingsAccount.getClient(), fromSavingsAccount,
accountTransferDTO.getTransferType()); toCurrentAccount.getOffice(), toCurrentAccount.getClient(), toCurrentAccount,
accountTransferDTO.getTransferType());
}
final Money amount = Money.of(fromSavingsAccount.getCurrency(), accountTransferDTO.getTransactionAmount()); final Money amount = Money.of(fromSavingsAccount.getCurrency(), accountTransferDTO.getTransactionAmount());
newDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer( accountTransferDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer(
newDetails, withdrawal, null, accountTransferDTO.getTransactionDate(), amount, accountTransferDetails, withdrawal, null, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription())); accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(newDetails); this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = newDetails.getId(); transferTransactionId = accountTransferDetails.getId();
} else if (isCurrentToCurrentAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) { } else if (isCurrentToCurrentAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Current Account → Current Account --- // --- Current Account → Current Account ---
@@ -528,22 +544,26 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getToAccountId())); "error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getToAccountId()));
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate()); accountTransferDTO.getTransactionDate(),
toCurrentAccount.getProductId(), toCurrentAccount.getId());
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate()); accountTransferDTO.getTransactionDate(),
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
final AccountTransferDetails newDetails = AccountTransferDetails.currentToCurrentTransfer( if (accountTransferDetails == null) {
fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount, accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
toCurrentAccount.getOffice(), toCurrentAccount.getClient(), toCurrentAccount, fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount,
accountTransferDTO.getTransferType()); toCurrentAccount.getOffice(), toCurrentAccount.getClient(), toCurrentAccount,
accountTransferDTO.getTransferType());
}
final MonetaryCurrency currency = new MonetaryCurrency(fromCurrentAccount.getCurrencyCode(), final MonetaryCurrency currency = new MonetaryCurrency(fromCurrentAccount.getCurrencyCode(),
fromCurrentAccount.getCurrencyDigits(), fromCurrentAccount.getCurrencyMultiplesOf()); fromCurrentAccount.getCurrencyDigits(), fromCurrentAccount.getCurrencyMultiplesOf());
final Money amount = Money.of(currency, accountTransferDTO.getTransactionAmount()); final Money amount = Money.of(currency, accountTransferDTO.getTransactionAmount());
newDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer( accountTransferDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer(
newDetails, null, null, accountTransferDTO.getTransactionDate(), amount, accountTransferDetails, null, null, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription())); accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(newDetails); this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = newDetails.getId(); transferTransactionId = accountTransferDetails.getId();
} else if (isCurrentToLoanAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) { } else if (isCurrentToLoanAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Current Account → Loan Account --- // --- Current Account → Loan Account ---
@@ -553,7 +573,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getFromAccountId())); "error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getFromAccountId()));
final Loan toLoanAccount = this.loanAccountAssembler.assembleFrom(accountTransferDTO.getToAccountId()); final Loan toLoanAccount = this.loanAccountAssembler.assembleFrom(accountTransferDTO.getToAccountId());
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), performCurrentWithdrawalForLoan(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate()); accountTransferDTO.getTransactionDate());
ExternalId externalId = externalIdFactory.create(accountTransferDTO.getTxnExternalId().getValue()); ExternalId externalId = externalIdFactory.create(accountTransferDTO.getTxnExternalId().getValue());
@@ -656,10 +676,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
} }
/** /**
* Withdraw amount from a current account and save the transaction. * Withdraw for Current → Loan transfers. Loan domain service handles the loan-side GL,
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient. * so current account side uses LIABILITY_TRANSFER as the counterpart (classic approach).
*/ */
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount, private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) { final LocalDate txnDate) {
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
@@ -677,6 +697,40 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
account.setTotalWithdrawalsDerived( account.setTotalWithdrawalsDerived(
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount)); (account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account); currentAccountRepository.save(account);
if (account.getProductId() != null && txn.getId() != null) {
final String currentTxnId = "CA" + txn.getId();
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
account.getId(), currentTxnId, txnDate, amount, false);
}
return txn;
}
/**
* Withdraw amount from a current account and save the transaction.
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
*/
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
if (!account.isAllowOverdraft() && amount.compareTo(available) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.insufficient.balance",
"Insufficient balance in current account " + account.getId()
+ ". Available: " + available + ", Requested: " + amount, account.getId());
}
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.subtract(amount);
txn.setRunningBalanceDerived(newBalance);
currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(newBalance);
account.setTotalWithdrawalsDerived(
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
return txn; return txn;
} }
@@ -684,7 +738,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Deposit amount into a current account and save the transaction. * Deposit amount into a current account and save the transaction.
*/ */
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount, private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) { final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null); final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.add(amount); final BigDecimal newBalance = balance.add(amount);
@@ -694,9 +748,67 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
account.setTotalDepositsDerived( account.setTotalDepositsDerived(
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount)); (account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account); currentAccountRepository.save(account);
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
return txn; return txn;
} }
/**
* Posts double-entry journal entries for a current account leg of an account transfer.
* <p>
* Current Product GL mappings are stored under {@code PortfolioProductType.SAVING}, so the
* savings JE helper resolves them correctly.
* <p>
* Transaction id is prefixed with "C" (e.g. {@code C57}) so the entries are NOT grouped with
* savings entries that share the same numeric id (those use prefix "S"). The helper only
* appends its own "S" prefix when the id is numeric; passing "C57" keeps it as-is.
*
* @param isWithdrawal {@code true} for the from-leg (DR control / CR liability transfer);
* {@code false} for the to-leg (DR liability transfer / CR control)
*/
/**
* Posts direct transfer journal entries between a current account and a counterpart savings/current account.
* Each entry carries the correct entity ID so the correct account number appears on each GL line.
*
* For withdrawal (current → savings):
* DR: current product GL (entity = current account)
* CR: counterpart product GL (entity = savings/current account)
*
* For deposit (savings → current):
* DR: counterpart product GL (entity = savings/current account)
* CR: current product GL (entity = current account)
*
* @param counterpartProductId savings/current product ID of the other account
* @param counterpartEntityId savings/current account ID of the other account
*/
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
final Long counterpartProductId, final Long counterpartEntityId) {
if (account.getProductId() == null || txn.getId() == null) {
return;
}
// Prefix "CA" so:
// 1. The helper does NOT re-prefix with "S" (avoids txn id collision with savings "S" prefix)
// 2. JournalEntryReadPlatformServiceImpl joins m_current_account (transaction_id LIKE 'CA%')
// so the correct current account number appears on the DR/CR entry.
final String currentTxnId = "CA" + txn.getId();
if (isWithdrawal) {
// DR: current account GL CR: counterpart (savings/current) GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
account.getProductId(), account.getId(),
counterpartProductId, counterpartEntityId,
currentTxnId, txnDate, amount);
} else {
// DR: counterpart (savings/current) GL CR: current account GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartEntityId,
account.getProductId(), account.getId(),
currentTxnId, txnDate, amount);
}
}
@Override @Override
@Transactional @Transactional
public CommandProcessingResult refundByTransfer(JsonCommand command) { public CommandProcessingResult refundByTransfer(JsonCommand command) {
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.account.starter; package org.apache.fineract.portfolio.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
@@ -94,12 +95,13 @@ public class AccountConfiguration {
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository) { CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository); currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
} }
@Bean @Bean
@@ -139,8 +139,10 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
} }
this.savingsAccountRepository.save(account); this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, transactionBooleanValues.isAccountTransfer(), if (!transactionBooleanValues.isSkipAccountingPostings()) {
backdatedTxnsAllowedTill); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds,
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill);
}
businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
return withdrawal; return withdrawal;
@@ -153,13 +155,32 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean backdatedTxnsAllowedTill) { final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean backdatedTxnsAllowedTill) {
final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.DEPOSIT; final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.DEPOSIT;
return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction,
savingsAccountTransactionType, backdatedTxnsAllowedTill); savingsAccountTransactionType, backdatedTxnsAllowedTill, false);
}
@Override
public SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean backdatedTxnsAllowedTill,
final boolean skipAccountingPostings) {
final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.DEPOSIT;
return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction,
savingsAccountTransactionType, backdatedTxnsAllowedTill, skipAccountingPostings);
} }
private SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt, private SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean isAccountTransfer, final boolean isRegularTransaction,
final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill) { final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill) {
return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction,
savingsAccountTransactionType, backdatedTxnsAllowedTill, false);
}
private SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer, final boolean isRegularTransaction,
final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill,
final boolean skipAccountingPostings) {
context.authenticatedUser(); context.authenticatedUser();
account.validateForAccountBlock(); account.validateForAccountBlock();
account.validateForCreditBlock(); account.validateForCreditBlock();
@@ -210,7 +231,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
this.savingsAccountRepository.saveAndFlush(account); this.savingsAccountRepository.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill); if (!skipAccountingPostings) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill);
}
businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
return deposit; return deposit;
} }
@@ -25,15 +25,22 @@ public class SavingsTransactionBooleanValues {
private final boolean isApplyWithdrawFee; private final boolean isApplyWithdrawFee;
private final boolean isInterestTransfer; private final boolean isInterestTransfer;
private final boolean isExceptionForBalanceCheck; private final boolean isExceptionForBalanceCheck;
private final boolean skipAccountingPostings;
public SavingsTransactionBooleanValues(final boolean isAccountTransfer, final boolean isRegularTransaction, public SavingsTransactionBooleanValues(final boolean isAccountTransfer, final boolean isRegularTransaction,
final boolean isApplyWithdrawFee, final boolean isInterestTransfer, final boolean isExceptionForBalanceCheck) { final boolean isApplyWithdrawFee, final boolean isInterestTransfer, final boolean isExceptionForBalanceCheck) {
this(isAccountTransfer, isRegularTransaction, isApplyWithdrawFee, isInterestTransfer, isExceptionForBalanceCheck, false);
}
public SavingsTransactionBooleanValues(final boolean isAccountTransfer, final boolean isRegularTransaction,
final boolean isApplyWithdrawFee, final boolean isInterestTransfer, final boolean isExceptionForBalanceCheck,
final boolean skipAccountingPostings) {
this.isAccountTransfer = isAccountTransfer; this.isAccountTransfer = isAccountTransfer;
this.isRegularTransaction = isRegularTransaction; this.isRegularTransaction = isRegularTransaction;
this.isApplyWithdrawFee = isApplyWithdrawFee; this.isApplyWithdrawFee = isApplyWithdrawFee;
this.isInterestTransfer = isInterestTransfer; this.isInterestTransfer = isInterestTransfer;
this.isExceptionForBalanceCheck = isExceptionForBalanceCheck; this.isExceptionForBalanceCheck = isExceptionForBalanceCheck;
this.skipAccountingPostings = skipAccountingPostings;
} }
public boolean isAccountTransfer() { public boolean isAccountTransfer() {
@@ -56,4 +63,8 @@ public class SavingsTransactionBooleanValues {
return this.isExceptionForBalanceCheck; return this.isExceptionForBalanceCheck;
} }
public boolean isSkipAccountingPostings() {
return this.skipAccountingPostings;
}
} }
@@ -38,6 +38,10 @@ public interface SavingsAccountDomainService {
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer, boolean isRegularTransaction, BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer, boolean isRegularTransaction,
boolean backdatedTxnsAllowedTill); boolean backdatedTxnsAllowedTill);
SavingsAccountTransaction handleDeposit(SavingsAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer, boolean isRegularTransaction,
boolean backdatedTxnsAllowedTill, boolean skipAccountingPostings);
void postJournalEntries(SavingsAccount savingsAccount, Set<Long> existingTransactionIds, Set<Long> existingReversedTransactionIds, void postJournalEntries(SavingsAccount savingsAccount, Set<Long> existingTransactionIds, Set<Long> existingReversedTransactionIds,
boolean backdatedTxnsAllowedTill); boolean backdatedTxnsAllowedTill);