feat: automate internal transfer destination statement line creation upon source bank reconciliation

This commit is contained in:
Suherdy Yacob 2026-04-29 11:19:32 +07:00
parent 85a018c89d
commit 6f1e1088c3
6 changed files with 95 additions and 64 deletions

View File

@ -1,32 +1,34 @@
# Custom Bank Internal Transfer
A custom Odoo 17 module that provides a simplified 2-entry internal bank transfer flow.
A specialized Odoo 17 module that automates the two-step reconciliation workflow for internal bank transfers. This module is designed to ensure that funds leaving one bank account are accurately tracked and automatically mirrored as incoming funds in the destination account upon reconciliation.
## Features
* **Dedicated Menu**: Adds a separate "Bank Internal Transfer" menu under Accounting/Vendors.
* **Direct Reconciliation Flow**: Generates only 2 journal entries instead of the standard 3, completely bypassing the liquidity transfer / internal transfer account.
* Entry 1: Source Bank Statement Line (Dr Outstanding Payments [Source], Cr Source Bank)
* Entry 2: Destination Bank Statement Line (Dr Dest Bank, Cr Outstanding Payments [Source])
* **Unique Sequencing**: Implements a dedicated sequence (e.g. `BIT/2026/0001`) for bank internal transfers.
* **Printable Receipt**: Supports generating PDF payment receipts using the standard vendor payment receipt template.
* **Bank-Statement Driven Flow**: Unlike standard Odoo internal transfers that create payments first, this module starts with a bank statement line in the source journal.
* **Automated Destination Mirroring**: When the finance team reconciles the source bank statement line (e.g., BTN), the module **automatically** generates the corresponding statement line in the destination journal (e.g., BCA).
* **Auto-Reconciliation**: The generated destination statement line is automatically reconciled against the source journal's outgoing payment transit account (e.g., AR Clearing), completing the transfer without manual entry.
* **Minimal Accounting Footprint**: Results in exactly 2 journal entries (Source side and Destination side), maintaining a clean audit trail between bank accounts.
* **Dedicated Menu**: Adds a "Bank Internal Transfer" menu under Accounting > Accounting for easy tracking.
* **Dedicated Sequencing**: Uses a custom sequence (e.g., `INT/TRANS/0001`) for clear identification.
## Dependencies
## Workflow
* `account`
* `account_accountant` (Required for Odoo Enterprise accounting menus)
1. **Creation**: Create a "Bank Internal Transfer" record.
2. **Confirmation**: Click "Confirm". This generates an un-reconciled bank statement line in the **Source Bank Journal**.
3. **Source Reconciliation**: The finance team reconciles this line in the Odoo Bank Reconciliation widget using the appropriate clearing account.
4. **Automation Trigger**: Upon validation of the source line, the module:
* Creates a new bank statement line in the **Destination Bank Journal**.
* Automatically reconciles it against the transit account.
5. **Completion**: Both journals are now balanced, and the transfer record is updated with links to both statement lines.
## Technical Details
* **Hook**: Overrides `bank.rec.widget._action_validate` to capture the exact moment a source transfer is finalized.
* **Transit Account**: Automatically uses the source journal's manual outbound outstanding account, falling back to account code `218401` (AR Clearing) if needed.
* **Compatibility**: Designed to work alongside `account_reconcile_reference` and other accounting extensions.
## Installation
1. Copy the `account_custom_internal_transfer` directory to your Odoo custom addons path.
2. Update the App List in Odoo.
3. Install the "Custom Bank Internal Transfer" module.
## Usage
1. Navigate to **Accounting > Vendors > Bank Internal Transfer**.
2. Click **New**.
3. Select the **Source Journal** (e.g., Bank BCA) and **Destination Journal** (e.g., Cash).
4. Specify the **Date**, **Amount**, and **Memo**.
5. Click **Confirm** to execute the transfer.
6. To print a receipt, click **Print > Payment Receipt**.

View File

@ -1 +1,3 @@
from . import bank_internal_transfer
from . import account_bank_statement_line
from . import bank_rec_widget

View File

@ -0,0 +1,8 @@
from odoo import models, fields, api
class AccountBankStatementLine(models.Model):
_inherit = 'account.bank.statement.line'
bank_internal_transfer_id = fields.Many2one('bank.internal.transfer', string='Related Internal Transfer', readonly=True)

View File

@ -19,7 +19,9 @@ class BankInternalTransfer(models.Model):
('draft', 'Draft'),
('posted', 'Posted'),
], string='Status', default='draft', required=True)
statement_line_id = fields.Many2one('account.bank.statement.line', string='Source Statement Line', readonly=True)
source_statement_line_id = fields.Many2one('account.bank.statement.line', string='Source Statement Line', readonly=True)
destination_statement_line_id = fields.Many2one('account.bank.statement.line', string='Destination Statement Line', readonly=True)
# Fields for report compatibility (account.report_payment_receipt)
ref = fields.Char(string='Report Reference', related='memo')
@ -79,63 +81,30 @@ class BankInternalTransfer(models.Model):
if transfer.source_journal_id == transfer.destination_journal_id:
raise UserError(_("Source and destination journals must be different."))
# 1. Determine Outstanding Payments account from Source Journal
outstanding_account = transfer.source_journal_id.outbound_payment_method_line_ids.mapped('payment_account_id')
if outstanding_account:
outstanding_account = outstanding_account[0]
else:
outstanding_account = transfer.company_id.account_journal_payment_credit_account_id
if not outstanding_account:
raise UserError(_("Please configure the Outstanding Payments Account on the source journal or company."))
# 2. Create the Bank Statement Line for the Source Journal
# Create the Bank Statement Line for the Source Journal
# This generates the `account.move` with Credit Bank, Debit Suspense
stmt_line_vals = {
'journal_id': transfer.source_journal_id.id,
'date': transfer.date,
'payment_ref': transfer.memo or transfer.name,
'amount': -transfer.amount, # Outgoing money
'bank_internal_transfer_id': transfer.id,
}
stmt_line = self.env['account.bank.statement.line'].create(stmt_line_vals)
# 3. Modify the underlying move's suspense line to use the Outstanding Payments account
suspense_account = transfer.source_journal_id.suspense_account_id
if not suspense_account:
raise UserError(_("Please configure the Suspense Account on the source journal."))
move = stmt_line.move_id
if move.state != 'draft':
move.button_draft()
suspense_line = move.line_ids.filtered(lambda l: l.account_id == suspense_account)
if not suspense_line:
# Fallback if somehow it didn't use the suspense account
suspense_line = move.line_ids.filtered(lambda l: l.debit > 0)
if suspense_line:
# We use check_move_validity=False to bypass balance checks while updating
suspense_line.with_context(check_move_validity=False).write({
'account_id': outstanding_account.id,
})
# Post the move
move.action_post()
# Link and update state
transfer.write({
'statement_line_id': stmt_line.id,
'source_statement_line_id': stmt_line.id,
'state': 'posted',
})
def action_draft(self):
for transfer in self:
if transfer.statement_line_id:
# Check if it's already reconciled on the destination side
for line in transfer.statement_line_id.move_id.line_ids:
if line.reconciled:
raise UserError(_("You cannot reset a transfer that has already been reconciled on the destination side."))
transfer.statement_line_id.move_id.button_draft()
transfer.statement_line_id.unlink()
if transfer.destination_statement_line_id:
raise UserError(_("You cannot reset a transfer that has already been reconciled and created a destination statement line."))
if transfer.source_statement_line_id:
if transfer.source_statement_line_id.is_reconciled:
raise UserError(_("You cannot reset a transfer because the source statement line is already reconciled."))
transfer.source_statement_line_id.move_id.button_draft()
transfer.source_statement_line_id.unlink()
transfer.state = 'draft'

49
models/bank_rec_widget.py Normal file
View File

@ -0,0 +1,49 @@
from odoo import models
import logging
_logger = logging.getLogger(__name__)
class BankRecWidget(models.Model):
_inherit = 'bank.rec.widget'
def _action_validate(self):
# Save the statement line before super() clears or processes it
st_line = self.st_line_id
# Call the original validate method
super()._action_validate()
# Check if this was the source statement line of our internal transfer
if st_line and st_line.bank_internal_transfer_id:
transfer = st_line.bank_internal_transfer_id
if transfer.source_statement_line_id == st_line and not transfer.destination_statement_line_id:
# 1. Create Destination Bank Statement Line
dest_line = self.env['account.bank.statement.line'].create({
'journal_id': transfer.destination_journal_id.id,
'date': transfer.date,
'payment_ref': transfer.memo or transfer.name,
'amount': transfer.amount, # Incoming money
'bank_internal_transfer_id': transfer.id,
})
transfer.destination_statement_line_id = dest_line
# 2. Auto-reconcile Destination Line
# Find the account to use: Source Journal's Outgoing Payment account OR 218401
transit_account = transfer.source_journal_id.outbound_payment_method_line_ids.filtered(
lambda l: l.code == 'manual'
).mapped('payment_account_id')
if not transit_account:
transit_account = self.env['account.account'].search([
('code', '=', '218401'),
('company_id', '=', transfer.company_id.id)
], limit=1)
if transit_account:
suspense_account = dest_line.journal_id.suspense_account_id
suspense_line = dest_line.move_id.line_ids.filtered(lambda l: l.account_id == suspense_account)
if suspense_line:
suspense_line.with_context(check_move_validity=False).write({
'account_id': transit_account.id,
})

View File

@ -55,7 +55,8 @@
<field name="currency_id" invisible="1"/>
<field name="memo" readonly="state != 'draft'"/>
<field name="company_id" invisible="1"/>
<field name="statement_line_id" invisible="not statement_line_id"/>
<field name="source_statement_line_id" invisible="not source_statement_line_id"/>
<field name="destination_statement_line_id" invisible="not destination_statement_line_id"/>
</group>
</group>
</sheet>