Compare commits
No commits in common. "main" and "19.0" have entirely different histories.
40
README.md
40
README.md
@ -1,34 +1,32 @@
|
|||||||
# Custom Bank Internal Transfer
|
# Custom Bank Internal Transfer
|
||||||
|
|
||||||
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.
|
A custom Odoo 17 module that provides a simplified 2-entry internal bank transfer flow.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* **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.
|
* **Dedicated Menu**: Adds a separate "Bank Internal Transfer" menu under Accounting/Vendors.
|
||||||
* **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).
|
* **Direct Reconciliation Flow**: Generates only 2 journal entries instead of the standard 3, completely bypassing the liquidity transfer / internal transfer account.
|
||||||
* **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.
|
* Entry 1: Source Bank Statement Line (Dr Outstanding Payments [Source], Cr Source Bank)
|
||||||
* **Minimal Accounting Footprint**: Results in exactly 2 journal entries (Source side and Destination side), maintaining a clean audit trail between bank accounts.
|
* Entry 2: Destination Bank Statement Line (Dr Dest Bank, Cr Outstanding Payments [Source])
|
||||||
* **Dedicated Menu**: Adds a "Bank Internal Transfer" menu under Accounting > Accounting for easy tracking.
|
* **Unique Sequencing**: Implements a dedicated sequence (e.g. `BIT/2026/0001`) for bank internal transfers.
|
||||||
* **Dedicated Sequencing**: Uses a custom sequence (e.g., `INT/TRANS/0001`) for clear identification.
|
* **Printable Receipt**: Supports generating PDF payment receipts using the standard vendor payment receipt template.
|
||||||
|
|
||||||
## Workflow
|
## Dependencies
|
||||||
|
|
||||||
1. **Creation**: Create a "Bank Internal Transfer" record.
|
* `account`
|
||||||
2. **Confirmation**: Click "Confirm". This generates an un-reconciled bank statement line in the **Source Bank Journal**.
|
* `account_accountant` (Required for Odoo Enterprise accounting menus)
|
||||||
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
|
## Installation
|
||||||
|
|
||||||
1. Copy the `account_custom_internal_transfer` directory to your Odoo custom addons path.
|
1. Copy the `account_custom_internal_transfer` directory to your Odoo custom addons path.
|
||||||
2. Update the App List in Odoo.
|
2. Update the App List in Odoo.
|
||||||
3. Install the "Custom Bank Internal Transfer" module.
|
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**.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Custom Bank Internal Transfer',
|
'name': 'Custom Bank Internal Transfer',
|
||||||
'version': '1.0',
|
'version': '19.0.1.0',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'Simplified 2-entry internal bank transfers',
|
'summary': 'Simplified 2-entry internal bank transfers',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
from . import bank_internal_transfer
|
from . import bank_internal_transfer
|
||||||
from . import account_bank_statement_line
|
from . import account_bank_statement_line
|
||||||
from . import bank_rec_widget
|
from . import account_move
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
from odoo import models, fields, api
|
from odoo import models, fields
|
||||||
|
|
||||||
class AccountBankStatementLine(models.Model):
|
class AccountBankStatementLine(models.Model):
|
||||||
_inherit = 'account.bank.statement.line'
|
_inherit = 'account.bank.statement.line'
|
||||||
|
|
||||||
bank_internal_transfer_id = fields.Many2one('bank.internal.transfer', string='Related Internal Transfer', readonly=True)
|
bank_internal_transfer_id = fields.Many2one(
|
||||||
|
'bank.internal.transfer',
|
||||||
|
string='Bank Internal Transfer',
|
||||||
|
readonly=True,
|
||||||
|
ondelete='set null'
|
||||||
|
)
|
||||||
|
|||||||
18
models/account_move.py
Normal file
18
models/account_move.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from odoo import models, api
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
def set_moves_checked(self, is_checked=True):
|
||||||
|
""" Hook into the 'Review' action of the bank reconciliation widget.
|
||||||
|
When a move is marked as checked, if it's linked to an internal transfer,
|
||||||
|
trigger the automation of the destination leg.
|
||||||
|
"""
|
||||||
|
res = super().set_moves_checked(is_checked=is_checked)
|
||||||
|
if is_checked:
|
||||||
|
for move in self:
|
||||||
|
# In Odoo 19, statement lines are moves or linked to moves via statement_line_id
|
||||||
|
st_line = move.statement_line_id
|
||||||
|
if st_line and st_line.bank_internal_transfer_id:
|
||||||
|
st_line.bank_internal_transfer_id._automate_destination_leg()
|
||||||
|
return res
|
||||||
@ -19,7 +19,6 @@ class BankInternalTransfer(models.Model):
|
|||||||
('draft', 'Draft'),
|
('draft', 'Draft'),
|
||||||
('posted', 'Posted'),
|
('posted', 'Posted'),
|
||||||
], string='Status', default='draft', required=True)
|
], string='Status', default='draft', required=True)
|
||||||
|
|
||||||
source_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)
|
destination_statement_line_id = fields.Many2one('account.bank.statement.line', string='Destination Statement Line', readonly=True)
|
||||||
|
|
||||||
@ -32,9 +31,8 @@ class BankInternalTransfer(models.Model):
|
|||||||
reconciled_bill_ids = fields.Many2many('account.move', compute='_compute_dummy_fields')
|
reconciled_bill_ids = fields.Many2many('account.move', compute='_compute_dummy_fields')
|
||||||
journal_id = fields.Many2one('account.journal', compute='_compute_dummy_fields')
|
journal_id = fields.Many2one('account.journal', compute='_compute_dummy_fields')
|
||||||
partner_bank_id = fields.Many2one('res.partner.bank', compute='_compute_dummy_fields')
|
partner_bank_id = fields.Many2one('res.partner.bank', compute='_compute_dummy_fields')
|
||||||
payment_ids = fields.Many2many('bank.internal.transfer', compute='_compute_dummy_fields')
|
payment_ids = fields.Many2many('bank.internal.transfer', compute='_compute_dummy_fields', string='Payments')
|
||||||
deduction_line_ids = fields.Many2many('account.move', compute='_compute_dummy_fields')
|
deduction_line_ids = fields.Many2many('account.move', compute='_compute_dummy_fields')
|
||||||
ref = fields.Char(related='memo')
|
|
||||||
amount_total = fields.Monetary(related='amount')
|
amount_total = fields.Monetary(related='amount')
|
||||||
final_payment_amount = fields.Monetary(related='amount')
|
final_payment_amount = fields.Monetary(related='amount')
|
||||||
|
|
||||||
@ -81,8 +79,17 @@ class BankInternalTransfer(models.Model):
|
|||||||
if transfer.source_journal_id == transfer.destination_journal_id:
|
if transfer.source_journal_id == transfer.destination_journal_id:
|
||||||
raise UserError(_("Source and destination journals must be different."))
|
raise UserError(_("Source and destination journals must be different."))
|
||||||
|
|
||||||
# Create the Bank Statement Line for the Source Journal
|
# 1. Determine Outstanding Payments account from Source Journal
|
||||||
# This generates the `account.move` with Credit Bank, Debit Suspense
|
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
|
||||||
stmt_line_vals = {
|
stmt_line_vals = {
|
||||||
'journal_id': transfer.source_journal_id.id,
|
'journal_id': transfer.source_journal_id.id,
|
||||||
'date': transfer.date,
|
'date': transfer.date,
|
||||||
@ -92,19 +99,94 @@ class BankInternalTransfer(models.Model):
|
|||||||
}
|
}
|
||||||
stmt_line = self.env['account.bank.statement.line'].create(stmt_line_vals)
|
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
|
||||||
|
# This effectively "reconciles" the source line against the transfer
|
||||||
|
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
|
||||||
|
suspense_line = move.line_ids.filtered(lambda l: l.account_id == suspense_account)
|
||||||
|
if not suspense_line:
|
||||||
|
suspense_line = move.line_ids.filtered(lambda l: l.debit > 0)
|
||||||
|
|
||||||
|
if suspense_line:
|
||||||
|
suspense_line.with_context(check_move_validity=False).write({
|
||||||
|
'account_id': outstanding_account.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Post the move
|
||||||
|
move.action_post()
|
||||||
|
|
||||||
# Link and update state
|
# Link and update state
|
||||||
transfer.write({
|
transfer.write({
|
||||||
'source_statement_line_id': stmt_line.id,
|
'source_statement_line_id': stmt_line.id,
|
||||||
'state': 'posted',
|
'state': 'posted',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _automate_destination_leg(self):
|
||||||
|
""" Automatically create and reconcile the destination bank statement line.
|
||||||
|
This is called when the source statement line is 'Reviewed'.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.destination_statement_line_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Determine the transit account
|
||||||
|
transit_account = self.source_journal_id.outbound_payment_method_line_ids.mapped('payment_account_id')
|
||||||
|
if transit_account:
|
||||||
|
transit_account = transit_account[0]
|
||||||
|
else:
|
||||||
|
# Fallback to default transit account code 218401
|
||||||
|
transit_account = self.env['account.account'].search([
|
||||||
|
('code', '=', '218401'),
|
||||||
|
('company_id', '=', self.company_id.id)
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not transit_account:
|
||||||
|
transit_account = self.company_id.account_journal_payment_credit_account_id
|
||||||
|
|
||||||
|
if not transit_account:
|
||||||
|
raise UserError(_("Could not find a suitable transit account (218401 or Outstanding Payments)."))
|
||||||
|
|
||||||
|
# 2. Create the Bank Statement Line for the Destination Journal
|
||||||
|
dst_st_line_vals = {
|
||||||
|
'journal_id': self.destination_journal_id.id,
|
||||||
|
'date': self.date,
|
||||||
|
'payment_ref': self.memo or self.name,
|
||||||
|
'amount': self.amount, # Incoming money
|
||||||
|
'bank_internal_transfer_id': self.id,
|
||||||
|
}
|
||||||
|
dst_st_line = self.env['account.bank.statement.line'].create(dst_st_line_vals)
|
||||||
|
self.destination_statement_line_id = dst_st_line
|
||||||
|
|
||||||
|
# 3. Reconcile the destination line against the transit account
|
||||||
|
move = dst_st_line.move_id
|
||||||
|
suspense_account = self.destination_journal_id.suspense_account_id
|
||||||
|
suspense_line = move.line_ids.filtered(lambda l: l.account_id == suspense_account)
|
||||||
|
if not suspense_line:
|
||||||
|
suspense_line = move.line_ids.filtered(lambda l: l.credit > 0)
|
||||||
|
|
||||||
|
if suspense_line:
|
||||||
|
suspense_line.with_context(check_move_validity=False).write({
|
||||||
|
'account_id': transit_account.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Mark as reviewed automatically
|
||||||
|
move.set_moves_checked(True)
|
||||||
|
|
||||||
def action_draft(self):
|
def action_draft(self):
|
||||||
for transfer in self:
|
for transfer in self:
|
||||||
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:
|
||||||
if transfer.source_statement_line_id.is_reconciled:
|
if transfer.source_statement_line_id.is_reconciled and transfer.destination_statement_line_id:
|
||||||
raise UserError(_("You cannot reset a transfer because the source statement line is already reconciled."))
|
raise UserError(_("You cannot reset a transfer that has already been processed on the destination side."))
|
||||||
|
|
||||||
transfer.source_statement_line_id.move_id.button_draft()
|
transfer.source_statement_line_id.move_id.button_draft()
|
||||||
transfer.source_statement_line_id.unlink()
|
transfer.source_statement_line_id.unlink()
|
||||||
|
|
||||||
|
if transfer.destination_statement_line_id:
|
||||||
|
transfer.destination_statement_line_id.move_id.button_draft()
|
||||||
|
transfer.destination_statement_line_id.unlink()
|
||||||
|
|
||||||
transfer.state = 'draft'
|
transfer.state = 'draft'
|
||||||
|
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@ -1,20 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<!-- Sequence for the custom internal transfer -->
|
|
||||||
<record id="seq_bank_internal_transfer" model="ir.sequence">
|
|
||||||
<field name="name">Bank Internal Transfer Sequence</field>
|
|
||||||
<field name="code">bank.internal.transfer</field>
|
|
||||||
<field name="prefix">INT/TRANS/</field>
|
|
||||||
<field name="padding">4</field>
|
|
||||||
<field name="company_id" eval="False"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Tree View -->
|
<!-- Tree View -->
|
||||||
<record id="view_bank_internal_transfer_tree" model="ir.ui.view">
|
<record id="view_bank_internal_transfer_tree" model="ir.ui.view">
|
||||||
<field name="name">bank.internal.transfer.tree</field>
|
<field name="name">bank.internal.transfer.tree</field>
|
||||||
<field name="model">bank.internal.transfer</field>
|
<field name="model">bank.internal.transfer</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<tree string="Bank Internal Transfers">
|
<list string="Bank Internal Transfers">
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="date"/>
|
<field name="date"/>
|
||||||
<field name="source_journal_id"/>
|
<field name="source_journal_id"/>
|
||||||
@ -22,7 +14,7 @@
|
|||||||
<field name="amount" sum="Total"/>
|
<field name="amount" sum="Total"/>
|
||||||
<field name="currency_id" column_invisible="True"/>
|
<field name="currency_id" column_invisible="True"/>
|
||||||
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'posted'"/>
|
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'posted'"/>
|
||||||
</tree>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
@ -68,7 +60,7 @@
|
|||||||
<record id="action_bank_internal_transfer" model="ir.actions.act_window">
|
<record id="action_bank_internal_transfer" model="ir.actions.act_window">
|
||||||
<field name="name">Bank Internal Transfers</field>
|
<field name="name">Bank Internal Transfers</field>
|
||||||
<field name="res_model">bank.internal.transfer</field>
|
<field name="res_model">bank.internal.transfer</field>
|
||||||
<field name="view_mode">tree,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
<field name="help" type="html">
|
<field name="help" type="html">
|
||||||
<p class="o_view_nocontent_smiling_face">
|
<p class="o_view_nocontent_smiling_face">
|
||||||
Create your first Bank Internal Transfer
|
Create your first Bank Internal Transfer
|
||||||
@ -104,7 +96,7 @@
|
|||||||
<!-- Menu Item in Accounting App Top Level -->
|
<!-- Menu Item in Accounting App Top Level -->
|
||||||
<menuitem id="menu_bank_internal_transfer"
|
<menuitem id="menu_bank_internal_transfer"
|
||||||
name="Bank Internal Transfer"
|
name="Bank Internal Transfer"
|
||||||
parent="account_accountant.menu_accounting"
|
parent="account.menu_finance_entries"
|
||||||
action="action_bank_internal_transfer"
|
action="action_bank_internal_transfer"
|
||||||
sequence="25"/>
|
sequence="25"/>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user