From 4fbc0f17d81adee22a6fc5b6b82e29b02412077c Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 28 Apr 2026 11:30:09 +0700 Subject: [PATCH] first commit --- .gitignore | 12 +++ README.md | 32 ++++++ __init__.py | 1 + __manifest__.py | 22 ++++ data/ir_sequence_data.xml | 12 +++ models/__init__.py | 1 + models/bank_internal_transfer.py | 141 +++++++++++++++++++++++++ security/ir.model.access.csv | 3 + views/bank_internal_transfer_views.xml | 109 +++++++++++++++++++ 9 files changed, 333 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 data/ir_sequence_data.xml create mode 100644 models/__init__.py create mode 100644 models/bank_internal_transfer.py create mode 100644 security/ir.model.access.csv create mode 100644 views/bank_internal_transfer_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcdfe7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python +*.pyc +__pycache__/ + +# Odoo +*.pot +*.po +*.csv~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..50cf6cc --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Custom Bank Internal Transfer + +A custom Odoo 17 module that provides a simplified 2-entry internal bank transfer flow. + +## 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. + +## Dependencies + +* `account` +* `account_accountant` (Required for Odoo Enterprise accounting menus) + +## 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**. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..4173f00 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'Custom Bank Internal Transfer', + 'version': '19.0.1.0', + 'category': 'Accounting/Accounting', + 'summary': 'Simplified 2-entry internal bank transfers', + 'description': """ + Creates a new menu for Bank Internal Transfers. + Generates 2 journal entries instead of 3, completely bypassing the liquidity transfer account. + Entry 1: Source Bank Stmt Line (Dr Outstanding Payments [Source], Cr Source Bank) + Entry 2: Destination Bank Stmt Line (Dr Dest Bank, Cr Outstanding Payments [Source]) + """, + 'author': 'Suherdy Yacob', + 'depends': ['account', 'account_accountant'], + 'data': [ + 'security/ir.model.access.csv', + 'data/ir_sequence_data.xml', + 'views/bank_internal_transfer_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/data/ir_sequence_data.xml b/data/ir_sequence_data.xml new file mode 100644 index 0000000..97bc555 --- /dev/null +++ b/data/ir_sequence_data.xml @@ -0,0 +1,12 @@ + + + + + Bank Internal Transfer Sequence + bank.internal.transfer + BIT/%(year)s/ + 5 + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..7fbc3c4 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +from . import bank_internal_transfer diff --git a/models/bank_internal_transfer.py b/models/bank_internal_transfer.py new file mode 100644 index 0000000..b4bb745 --- /dev/null +++ b/models/bank_internal_transfer.py @@ -0,0 +1,141 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class BankInternalTransfer(models.Model): + _name = 'bank.internal.transfer' + _description = 'Bank Internal Transfer' + _order = 'date desc, id desc' + + name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New')) + source_journal_id = fields.Many2one('account.journal', string='Source Bank Journal', domain="[('type', '=', 'bank')]", required=True) + destination_journal_id = fields.Many2one('account.journal', string='Destination Bank Journal', domain="[('type', '=', 'bank')]", required=True) + date = fields.Date(string='Date', required=True, default=fields.Date.context_today) + amount = fields.Monetary(string='Amount', required=True) + currency_id = fields.Many2one('res.currency', compute='_compute_currency_id', store=True) + company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company) + memo = fields.Char(string='Memo') + payment_method_line_id = fields.Many2one('account.payment.method.line', string='Payment Method', domain="[('journal_id', '=', source_journal_id), ('payment_type', '=', 'outbound')]") + state = fields.Selection([ + ('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) + + # Fields for report compatibility (account.report_payment_receipt) + ref = fields.Char(string='Report Reference', related='memo') + partner_id = fields.Many2one('res.partner', compute='_compute_dummy_fields') + partner_type = fields.Char(compute='_compute_dummy_fields') + payment_method_id = fields.Many2one('account.payment.method', compute='_compute_payment_method_id') + reconciled_invoice_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') + partner_bank_id = fields.Many2one('res.partner.bank', compute='_compute_dummy_fields') + payment_ids = fields.Many2many('bank.internal.transfer', 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') + final_payment_amount = fields.Monetary(related='amount') + + def _compute_dummy_fields(self): + for rec in self: + # Mimic standard Odoo internal transfer behavior where the company is the partner + rec.partner_id = rec.company_id.partner_id + rec.partner_type = 'supplier' + rec.reconciled_invoice_ids = False + rec.reconciled_bill_ids = False + rec.journal_id = rec.source_journal_id + rec.partner_bank_id = rec.destination_journal_id.bank_account_id + rec.payment_ids = rec.ids + rec.deduction_line_ids = False + + @api.depends('payment_method_line_id') + def _compute_payment_method_id(self): + for rec in self: + rec.payment_method_id = rec.payment_method_line_id.payment_method_id + + def _get_payment_receipt_report_values(self): + self.ensure_one() + return { + 'display_payment_method': True, + 'display_invoices': False, + } + + @api.depends('source_journal_id') + def _compute_currency_id(self): + for record in self: + record.currency_id = record.source_journal_id.currency_id or record.company_id.currency_id + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('bank.internal.transfer') or _('New') + return super().create(vals_list) + + def action_confirm(self): + for transfer in self: + if transfer.amount <= 0: + raise UserError(_("Amount must be strictly positive.")) + 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 + # 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 + } + 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, + '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() + transfer.state = 'draft' diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..94dd4da --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_bank_internal_transfer_user,bank.internal.transfer.user,model_bank_internal_transfer,account.group_account_user,1,1,1,1 +access_bank_internal_transfer_manager,bank.internal.transfer.manager,model_bank_internal_transfer,account.group_account_manager,1,1,1,1 diff --git a/views/bank_internal_transfer_views.xml b/views/bank_internal_transfer_views.xml new file mode 100644 index 0000000..bca9ecd --- /dev/null +++ b/views/bank_internal_transfer_views.xml @@ -0,0 +1,109 @@ + + + + + Bank Internal Transfer Sequence + bank.internal.transfer + INT/TRANS/ + 4 + + + + + + bank.internal.transfer.tree + bank.internal.transfer + + + + + + + + + + + + + + + + bank.internal.transfer.form + bank.internal.transfer + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + Bank Internal Transfers + bank.internal.transfer + tree,form + +

+ Create your first Bank Internal Transfer +

+

+ Transfer funds directly between bank accounts with a simplified 2-entry process. +

+
+
+ + + + + + + Payment Receipt + bank.internal.transfer + qweb-pdf + account_custom_internal_transfer.report_bank_internal_transfer_receipt + account_custom_internal_transfer.report_bank_internal_transfer_receipt + + report + + + + +