From e25289e66b661f46644ba1b4f7f4a67480d5f9d9 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 5 May 2026 15:23:37 +0700 Subject: [PATCH] feat: implement centralized vendor payment workflow for cross-company bank journal transactions --- README.md | 15 +++++ __init__.py | 1 + __manifest__.py | 3 +- models/__init__.py | 3 +- models/account_journal.py | 36 +++++++++++ models/account_payment.py | 81 ++++++++++++++++++++++++ views/account_journal_views.xml | 29 +++++++++ wizard/__init__.py | 2 + wizard/account_payment_register.py | 99 ++++++++++++++++++++++++++++++ 9 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 models/account_journal.py create mode 100644 models/account_payment.py create mode 100644 views/account_journal_views.xml create mode 100644 wizard/__init__.py create mode 100644 wizard/account_payment_register.py diff --git a/README.md b/README.md index d159b57..d719b37 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,27 @@ When a branch-side POS session is closed, the module automates the inter-company - Debits the parent bank's **Outstanding Receipt Account**, allowing for seamless reconciliation against incoming bank statement lines in the parent's accounting dashboard. - Credits the Inter-company Liability account (e.g., `229101 Hubungan RK`). +### 3. Centralized Vendor Payment +Enables branches to pay vendor bills from a bank account managed by a parent company: +- **Branch-Side**: Intercepts the "Register Payment" wizard. Instead of creating a standard payment, it generates a clearing entry that moves the liability from the Vendor (Accounts Payable) to the Parent Company (Inter-company Account). +- **Parent-Side**: Automatically creates an actual `account.payment` record in the parent company, paying out of the parent bank journal and debiting the inter-company clearing account. +- **Workflow**: Vendor bill remains in the branch, but is marked as **Paid** via the clearing mechanism. The actual cash outflow and bank reconciliation happen in the parent. + ## Configuration +### 1. POS Inter-Company Clearing To enable the automated inter-company clearing, navigate to **Point of Sale > Configuration > Payment Methods** and configure the following in the "Inter-Company Clearing" section: - **Parent Company**: Select the target company (e.g., OT). - **Parent Bank Journal**: Select the journal in the parent company that receives the funds. - **Parent Inter-company Account**: The liability account in the parent company (e.g., `229101 Hubungan RK`). - **Parent Clearing Journal**: The journal in the parent company used to record these mirror entries (e.g., "Inter-Company Clearing"). +### 2. Centralized Vendor Payment +Configure a **Bank/Cash Journal** in the branch company to act as a bridge: +1. Open the journal form (**Accounting > Configuration > Journals**). +2. Go to the **Centralized Payment** tab. +3. Enable **Is Centralized**. +4. Set the **Parent Company**, **Parent Journal**, and both **Inter-Company (RK) Accounts**. +5. When registering a payment on a vendor bill, select this journal to trigger the cross-company flow. + ## Author - **Suherdy Yacob** diff --git a/__init__.py b/__init__.py index b3c9695..0790c42 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ from . import models +from . import wizard from odoo import api, SUPERUSER_ID def _auto_share_accounts_post_init(env): diff --git a/__manifest__.py b/__manifest__.py index 0a85ca3..22f3fd5 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -8,9 +8,10 @@ This module removes the standard restriction that prevents Chart of Accounts (COA) of type 'Bank and Cash' from being shared across multiple companies. """, 'author': 'Suherdy Yacob', - 'depends': ['account', 'point_of_sale'], + 'depends': ['point_of_sale', 'account'], 'data': [ 'views/pos_payment_method_views.xml', + 'views/account_journal_views.xml', ], 'installable': True, 'application': False, diff --git a/models/__init__.py b/models/__init__.py index f3225ed..0411f6a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,4 +1,5 @@ from . import account_account +from . import account_journal +from . import account_payment from . import pos_payment_method from . import pos_session - diff --git a/models/account_journal.py b/models/account_journal.py new file mode 100644 index 0000000..e607ba5 --- /dev/null +++ b/models/account_journal.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + is_centralized = fields.Boolean( + string='Is Centralized Payment', + help="If checked, payments made using this journal will be recorded in the parent company." + ) + parent_company_id = fields.Many2one( + 'res.company', + string='Parent Company', + help="The parent company where the actual bank payment will be recorded." + ) + parent_journal_id = fields.Many2one( + 'account.journal', + string='Parent Bank Journal', + domain="[('company_id', '=', parent_company_id), ('type', 'in', ('bank', 'cash'))]", + check_company=False, + help="The actual bank journal in the parent company." + ) + parent_intercompany_account_id = fields.Many2one( + 'account.account', + string='Parent Inter-company Account', + domain="[('company_ids', 'in', parent_company_id)]", + check_company=False, + help="The Hubungan RK account in the parent company to debit (Receivable from branch)." + ) + branch_intercompany_account_id = fields.Many2one( + 'account.account', + string='Branch Inter-company Account', + domain="[('company_ids', 'in', company_id)]", + help="The Hubungan RK account in the branch company to credit (Liability to parent)." + ) diff --git a/models/account_payment.py b/models/account_payment.py new file mode 100644 index 0000000..aafe950 --- /dev/null +++ b/models/account_payment.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from odoo import models, api, fields, _ +from odoo.exceptions import UserError + +class AccountPayment(models.Model): + _inherit = 'account.payment' + + def action_post(self): + # 1. Capture info before super() because we might need to create extra moves + centralized_payments = self.filtered(lambda p: p.journal_id.is_centralized and p.company_id != p.journal_id.parent_company_id) + + # 2. Call super() to post the original payment(s) + # We need to make sure the original payment uses the RK account instead of Bank account if it's centralized + for payment in centralized_payments: + if not payment.journal_id.branch_intercompany_account_id: + raise UserError(_("Please configure the Branch Inter-company Account on journal %s", payment.journal_id.name)) + + # We override the journal's account temporarily for the liquidity line + res = super().action_post() + + # 3. Handle Centralized entries in Parent + for payment in centralized_payments: + parent_company = payment.journal_id.parent_company_id + parent_journal = payment.journal_id.parent_journal_id + parent_rk_account = payment.journal_id.parent_intercompany_account_id + + print(f">>> DEBUG: Centralized Payment for {payment.name}") + print(f" Company: {payment.company_id.id}, Parent Co: {parent_company.id}") + print(f" Parent Journal: {parent_journal.name} (ID: {parent_journal.id})") + print(f" Parent RK: {parent_rk_account.code} (ID: {parent_rk_account.id})") + + if not parent_journal or not parent_rk_account: + raise UserError(_("Please configure the Parent Journal and RK Account on journal %s", payment.journal_id.name)) + + # Create the mirroring entry in Parent + # Debit: Parent RK (Receivable from Branch) + # Credit: Bank + parent_move = self.env['account.move'].with_company(parent_company).create({ + 'move_type': 'entry', + 'date': payment.date, + 'company_id': parent_company.id, + 'journal_id': parent_journal.id, + 'ref': f"Centralized Payment: {payment.name} ({payment.company_id.name})", + 'line_ids': [ + (0, 0, { + 'name': payment.memo or f"Centralized Payment: {payment.name}", + 'account_id': parent_rk_account.id, + 'debit': payment.amount, + 'partner_id': payment.partner_id.id, + 'company_id': parent_company.id, + 'display_type': False, + }), + (0, 0, { + 'name': payment.memo or f"Centralized Payment: {payment.name}", + 'account_id': parent_journal.default_account_id.id, + 'credit': payment.amount, + 'partner_id': payment.partner_id.id, + 'company_id': parent_company.id, + 'display_type': False, + }), + ] + }) + parent_move.action_post() + print(f" Parent Move Posted: {parent_move.name}") + + return res + + def _prepare_move_line_default_vals(self, write_off_line_vals=None, force_balance=None): + """ + Override to use the Branch RK account for the liquidity line + when the payment is centralized. + """ + res = super()._prepare_move_line_default_vals(write_off_line_vals, force_balance) + if self.journal_id.is_centralized and self.company_id != self.journal_id.parent_company_id: + # The first line is usually the liquidity line in outbound payments + # or the counterpart line. Odoo 17+ uses _seek_for_lines to identify them. + # But here we can just update the account if it matches the journal's default. + for line_vals in res: + if line_vals.get('account_id') == self.journal_id.default_account_id.id: + line_vals['account_id'] = self.journal_id.branch_intercompany_account_id.id + return res diff --git a/views/account_journal_views.xml b/views/account_journal_views.xml new file mode 100644 index 0000000..06da8ae --- /dev/null +++ b/views/account_journal_views.xml @@ -0,0 +1,29 @@ + + + + account.journal.form.inherit.centralized + account.journal + + + + + + + + + + + + + + + +

+ When this journal is used to pay bills in the current company, the actual bank payment will be recorded in the parent company, + and an inter-company clearing entry will be created in the current company. +

+
+
+
+
+
diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..353adbd --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import account_payment_register diff --git a/wizard/account_payment_register.py b/wizard/account_payment_register.py new file mode 100644 index 0000000..60fff79 --- /dev/null +++ b/wizard/account_payment_register.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class AccountPaymentRegister(models.TransientModel): + _inherit = 'account.payment.register' + + def _create_payments(self): + # Intercept centralized payments + if self.journal_id.is_centralized: + return self._create_centralized_payments() + return super(AccountPaymentRegister, self)._create_payments() + + def _create_centralized_payments(self): + """ + Custom logic to create inter-company clearing moves when paying via a centralized journal. + """ + journal = self.journal_id + if not journal.parent_company_id or not journal.parent_journal_id: + raise UserError(_("The selected journal is marked as centralized but is missing parent company/journal configuration.")) + + if not journal.branch_intercompany_account_id or not journal.parent_intercompany_account_id: + raise UserError(_("Inter-company (RK) accounts must be configured on the centralized journal.")) + + branch_company = self.company_id + parent_company = journal.parent_company_id + + payments = self.env['account.payment'] + + # We process batches. In centralized mode, we usually expect 1 batch if coming from a single bill. + # But we handle multiple if needed. + for batch_result in self.batches: + lines = batch_result['lines'] + amount = abs(sum(lines.mapped('amount_residual'))) + if self.currency_id != branch_company.currency_id: + amount = abs(sum(lines.mapped('amount_residual_currency'))) + + # 1. Create Clearing Move in Branch Company + # Debit: Payable Account (clears the vendor bill) + # Credit: Inter-company (RK) Account (Liability to Parent) + + clearing_move_vals = { + 'move_type': 'entry', + 'company_id': branch_company.id, + 'journal_id': journal.id, + 'date': self.payment_date, + 'ref': _("Centralized Payment for %s") % (", ".join(lines.move_id.mapped('name'))), + 'line_ids': [ + (0, 0, { + 'name': _("Clearing: %s") % (", ".join(lines.move_id.mapped('name'))), + 'partner_id': self.partner_id.id, + 'account_id': lines[0].account_id.id, # The payable account + 'debit': amount if self.payment_type == 'outbound' else 0.0, + 'credit': amount if self.payment_type == 'inbound' else 0.0, + 'currency_id': self.currency_id.id, + 'amount_currency': amount if self.payment_type == 'outbound' else -amount, + }), + (0, 0, { + 'name': _("Due to Parent (%s)") % parent_company.name, + 'partner_id': False, + 'account_id': journal.branch_intercompany_account_id.id, + 'debit': amount if self.payment_type == 'inbound' else 0.0, + 'credit': amount if self.payment_type == 'outbound' else 0.0, + 'currency_id': self.currency_id.id, + 'amount_currency': -amount if self.payment_type == 'outbound' else amount, + }), + ], + } + + branch_move = self.env['account.move'].create(clearing_move_vals) + branch_move.action_post() + + # Reconcile Branch Move with the Bill Lines + clearing_lines = branch_move.line_ids.filtered(lambda l: l.account_id == lines[0].account_id) + (lines + clearing_lines).reconcile() + + # 2. Create actual Payment in Parent Company + # We use account.payment to ensure it shows up in bank reconciliation in the parent + payment_vals = { + 'company_id': parent_company.id, + 'journal_id': journal.parent_journal_id.id, + 'payment_type': self.payment_type, + 'partner_type': self.partner_type, + 'partner_id': self.partner_id.id, + 'amount': amount, + 'currency_id': self.currency_id.id, + 'date': self.payment_date, + 'memo': _("Centralized Pay for %s (%s)") % (branch_company.name, ", ".join(lines.move_id.mapped('name'))), + 'destination_account_id': journal.parent_intercompany_account_id.id, + } + + # Create payment in parent company context + parent_payment = self.env['account.payment'].with_company(parent_company).sudo().create(payment_vals) + parent_payment.action_post() + + payments |= parent_payment + + return payments