Compare commits

..

No commits in common. "19.0" and "17.0" have entirely different histories.
19.0 ... 17.0

30 changed files with 407 additions and 800 deletions

18
.gitignore vendored
View File

@ -1,18 +0,0 @@
# Python
*.py[cod]
*$py.class
__pycache__/
# OS
.DS_Store
Thumbs.db
# IDE / Editors
.vscode/
.idea/
*.swp
*.swo
*~
# Odoo specific
*.pot

View File

@ -1,58 +1,41 @@
# Account Shared Bank Cash
## Overview
This custom Odoo 19 module removes the standard restriction that prevents Chart of Accounts (COA) of type **Bank and Cash** (`asset_cash`) from being shared across multiple companies.
By default, Odoo restricts a Bank and Cash account to a single company. This module patches `_check_company_consistency` on the `account.account` model to allow you to configure multiple companies for a single Bank/Cash account. This is particularly useful for setups where multiple Point of Sale (POS) shops across different companies deposit into the same physical bank account or cash account.
## Features
### 1. Shared COA Support
- Overrides `account.account` company validation to allow `len(company_ids) > 1` on `asset_cash` accounts.
- Ensures POS payments and closing entries seamlessly use the shared account.
### 2. Automated Inter-Company Clearing
When a branch-side POS session is closed, the module automates the inter-company accounting flow:
- **Branch Company (Origin)**:
- Aggregates all bank clearing entries into a **single aggregated journal entry** (usually just 2 lines: Total Receivable vs Total Inter-company Account).
- Automatically reconciles these clearing lines against the main POS session move.
- Visually links the clearing moves to the POS Session's **"Journal Items"** dashboard.
- **Parent Company (Destination)**:
- Automatically creates **separate mirror journal entries** for each payment method (e.g., separate entries for BCA, BTN, BRI).
- 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.
### 4. Multi-Company POS Order and Checkout Bypass
Solves Odoo's restrictive `Access to unauthorized or invalid companies` error/warnings in logs when branch cashiers place orders, checkout, or sync POS sessions:
- **Environment & Session Sanitization**: Overrides `ir.http._pre_dispatch` and `_dispatch` to safely query the cashier's authorized companies using `sudo()`, and dynamically filters out unauthorized or parent company IDs (such as parent company `2`) from `allowed_company_ids` in request parameters (`request.params`), HTTP sessions (`request.session.context`), and environment context (`request.env.context`).
- **Sudo Order Post-Processing**: Inherits `pos.order` to run post-processing (`_process_saved_order`, including picking generation, cost/method computation, and payment posting) under `sudo()`. This allows Odoo to safely read company-dependent properties (like `cost_method` or `property_cost_method`) and generate inventory pickings without triggering security warnings in `environments.py`.
### 5. Centralized Cash Bank Reconciliation Patch
Enables matching of cash journal entries (e.g. from the centralized cash journal) in Odoo's standard bank reconciliation widget:
- **Reconciliation Candidate Expansion**: Patches the standard `account.move.line` search method (`_search_account_id`) to automatically include `asset_cash` accounts when filtering candidate line searches. This allows users to reconcile bank statement lines in the parent company directly against mirror/clearing cash entries.
This module mirrors the parent company's Bank and Cash accounts to branch companies and provides:
1. **Mirrored Bank/Cash Accounts**: Automatically clones parent company bank/cash accounts and journals to branches on install
2. **POS Inter-Company Clearing**: When a POS session closes, creates automated clearing entries between the branch and parent company
3. **Centralized Vendor Payment**: Branch vendor bills can be paid via parent company's bank journal with inter-company clearing
## 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").
On each `POS Payment Method`:
- **Inter-Company Clearing Account**: The RK account in the branch company
- **Clearing Journal**: A miscellaneous journal for the clearing entry
- **Parent Company**: The parent company for the mirror entry
- **Parent Bank Journal**: The parent's bank journal (outstanding receipt account will be debited)
- **Parent Hubungan RK Account**: The parent's inter-company liability account
- **Parent Clearing Journal**: A miscellaneous journal in the parent for the mirror entry
### 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**
On each `Account Journal` (Bank/Cash):
- **Is Centralized Payment**: Enable centralized mode
- **Parent Company**: The parent company
- **Parent Bank Journal**: The parent's actual bank journal
- **Parent Inter-Company Account**: The RK account in the parent (Receivable from branch)
- **Branch Inter-Company Account**: The RK account in the branch (Liability to parent)
## Technical Notes
- **Odoo 17 Compatibility**: Uses mirror account strategy since Odoo 17 `account.account` uses single `company_id`
- **Post-init hook**: Automatically clones parent bank/cash accounts and journals to branches
- **Uninstall hook**: Removes mirrored accounts that have no journal items
## Dependencies
- `point_of_sale`
- `account`

View File

@ -1,46 +1,132 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizard
from odoo import api, SUPERUSER_ID
import logging
def _auto_share_accounts_post_init(env):
"""
Automatically link accounts from the Parent Company (ID 2 - OT) to any Branch Company
that does not have its own Chart of Accounts.
"""
# Link Parent Company (ID 2) accounts to other companies if they don't have their own COA
env.cr.execute("""
INSERT INTO account_account_res_company_rel (account_id, company_id)
SELECT a.id, c.id
FROM account_account a
CROSS JOIN res_company c
WHERE a.company_id = 2
AND c.id != 2
AND NOT EXISTS (
SELECT 1 FROM account_account_res_company_rel r
WHERE r.account_id = a.id AND r.company_id = c.id
)
AND NOT EXISTS (
SELECT 1 FROM account_account a2
WHERE a2.company_id = c.id
)
ON CONFLICT DO NOTHING
""")
_logger = logging.getLogger(__name__)
# Set a dummy chart template for branches to satisfy Odoo 19 POS validation
env.cr.execute("""
UPDATE res_company
SET chart_template = 'id'
WHERE id != 2 AND chart_template IS NULL
""")
def _cleanup_shared_accounts_uninstall(env):
def _auto_share_accounts_post_init(cr, registry):
"""
Remove the shared account relations upon uninstallation to prevent
UserErrors when Odoo's standard company consistency checks are restored.
Automatically mirror Bank & Cash accounts from the Parent Company (ID 2)
to branch companies that don't have their own Chart of Accounts.
Also auto-creates Bank journals in each branch for the mirrored accounts.
Odoo 17 Strategy: Since account.account uses a single company_id (M2O),
we CREATE new account records in each branch company with the same code/name
as the parent's bank/cash accounts (instead of sharing via M2M in Odoo 19).
"""
env.cr.execute("""
DELETE FROM account_account_res_company_rel
WHERE company_id != 2
AND account_id IN (SELECT id FROM account_account WHERE company_id = 2)
""")
from . import controllers
env = api.Environment(cr, SUPERUSER_ID, {})
parent_company = env['res.company'].browse(2)
if not parent_company.exists():
_logger.warning("Parent company (ID=2) not found, skipping auto-share.")
return
branch_companies = env['res.company'].search([('id', '!=', 2)])
# Find parent's bank/cash accounts
parent_bank_cash_accounts = env['account.account'].search([
('company_id', '=', parent_company.id),
('account_type', '=', 'asset_cash'),
])
if not parent_bank_cash_accounts:
_logger.info("No bank/cash accounts found in parent company, skipping.")
return
for branch in branch_companies:
# Check if branch already has its own accounts
existing_branch_accounts = env['account.account'].search([
('company_id', '=', branch.id),
('account_type', '=', 'asset_cash'),
])
if existing_branch_accounts:
_logger.info("Branch %s already has bank/cash accounts, skipping.", branch.name)
continue
# Clone parent's bank/cash accounts to this branch
for parent_acc in parent_bank_cash_accounts:
# Check if an account with the same code already exists in this branch
existing = env['account.account'].search([
('company_id', '=', branch.id),
('code', '=', parent_acc.code),
], limit=1)
if not existing:
env['account.account'].create({
'name': parent_acc.name,
'code': parent_acc.code,
'account_type': parent_acc.account_type,
'company_id': branch.id,
'reconcile': parent_acc.reconcile,
'currency_id': parent_acc.currency_id.id if parent_acc.currency_id else False,
'note': "Auto-mirrored from parent company (%s)" % parent_company.name,
})
_logger.info(
"Created mirror account %s (%s) in branch %s",
parent_acc.code, parent_acc.name, branch.name
)
# Auto-create bank journals in branch for mirrored accounts
parent_bank_journals = env['account.journal'].search([
('company_id', '=', parent_company.id),
('type', 'in', ('bank', 'cash')),
])
for parent_journal in parent_bank_journals:
existing_journal = env['account.journal'].search([
('company_id', '=', branch.id),
('code', '=', parent_journal.code),
], limit=1)
if not existing_journal:
# Find the mirrored account in the branch
branch_account = env['account.account'].search([
('company_id', '=', branch.id),
('code', '=', parent_journal.default_account_id.code),
], limit=1)
if branch_account:
env['account.journal'].create({
'name': parent_journal.name,
'code': parent_journal.code,
'type': parent_journal.type,
'company_id': branch.id,
'default_account_id': branch_account.id,
})
_logger.info(
"Created mirror journal %s (%s) in branch %s",
parent_journal.code, parent_journal.name, branch.name
)
def _cleanup_shared_accounts_uninstall(cr, registry):
"""
Remove the auto-mirrored accounts upon uninstallation.
Only removes accounts tagged with the auto-mirror note and having no journal items.
"""
env = api.Environment(cr, SUPERUSER_ID, {})
mirrored_accounts = env['account.account'].search([
('company_id', '!=', 2),
('note', 'ilike', 'Auto-mirrored from parent company'),
('account_type', '=', 'asset_cash'),
])
for acc in mirrored_accounts:
# Only delete if no journal items reference this account
has_items = env['account.move.line'].search([
('account_id', '=', acc.id),
], limit=1)
if not has_items:
_logger.info("Removing mirrored account %s from company %s", acc.code, acc.company_id.name)
acc.unlink()
else:
_logger.warning(
"Cannot remove mirrored account %s from company %s: has journal items",
acc.code, acc.company_id.name
)

View File

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*-
{
'name': 'Account Shared Bank Cash',
'version': '1.0',
'version': '17.0.1.0',
'category': 'Accounting',
'summary': 'Allows Bank & Cash accounts to be shared between companies',
'summary': 'Mirror parent bank/cash accounts to branches with inter-company clearing',
'description': """
This module removes the standard restriction that prevents Chart of Accounts (COA) of type 'Bank and Cash' from being shared across multiple companies.
This module mirrors the parent company's Bank and Cash accounts and journals to branch companies,
and provides automated inter-company clearing for POS sessions and centralized vendor payments.
""",
'author': 'Suherdy Yacob',
'depends': ['point_of_sale', 'account'],

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
from . import main

View File

@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
"""
Minimal call_kw patch for cache busting on user/company writes.
The actual company sanitization is done in environments.py directly.
"""
import logging
import odoo.service.model as _service_model
_logger = logging.getLogger(__name__)
_orig_call_kw = _service_model.call_kw
def _call_kw_wrapper(model, name, args, kwargs):
return _orig_call_kw(model, name, args, kwargs)
_service_model.call_kw = _call_kw_wrapper
_logger.info("account_shared_bank_cash: controllers loaded")

View File

@ -1,11 +1,5 @@
from . import account_account
# -*- coding: utf-8 -*-
from . import account_journal
from . import account_payment
from . import ir_http
from . import pos_payment_method
from . import pos_session
from . import pos_order
from . import account_move
from . import res_company
from . import analytic_mixin

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, models, _
from odoo.exceptions import ValidationError, UserError
class AccountAccount(models.Model):
_inherit = 'account.account'
@api.constrains('company_ids', 'account_type')
def _check_company_consistency(self):
if accounts_without_company := self.filtered(lambda a: not a.sudo().company_ids):
raise ValidationError(
self.env._(
"The following accounts must be assigned to at least one company:\n%(accounts)s",
accounts="\n".join(f"- {account.display_name}" for account in accounts_without_company),
),
)
# We explicitly REMOVE the check preventing 'asset_cash' accounts from having len(company_ids) > 1
# The standard Odoo validation below was removed:
# if self.filtered(lambda a: a.account_type == 'asset_cash' and len(a.company_ids) > 1):
# raise ValidationError(_("Bank & Cash accounts cannot be shared between companies."))
# Need to invalidate the sudo cache as we might have just written on `company_ids`
self.invalidate_recordset(fnames=['company_ids'])
for companies, accounts in self.grouped(lambda a: a.company_ids).items():
if self.env['account.move.line'].sudo().search_count([
('account_id', 'in', accounts.ids),
'!', ('company_id', 'child_of', companies.ids)
], limit=1):
raise UserError(_("You can't unlink this company from this account since there are some journal items linked to it."))

View File

@ -2,6 +2,7 @@
from odoo import models, fields, api, _
class AccountJournal(models.Model):
_inherit = 'account.journal'

View File

@ -1,179 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, api, fields, _
from odoo.exceptions import UserError, ValidationError
class AccountMove(models.Model):
_inherit = 'account.move'
rk_payment_journal_id = fields.Many2one(
'account.journal',
string='RK Payment Journal',
help='The bank/cash journal used for this Hubungan RK process'
)
def _get_last_sequence(self, relaxed=False, with_prefix=None):
self.ensure_one()
if not self.rk_payment_journal_id:
return super()._get_last_sequence(relaxed=relaxed, with_prefix=with_prefix)
if self._sequence_field not in self._fields or not self._fields[self._sequence_field].store:
raise ValidationError(_('%s is not a stored field', self._sequence_field))
where_string, param = self._get_last_sequence_domain(relaxed)
if self._origin.id:
where_string += " AND id != %(id)s "
param['id'] = self._origin.id
prefix_base = f"{self.journal_id.code}-{self.rk_payment_journal_id.code}"
if with_prefix is not None:
where_string += " AND sequence_prefix = %(with_prefix)s "
param['with_prefix'] = with_prefix
else:
where_string += " AND sequence_prefix LIKE %(prefix_like)s "
param['prefix_like'] = f"{prefix_base}/%"
query = f"""
SELECT {self._sequence_field} FROM {self._table}
{where_string}
AND sequence_prefix = (SELECT sequence_prefix FROM {self._table} {where_string} ORDER BY id DESC LIMIT 1)
ORDER BY sequence_number DESC
LIMIT 1
"""
self.flush_model([self._sequence_field, 'sequence_number', 'sequence_prefix'])
self.env.cr.execute(query, param)
return (self.env.cr.fetchone() or [None])[0]
def _get_starting_sequence(self):
self.ensure_one()
if self.rk_payment_journal_id:
move_date = self.date or self.invoice_date or fields.Date.context_today(self)
year_part = "%04d" % move_date.year
prefix_base = f"{self.journal_id.code}-{self.rk_payment_journal_id.code}"
return f"{prefix_base}/{year_part}/{move_date.month:02d}/0000"
return super()._get_starting_sequence()
def action_post(self):
res = super().action_post()
# 1. Handle Centralized entries in Parent when payment moves are posted
for move in self:
payments = move.payment_ids or move.origin_payment_id
if not payments:
continue
centralized_payments = payments.filtered(
lambda p: p.journal_id.sudo().is_centralized and
p.company_id != p.journal_id.sudo().parent_company_id
)
for payment in centralized_payments:
journal_sudo = payment.journal_id.sudo()
parent_company = journal_sudo.parent_company_id
parent_journal = journal_sudo.parent_journal_id
parent_rk_account = journal_sudo.parent_intercompany_account_id
if not journal_sudo.branch_intercompany_account_id:
raise UserError(_("Please configure the Branch Inter-company Account on journal %s", payment.journal_id.name))
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))
parent_journal_sudo = parent_journal.sudo()
parent_rk_account_sudo = parent_rk_account.sudo()
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_sudo.name} (ID: {parent_journal_sudo.id})")
print(f" Parent RK: {parent_rk_account_sudo.code} (ID: {parent_rk_account_sudo.id})")
# Calculate currency-adjusted amount for the parent company
parent_currency = parent_company.currency_id
if payment.currency_id != parent_currency:
amount_parent_curr = payment.currency_id._convert(
payment.amount, parent_currency, parent_company, payment.date
)
else:
amount_parent_curr = payment.amount
# Create the mirroring entry in Parent using sudo
parent_move = self.env['account.move'].sudo().with_company(parent_company).create({
'move_type': 'entry',
'date': payment.date,
'company_id': parent_company.id,
'journal_id': parent_journal.id,
'partner_id': payment.company_id.partner_id.id,
'currency_id': parent_currency.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': amount_parent_curr,
'credit': 0.0,
'partner_id': payment.company_id.partner_id.id,
'company_id': parent_company.id,
'currency_id': payment.currency_id.id,
'amount_currency': payment.amount,
'display_type': 'product',
}),
(0, 0, {
'name': payment.memo or f"Centralized Payment: {payment.name}",
'account_id': parent_journal_sudo.default_account_id.id,
'debit': 0.0,
'credit': amount_parent_curr,
'partner_id': payment.company_id.partner_id.id,
'company_id': parent_company.id,
'currency_id': payment.currency_id.id,
'amount_currency': -payment.amount,
'display_type': 'product',
}),
]
})
parent_move.sudo().action_post()
print(f" Parent Move Posted: {parent_move.name}")
return res
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
@api.model
def _search_account_id(self, operator, value):
res = super()._search_account_id(operator, value)
if res and isinstance(res, list) and len(res) == 1:
term = res[0]
if len(term) == 3 and term[0] == 'account_id' and term[1] in ('in', 'not in') and isinstance(term[2], (list, tuple, set)):
resolved_ids = list(term[2])
reconcile_accounts = self.env['account.account'].sudo().browse(resolved_ids).filtered('reconcile')
if reconcile_accounts:
cash_accounts = self.env['account.account'].sudo().search([('account_type', '=', 'asset_cash')])
if cash_accounts:
if term[1] == 'in':
res = [
'|',
('account_id', 'in', tuple(reconcile_accounts.ids)),
'&',
'&',
('account_id', 'in', tuple(cash_accounts.ids)),
('journal_id.type', '=', 'cash'),
('journal_id.company_id', '=', self.env.company.id)
]
elif term[1] == 'not in':
res = [
'&',
('account_id', 'not in', tuple(reconcile_accounts.ids)),
'|',
'|',
('account_id', 'not in', tuple(cash_accounts.ids)),
('journal_id.type', '!=', 'cash'),
('journal_id.company_id', '!=', self.env.company.id)
]
return res
def _related_analytic_distribution(self):
res = super()._related_analytic_distribution()
if res:
return self.env['analytic.mixin']._clean_analytic_distribution(res)
return res

View File

@ -1,22 +1,87 @@
# -*- coding: utf-8 -*-
from odoo import models, api, fields, _
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
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. Validate centralized payments before posting
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
)
# 3. Call super() to post the original payment(s)
res = super().action_post()
# 4. 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
_logger.info(
"Centralized Payment for %s | Company: %s -> Parent: %s | Parent Journal: %s | RK: %s",
payment.name, payment.company_id.name, parent_company.name,
parent_journal.name, parent_rk_account.code
)
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': _("Centralized Payment: %s (%s)") % (payment.name, payment.company_id.name),
'line_ids': [
(0, 0, {
'name': payment.ref or _("Centralized Payment: %s") % payment.name,
'account_id': parent_rk_account.id,
'debit': payment.amount,
'partner_id': payment.partner_id.id,
'company_id': parent_company.id,
}),
(0, 0, {
'name': payment.ref or _("Centralized Payment: %s") % payment.name,
'account_id': parent_journal.default_account_id.id,
'credit': payment.amount,
'partner_id': payment.partner_id.id,
'company_id': parent_company.id,
}),
]
})
parent_move.action_post()
_logger.info("Parent Move Posted: %s", 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)
journal_sudo = self.journal_id.sudo()
if journal_sudo.is_centralized and self.company_id != journal_sudo.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.
if self.journal_id.is_centralized and self.company_id != self.journal_id.parent_company_id:
# Update the account if it matches the journal's default (liquidity account).
for line_vals in res:
if line_vals.get('account_id') == journal_sudo.default_account_id.id:
line_vals['account_id'] = journal_sudo.branch_intercompany_account_id.id
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

View File

@ -1,37 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, api
class AnalyticMixin(models.AbstractModel):
_inherit = 'analytic.mixin'
def _sanitize_values(self, vals, decimal_precision):
if 'analytic_distribution' in vals:
dist = vals.get('analytic_distribution')
if dist:
if isinstance(dist, str):
import json
try:
dist = json.loads(dist)
except Exception:
dist = {}
vals['analytic_distribution'] = self._clean_analytic_distribution(dist) or False
else:
vals['analytic_distribution'] = False
return super()._sanitize_values(vals, decimal_precision)
@api.model
def _clean_analytic_distribution(self, dist):
if not dist or not isinstance(dist, dict):
return {}
clean_dist = {}
for key, val in dist.items():
str_key = str(key)
if str_key == '__update__':
clean_dist[str_key] = val
elif str_key:
if all(part.isdigit() for part in str_key.split(',')):
try:
clean_dist[str_key] = float(val)
except (TypeError, ValueError):
pass
return clean_dist

View File

@ -1,138 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models
from odoo.http import request
import logging
_logger = logging.getLogger(__name__)
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _pre_dispatch(cls, rule, args):
"""
Sanitize allowed_company_ids in the request session, request environment,
and request parameters contexts BEFORE the environment is fully used.
This prevents AccessError from environments.py when any of these contexts
contain company IDs that are not in the user's authorized list.
"""
try:
if request.env.uid:
# Use sudo() to query the user's companies safely, bypassing company restrictions.
user_cids = set(request.env.user.sudo()._get_company_ids())
default_cid = request.env.user.sudo().company_id.id
def sanitize_context_cids(context, user_cids, default_cid):
if not isinstance(context, dict) or 'allowed_company_ids' not in context:
return False
cids = context['allowed_company_ids']
if not isinstance(cids, list):
return False
valid_cids = [c for c in cids if c in user_cids]
if not valid_cids:
valid_cids = [default_cid]
if valid_cids != cids:
_logger.warning(
"Sanitized allowed_company_ids in context from %s to %s for user %s",
cids, valid_cids, request.env.uid,
)
context['allowed_company_ids'] = valid_cids
return True
return False
# Sanitize session context
if getattr(request, 'session', None) and getattr(request.session, 'context', None):
sanitize_context_cids(request.session.context, user_cids, default_cid)
# Sanitize request.env.context
env_context = dict(request.env.context)
if sanitize_context_cids(env_context, user_cids, default_cid):
request.update_context(**env_context)
# Sanitize request params root context
if isinstance(getattr(request, 'params', None), dict):
root_context = request.params.get('context')
if isinstance(root_context, dict):
sanitize_context_cids(root_context, user_cids, default_cid)
# Sanitize request params kwargs context
kwargs = request.params.get('kwargs')
if isinstance(kwargs, dict):
kwargs_context = kwargs.get('context')
if isinstance(kwargs_context, dict):
sanitize_context_cids(kwargs_context, user_cids, default_cid)
except Exception as e:
_logger.warning(
"IrHttp: could not sanitize company context in _pre_dispatch: %s", e,
)
# Roll back the aborted transaction so subsequent DB queries in this
# request can still execute. Without this PostgreSQL keeps the
# connection in an InFailedSqlTransaction state and rejects every
# further query in the same request.
try:
request.env.cr.rollback()
except Exception:
pass
return super()._pre_dispatch(rule, args)
@classmethod
def _dispatch(cls, endpoint):
"""
Sanitize allowed_company_ids in the request parameters (request.params)
context AFTER they have been populated by the JSON-RPC dispatcher but
BEFORE the controller/model method is executed.
"""
try:
if request.env.uid:
# Use sudo() to query the user's companies safely, bypassing company restrictions.
user_cids = set(request.env.user.sudo()._get_company_ids())
default_cid = request.env.user.sudo().company_id.id
def sanitize_context_cids(context, user_cids, default_cid):
if not isinstance(context, dict) or 'allowed_company_ids' not in context:
return False
cids = context['allowed_company_ids']
if not isinstance(cids, list):
return False
valid_cids = [c for c in cids if c in user_cids]
if not valid_cids:
valid_cids = [default_cid]
if valid_cids != cids:
_logger.warning(
"Sanitized allowed_company_ids in _dispatch from %s to %s for user %s",
cids, valid_cids, request.env.uid,
)
context['allowed_company_ids'] = valid_cids
return True
return False
# Sanitize request params root context
if isinstance(getattr(request, 'params', None), dict):
root_context = request.params.get('context')
if isinstance(root_context, dict):
sanitize_context_cids(root_context, user_cids, default_cid)
# Sanitize request params kwargs context
kwargs = request.params.get('kwargs')
if isinstance(kwargs, dict):
kwargs_context = kwargs.get('context')
if isinstance(kwargs_context, dict):
sanitize_context_cids(kwargs_context, user_cids, default_cid)
except Exception as e:
_logger.warning(
"IrHttp: could not sanitize company context in _dispatch: %s", e,
)
# Roll back the aborted transaction so subsequent DB queries in this
# request can still execute. Without this PostgreSQL keeps the
# connection in an InFailedSqlTransaction state and rejects every
# further query in the same request.
try:
request.env.cr.rollback()
except Exception:
pass
return super()._dispatch(endpoint)

View File

@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models
class PosOrder(models.Model):
_inherit = 'pos.order'
def _process_saved_order(self, draft):
"""
Escalate to superuser during saved order post-processing (picking creation,
cost computation, etc.). This avoids multi-company access/environment checks
when POS cashier handles cross-company or company-dependent properties.
"""
return super(PosOrder, self.sudo())._process_saved_order(draft)

View File

@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, Command
from odoo import models, fields, api, Command, _
import logging
_logger = logging.getLogger(__name__)
class PosSession(models.Model):
_inherit = 'pos.session'
@ -12,7 +13,7 @@ class PosSession(models.Model):
res = super(PosSession, self)._validate_session(balancing_account, amount_to_balance, bank_payment_method_diffs)
# After the standard validation and account move creation, we create the inter-company clearing moves
self.sudo()._create_intercompany_clearing_moves()
self._create_intercompany_clearing_moves()
return res
@ -47,13 +48,15 @@ class PosSession(models.Model):
# Manually handle intercompany ones: create the line in main move but skip account.payment
for pm, amounts in intercompany_combine.items():
combine_receivable_line = MoveLine.create(self._get_combine_receivable_vals(pm, amounts['amount'], amounts['amount_converted']))
combine_receivable_line = MoveLine.create(
self._get_combine_receivable_vals(pm, amounts['amount'], amounts['amount_converted'])
)
res_data['payment_method_to_receivable_lines'][pm] = combine_receivable_line
return res_data
def _create_intercompany_clearing_moves(self):
for session in self.sudo():
for session in self:
if session.state != 'closed' or not session.move_id:
continue
@ -65,8 +68,7 @@ class PosSession(models.Model):
for order in orders:
for payment in order.payment_ids:
pm_id = payment.payment_method_id.id
pm = self.env['pos.payment.method'].sudo().browse(pm_id)
pm = payment.payment_method_id
if pm.intercompany_clearing_account_id and pm.intercompany_clearing_journal_id:
if pm not in clearing_amounts:
clearing_amounts[pm] = 0.0
@ -83,8 +85,8 @@ class PosSession(models.Model):
journal_to_pms[journal].append(pm)
for clearing_journal, pms in journal_to_pms.items():
aggregated_data = {} # Key: (receivable_account, intercompany_account)
pm_level_data = [] # For parent mirror entries
aggregated_data = {} # Key: (receivable_account, intercompany_account)
pm_level_data = [] # For parent mirror entries
for pm in pms:
amount = clearing_amounts[pm]
@ -98,7 +100,8 @@ class PosSession(models.Model):
amount_company_curr = amount
if session.currency_id != session.company_id.currency_id:
amount_company_curr = session.currency_id._convert(
amount, session.company_id.currency_id, session.company_id, session.stop_at or fields.Date.context_today(session)
amount, session.company_id.currency_id, session.company_id,
session.stop_at or fields.Date.context_today(session)
)
# Store PM level data for parent mirror
@ -126,32 +129,32 @@ class PosSession(models.Model):
continue
line_ids = []
for key, data in aggregated_data.items():
for key, agg_data in aggregated_data.items():
# CREDIT: Total AR in Transit
line_ids.append(Command.create({
'name': f"Total Clearing - {session.name}",
'account_id': data['receivable_account'].id,
'credit': data['total_company_curr'],
'name': _("Total Clearing - %s") % session.name,
'account_id': agg_data['receivable_account'].id,
'credit': agg_data['total_company_curr'],
'debit': 0.0,
'currency_id': session.currency_id.id,
'amount_currency': -data['total_amount'],
'amount_currency': -agg_data['total_amount'],
}))
# DEBIT: Total Hubungan RK
line_ids.append(Command.create({
'name': f"Total Due from Parent - {session.name}",
'account_id': data['intercompany_account'].id,
'name': _("Total Due from Parent - %s") % session.name,
'account_id': agg_data['intercompany_account'].id,
'credit': 0.0,
'debit': data['total_company_curr'],
'debit': agg_data['total_company_curr'],
'currency_id': session.currency_id.id,
'amount_currency': data['total_amount'],
'amount_currency': agg_data['total_amount'],
}))
# --- BRANCH SIDE: Aggregated Clearing Move (Target: 2 items) ---
move_vals = {
'journal_id': clearing_journal.id,
'date': session.stop_at or fields.Date.context_today(session),
'ref': f"Inter-company clearing for {session.name}",
'ref': _("Inter-company clearing for %s") % session.name,
'move_type': 'entry',
'company_id': session.company_id.id,
'line_ids': line_ids,
@ -159,12 +162,12 @@ class PosSession(models.Model):
try:
clearing_move = self.env['account.move'].sudo().with_company(session.company_id).create(move_vals)
clearing_move.sudo()._post()
clearing_move._post()
# 1. Reconcile aggregated lines with session move
for key, data in aggregated_data.items():
receivable_account = data['receivable_account']
pms = data['pms']
for key, agg_data in aggregated_data.items():
receivable_account = agg_data['receivable_account']
pm_list = agg_data['pms']
try:
# Find the aggregated credit line
@ -172,31 +175,35 @@ class PosSession(models.Model):
lambda l: l.account_id == receivable_account and l.credit > 0
)
# Find all matching debit lines in the session move
# Odoo 19 names these lines as "SessionName - PMName"
# Odoo 17 names these lines as "SessionName - PMName"
pos_debit_lines = session.move_id.line_ids.filtered(
lambda l: l.account_id == receivable_account and l.debit > 0 and \
any(l.name.endswith(f" - {pm.name}") for pm in pms)
lambda l: l.account_id == receivable_account and l.debit > 0 and
any(l.name.endswith(" - %s" % pm.name) for pm in pm_list)
)
if clearing_credit_line and pos_debit_lines:
(clearing_credit_line + pos_debit_lines).reconcile()
except Exception as re_e:
_logger.warning("Could not auto-reconcile aggregated clearing lines for session %s: %s", session.name, re_e)
_logger.warning(
"Could not auto-reconcile aggregated clearing lines for session %s: %s",
session.name, re_e
)
# 2. Create parent mirror move (aggregated into one entry but separate lines per PM)
# 2. Create parent mirror move
self._create_aggregated_parent_mirror_move(session, pm_level_data, clearing_move)
except Exception as e:
_logger.error("Failed to create/post aggregated inter-company clearing move for session %s: %s", session.name, e)
_logger.error(
"Failed to create/post aggregated inter-company clearing move for session %s: %s",
session.name, e
)
def _get_related_account_moves(self):
res = super()._get_related_account_moves()
for session in self:
clearing_moves = self.env['account.move'].sudo().search([
('company_id', '=', session.company_id.id),
('ref', '=', f"Inter-company clearing for {session.name}")
('ref', '=', _("Inter-company clearing for %s") % session.name)
])
res |= clearing_moves
return res
@ -225,21 +232,23 @@ class PosSession(models.Model):
continue
outstanding_receipt_account = None
for pml in parent_bank_journal.sudo().inbound_payment_method_line_ids:
if pml.payment_account_id or pml.default_account_id:
outstanding_receipt_account = pml.payment_account_id or pml.default_account_id
for pml in parent_bank_journal.inbound_payment_method_line_ids:
if pml.payment_account_id:
outstanding_receipt_account = pml.payment_account_id
break
if not outstanding_receipt_account:
_logger.warning("No outstanding receipt account found on parent bank journal %s. Skipping PM %s.", parent_bank_journal.name, pm.name)
_logger.warning(
"No outstanding receipt account found on parent bank journal %s. Skipping PM %s.",
parent_bank_journal.name, pm.name
)
continue
group_key = (parent_company.id, parent_clearing_journal.id, parent_bank_journal.id)
group_key = (parent_company.id, parent_clearing_journal.id)
if group_key not in parent_groups:
parent_groups[group_key] = {
'parent_company': parent_company,
'parent_clearing_journal': parent_clearing_journal,
'parent_bank_journal': parent_bank_journal,
'lines_data': []
}
@ -256,7 +265,6 @@ class PosSession(models.Model):
for group_key, group_data in parent_groups.items():
parent_company = group_data['parent_company']
parent_clearing_journal = group_data['parent_clearing_journal']
parent_bank_journal = group_data['parent_bank_journal']
line_ids = []
@ -273,21 +281,19 @@ class PosSession(models.Model):
amount_parent_curr = amount
line_ids.append(Command.create({
'name': f"POS Receipt: {pm.name} ({session.company_id.name})",
'name': _("POS Receipt: %s (%s)") % (pm.name, session.company_id.name),
'account_id': line_data['outstanding_receipt_account'].id,
'debit': amount_parent_curr,
'credit': 0.0,
'partner_id': session.company_id.partner_id.id,
'currency_id': session.currency_id.id,
'amount_currency': amount,
}))
line_ids.append(Command.create({
'name': f"Due to Branch: {pm.name} ({session.company_id.name})",
'name': _("Due to Branch: %s (%s)") % (pm.name, session.company_id.name),
'account_id': line_data['parent_rk_account'].id,
'debit': 0.0,
'credit': amount_parent_curr,
'partner_id': session.company_id.partner_id.id,
'currency_id': session.currency_id.id,
'amount_currency': -amount,
}))
@ -298,17 +304,15 @@ class PosSession(models.Model):
parent_move_vals = {
'journal_id': parent_clearing_journal.id,
'date': entry_date,
'ref': f"POS Mirror: {session.name} - {session.company_id.name}",
'ref': _("POS Mirror: %s - %s") % (session.name, session.company_id.name),
'move_type': 'entry',
'company_id': parent_company.id,
'partner_id': session.company_id.partner_id.id,
'rk_payment_journal_id': parent_bank_journal.id,
'line_ids': line_ids,
}
try:
parent_move = self.env['account.move'].sudo().with_company(parent_company).create(parent_move_vals)
parent_move.sudo()._post()
parent_move._post()
_logger.info(
"Created aggregated parent mirror entry %s in company %s for session %s (Lines: %s)",
parent_move.name, parent_company.name, session.name, len(line_ids)
@ -318,12 +322,3 @@ class PosSession(models.Model):
"Failed to create aggregated parent mirror entry for session %s in company %s: %s",
session.name, parent_company.name, e
)
class StockPicking(models.Model):
_inherit = 'stock.picking'
@api.model
def _create_picking_from_pos_order_lines(self, location_dest_id, lines, picking_type, partner=False):
return super()._create_picking_from_pos_order_lines(location_dest_id, lines.sudo(), picking_type, partner)

View File

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class ResCompany(models.Model):
_inherit = 'res.company'
company_id = fields.Many2one(
'res.company',
string='Company',
compute='_compute_company_id',
search='_search_company_id'
)
@api.depends()
def _compute_company_id(self):
for company in self:
company.company_id = company.id
def _search_company_id(self, operator, value):
return [('id', operator, value)]

View File

@ -1,26 +0,0 @@
import sys
# Get the last session
session = env['pos.session'].search([('state', '=', 'closed')], order='id desc', limit=1)
print(f"Session: {session.name} (ID {session.id})")
# Check main POS move
print(f"Main POS Move: {session.move_id.name} (ID {session.move_id.id})")
for line in session.move_id.line_ids:
print(f" Line: {line.name} | Account: {line.account_id.code} | Debit: {line.debit} | Credit: {line.credit} | Reconciled: {line.reconciled}")
# Check for Bank Journal moves (these should NOT exist for intercompany PMs)
# Bank journal moves usually have payment_method_id set on account.payment
payments = env['account.payment'].search([('pos_session_id', '=', session.id)])
print(f"Payment moves found: {len(payments)}")
for p in payments:
print(f" Payment: {p.name} | Journal: {p.journal_id.name} | Amount: {p.amount}")
# Check for Inter-company clearing moves
clearing_moves = env['account.move'].search([('ref', 'like', f"Inter-company clearing for {session.name}%")])
print(f"Clearing moves found: {len(clearing_moves)}")
for move in clearing_moves:
print(f"Move: {move.name} | Ref: {move.ref} | State: {move.state}")
for line in move.line_ids:
print(f" Line: {line.name} | Account: {line.account_id.code} {line.account_id.name} | Debit: {line.debit} | Credit: {line.credit} | Reconciled: {line.reconciled}")

View File

@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
"""
Test script: Verify aggregation to 2 lines on branch side.
"""
from datetime import datetime
COMPANY_NAME = "Mie Mapan Rungkut Mapan"
POS_CONFIG_NAME = "POS Rungkut"
company = env['res.company'].search([('name', '=', COMPANY_NAME)], limit=1)
pos_config = env['pos.config'].search([('name', '=', POS_CONFIG_NAME), ('company_id', '=', company.id)], limit=1)
# Get BCA and BTN
bca_pm = pos_config.payment_method_ids.filtered(lambda pm: pm.name == 'BCA')
btn_pm = pos_config.payment_method_ids.filtered(lambda pm: pm.name == 'BTN')
# Create session
session = env['pos.session'].with_company(company).create({
'user_id': env.uid,
'config_id': pos_config.id,
})
session.sudo().set_opening_control(0, None)
# Create 2 orders
product = env['product.product'].search([('available_in_pos', '=', True)], limit=1)
for pm, amount in [(bca_pm, 10000), (btn_pm, 20000)]:
order = env['pos.order'].create({
'company_id': company.id,
'session_id': session.id,
'pos_reference': f'TEST/{session.id}/{pm.name}',
'date_order': datetime.now(),
'amount_tax': 0, 'amount_total': amount, 'amount_paid': amount, 'amount_return': 0,
'lines': [(0, 0, {'product_id': product.id, 'qty': 1, 'price_unit': amount, 'price_subtotal': amount, 'price_subtotal_incl': amount})],
})
env['pos.payment'].create({
'pos_order_id': order.id, 'payment_method_id': pm.id, 'amount': amount, 'payment_date': datetime.now(),
})
order.action_pos_order_paid()
# Close session
session.sudo().action_pos_session_closing_control()
# Verify related moves
related_moves = session._get_related_account_moves()
clearing_move = related_moves.filtered(lambda m: m.ref == f"Inter-company clearing for {session.name}")
print(f"Session: {session.name}")
print(f"Clearing Move: {clearing_move.name}")
print(f"Number of lines in clearing move: {len(clearing_move.line_ids)}")
for line in clearing_move.line_ids:
print(f" {line.account_id.code} {line.name}: {line.debit or -line.credit}")
env.cr.rollback()

View File

@ -1,27 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_journal_form_inherit_centralized" model="ir.ui.view">
<field name="name">account.journal.form.inherit.centralized</field>
<record id="view_account_journal_form_inherit" model="ir.ui.view">
<field name="name">account.journal.form.inherit.shared.bank</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='bank_account']" position="after">
<page name="centralized_payment" string="Centralized Payment" invisible="type not in ('bank', 'cash')">
<page name="centralized_payment" string="Centralized Payment">
<group>
<group string="Configuration">
<field name="is_centralized"/>
<field name="parent_company_id" invisible="not is_centralized" required="is_centralized"/>
<field name="parent_journal_id" invisible="not is_centralized" required="is_centralized"/>
<field name="is_centralized"/>
</group>
<group attrs="{'invisible': [('is_centralized', '=', False)]}">
<group string="Parent Company Settings">
<field name="parent_company_id"
attrs="{'required': [('is_centralized', '=', True)]}"/>
<field name="parent_journal_id"
attrs="{'required': [('is_centralized', '=', True)]}"/>
</group>
<group string="Inter-Company Accounts" invisible="not is_centralized">
<field name="branch_intercompany_account_id" required="is_centralized"/>
<field name="parent_intercompany_account_id" required="is_centralized"/>
<group string="Inter-Company (RK) Accounts">
<field name="parent_intercompany_account_id"
attrs="{'required': [('is_centralized', '=', True)]}"/>
<field name="branch_intercompany_account_id"
attrs="{'required': [('is_centralized', '=', True)]}"/>
</group>
</group>
<p class="text-muted" invisible="not is_centralized">
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.
</p>
</page>
</xpath>
</field>

View File

@ -1,26 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="pos_payment_method_view_form_inherit_intercompany" model="ir.ui.view">
<field name="name">pos.payment.method.form.inherit.intercompany</field>
<record id="view_pos_payment_method_form_inherit" model="ir.ui.view">
<field name="name">pos.payment.method.form.inherit.shared.bank</field>
<field name="model">pos.payment.method</field>
<field name="inherit_id" ref="point_of_sale.pos_payment_method_view_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='Payment methods']" position="after">
<group name="intercompany_clearing" string="Inter-Company Clearing (Branch Side)">
<field name="intercompany_clearing_account_id"/>
<field name="intercompany_clearing_journal_id"
invisible="not intercompany_clearing_account_id"
required="intercompany_clearing_account_id"/>
<group name="intercompany_clearing" string="Inter-Company Clearing">
<group>
<field name="intercompany_clearing_account_id"/>
<field name="intercompany_clearing_journal_id"
attrs="{'required': [('intercompany_clearing_account_id', '!=', False)]}"/>
</group>
</group>
<group name="parent_clearing" string="Inter-Company Clearing (Parent Side)"
invisible="not intercompany_clearing_account_id">
<field name="parent_company_id"/>
<field name="parent_bank_journal_id"
required="intercompany_clearing_account_id"/>
<field name="parent_intercompany_account_id"
required="intercompany_clearing_account_id"/>
<field name="parent_clearing_journal_id"
required="intercompany_clearing_account_id"/>
<group name="parent_mirror_entry" string="Parent Mirror Entry"
attrs="{'invisible': [('intercompany_clearing_account_id', '=', False)]}">
<group>
<field name="parent_company_id"/>
<field name="parent_bank_journal_id"
attrs="{'required': [('parent_company_id', '!=', False)]}"/>
<field name="parent_intercompany_account_id"
attrs="{'required': [('parent_company_id', '!=', False)]}"/>
<field name="parent_clearing_journal_id"
attrs="{'required': [('parent_company_id', '!=', False)]}"/>
</group>
</group>
</xpath>
</field>

Binary file not shown.

View File

@ -2,12 +2,16 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class AccountPaymentRegister(models.TransientModel):
_inherit = 'account.payment.register'
def _create_payments(self):
# Intercept centralized payments
"""Intercept centralized payments to create inter-company clearing moves."""
if self.journal_id.is_centralized:
return self._create_centralized_payments()
return super(AccountPaymentRegister, self)._create_payments()
@ -15,42 +19,47 @@ class AccountPaymentRegister(models.TransientModel):
def _create_centralized_payments(self):
"""
Custom logic to create inter-company clearing moves when paying via a centralized journal.
Branch side: Creates a clearing move (Debit Payable, Credit RK)
Parent side: Creates an account.payment (Debit RK, Credit Bank)
"""
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."))
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."))
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:
# Use _get_batches() - Odoo 17 method (not self.batches property)
all_batches = self._get_batches()
for batch_result in all_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')))
# Currency logic: debit/credit in company currency, amount_currency in foreign currency.
amount_company_curr = abs(sum(lines.mapped('amount_residual')))
amount_currency = 0.0
currency_id = False
if self.currency_id != branch_company.currency_id:
amount_currency = sum(lines.mapped('amount_residual_currency'))
currency_id = self.currency_id.id
# 1. Create Clearing Move in Branch Company
# Debit: Payable Account (clears the vendor bill)
# Credit: Inter-company (RK) Account (Liability to Parent)
# Currency logic: Odoo 19 requires debit/credit in company currency
# and amount_currency in foreign/company currency.
amount_company_curr = abs(sum(lines.mapped('amount_residual')))
# Fallback to the company currency if no foreign currency is used
currency_id = self.currency_id.id or branch_company.currency_id.id
if self.currency_id != branch_company.currency_id:
amount_currency = abs(sum(lines.mapped('amount_residual_currency')))
else:
amount_currency = amount_company_curr
clearing_move_vals = {
'move_type': 'entry',
'company_id': branch_company.id,
@ -61,14 +70,14 @@ class AccountPaymentRegister(models.TransientModel):
(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
'account_id': lines[0].account_id.id, # The payable account
'debit': amount_company_curr if self.payment_type == 'outbound' else 0.0,
'credit': amount_company_curr if self.payment_type == 'inbound' else 0.0,
'currency_id': currency_id,
'amount_currency': amount_currency if self.payment_type == 'outbound' else -amount_currency,
}),
(0, 0, {
'name': _("Due to Parent (%s)") % parent_company.sudo().name,
'name': _("Due to Parent (%s)") % parent_company.name,
'partner_id': False,
'account_id': journal.branch_intercompany_account_id.id,
'debit': amount_company_curr if self.payment_type == 'inbound' else 0.0,
@ -97,13 +106,16 @@ class AccountPaymentRegister(models.TransientModel):
'amount': abs(amount_currency) if currency_id else amount_company_curr,
'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'))),
'ref': _("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'].sudo().with_company(parent_company).create(payment_vals)
parent_payment.sudo().action_post()
parent_payment = self.env['account.payment'].with_company(parent_company).sudo().create(payment_vals)
parent_payment.action_post()
payments |= parent_payment