From 90b85e947a759aa59f86cb031417a8f367576b4b Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 31 Mar 2026 11:56:50 +0700 Subject: [PATCH] first commit --- .gitignore | 5 ++ README.md | 24 +++++ __init__.py | 1 + __manifest__.py | 14 +++ models/__init__.py | 4 + models/account_move.py | 140 ++++++++++++++++++++++++++++++ models/account_move_line.py | 30 +++++++ models/account_payment.py | 20 +++++ models/res_partner.py | 13 +++ views/account_move_line_views.xml | 16 ++++ 10 files changed, 267 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/account_move.py create mode 100644 models/account_move_line.py create mode 100644 models/account_payment.py create mode 100644 models/res_partner.py create mode 100644 views/account_move_line_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..752df4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.py[cod] +__pycache__/ +*.swp +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..63797f5 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Account Payment: Current Liability Override + +## Overview +By default, Odoo restricts the creation of payments strictly to accounts of type `asset_receivable` (Receivable) or `liability_payable` (Payable). This prevents businesses from cleanly mapping vendor or employee payables to `liability_current` (Current Liabilities) accounts, such as "Accrued Expenses". + +This module overrides those strict native constraints to allow `liability_current` accounts to be natively selected and processed in payments. + +## Features +- Overrides the `property_account_payable_id` domain on the `res.partner` form to allow selecting `liability_current` as a valid payable account. +- Overrides the `destination_account_id` domain in `account.payment` to support `liability_current`. +- Overrides `_get_valid_payment_account_types()` in `account.payment` to natively include `liability_current` in the payment registration wizard validation check. + +## Setup Requirements ⚠️ +Even with this module installed, Odoo still requires any account used for payment clearing/matching to allow reconciliation. + +**You MUST:** +1. Go to your Chart of Accounts. +2. Locate the specific `Current Liabilities` account you wish to use (e.g., `216109`). +3. Manually **tick** the `"Allow Reconciliation"` checkbox for that account. + +Without this checkbox, Odoo will raise errors during the Payment Registration process because the journal items cannot be paired. + +## Dependencies +- `account` 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..87ba0c3 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': 'Account Payment: Current Liability Override', + 'version': '17.0.1.0.0', + 'summary': 'Allow employees to use a Current Liability account (liability_current) as their payable account and process payments correctly.', + 'category': 'Accounting/Accounting', + 'author': 'Suherdy Yacob', + 'depends': ['account'], + 'data': [ + 'views/account_move_line_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..298a3f2 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_partner +from . import account_payment +from . import account_move_line +from . import account_move diff --git a/models/account_move.py b/models/account_move.py new file mode 100644 index 0000000..e5009af --- /dev/null +++ b/models/account_move.py @@ -0,0 +1,140 @@ +from odoo import api, models, fields +from odoo.tools.translate import _ + +class AccountMove(models.Model): + _inherit = 'account.move' + + @api.depends('amount_residual', 'move_type', 'state', 'company_id') + def _compute_payment_state(self): + super()._compute_payment_state() + for invoice in self: + if invoice.payment_state == 'paid' and invoice.is_invoice(include_receipts=True): + term_lines = invoice.line_ids.filtered(lambda l: l.display_type == 'payment_term' and l.account_id.account_type == 'liability_current') + if term_lines and invoice.currency_id.is_zero(invoice.amount_residual): + payments = (term_lines.matched_debit_ids.debit_move_id.move_id.payment_id | + term_lines.matched_credit_ids.credit_move_id.move_id.payment_id) + if any(not p.is_matched for p in payments if p): + invoice.payment_state = invoice._get_invoice_in_payment_state() + + def _get_all_reconciled_invoice_partials(self): + reconciled_partials = super()._get_all_reconciled_invoice_partials() + if isinstance(reconciled_partials, dict): + reconciled_partials = [] + + self.ensure_one() + + # If the super() returns something, it might only be for payable/receivable. + # We also need to fetch partials for liability_current + reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.account_type == 'liability_current') + if not reconciled_lines: + return reconciled_partials + + self.env['account.partial.reconcile'].flush_model([ + 'credit_amount_currency', 'credit_move_id', 'debit_amount_currency', + 'debit_move_id', 'exchange_move_id', + ]) + query = ''' + SELECT + part.id, + part.exchange_move_id, + part.debit_amount_currency AS amount, + part.credit_move_id AS aml_id, + 'debit' AS direction, + part.create_date + FROM account_partial_reconcile part + WHERE part.debit_move_id IN %s + UNION ALL + SELECT + part.id, + part.exchange_move_id, + part.credit_amount_currency AS amount, + part.debit_move_id AS aml_id, + 'credit' AS direction, + part.create_date + FROM account_partial_reconcile part + WHERE part.credit_move_id IN %s + ORDER BY create_date DESC + ''' + self.env.cr.execute(query, [tuple(reconciled_lines.ids), tuple(reconciled_lines.ids)]) + + aml_ids = set() + exchange_move_ids = set() + partials_data = [] + for row in self.env.cr.dictfetchall(): + aml_ids.add(row['aml_id']) + if row['exchange_move_id']: + exchange_move_ids.add(row['exchange_move_id']) + partials_data.append(row) + + if not partials_data: + return reconciled_partials + + amls = self.env['account.move.line'].browse(list(aml_ids)).filtered(lambda aml: aml.move_id.id not in exchange_move_ids) + amls_dict = {aml.id: aml for aml in amls} + + for partial in partials_data: + if partial['aml_id'] not in amls_dict: + continue + counterpart_aml = amls_dict[partial['aml_id']] + reconciled_partials.append({ + 'aml': counterpart_aml, + 'amount': partial['amount'], + 'currency': self.currency_id, + 'is_exchange': bool(partial['exchange_move_id']), + 'partial_id': partial['id'], + }) + + return reconciled_partials + + def _compute_payments_widget_to_reconcile_info(self): + super()._compute_payments_widget_to_reconcile_info() + for move in self: + if move.state != 'posted' or move.payment_state not in ('not_paid', 'partial') or not move.is_invoice(include_receipts=True): + continue + + pay_term_lines = move.line_ids.filtered(lambda line: line.account_id.account_type == 'liability_current') + if not pay_term_lines: + continue + + domain = [ + ('account_id', 'in', pay_term_lines.account_id.ids), + ('parent_state', '=', 'posted'), + ('partner_id', '=', move.commercial_partner_id.id), + ('reconciled', '=', False), + '|', ('amount_residual', '!=', 0.0), ('amount_residual_currency', '!=', 0.0), + ] + + payments_widget_vals = move.invoice_outstanding_credits_debits_widget or {'outstanding': True, 'content': [], 'move_id': move.id} + + if move.is_inbound(): + domain.append(('balance', '<', 0.0)) + payments_widget_vals['title'] = _('Outstanding credits') + else: + domain.append(('balance', '>', 0.0)) + payments_widget_vals['title'] = _('Outstanding debits') + + for line in self.env['account.move.line'].search(domain): + if line.currency_id == move.currency_id: + amount = abs(line.amount_residual_currency) + else: + amount = line.company_currency_id._convert(abs(line.amount_residual), move.currency_id, move.company_id, line.date) + if move.currency_id.is_zero(amount): + continue + + # avoid duplicates + if payments_widget_vals['content'] and any(c['id'] == line.id for c in payments_widget_vals['content']): + continue + + payments_widget_vals['content'].append({ + 'journal_name': line.ref or line.move_id.name, + 'amount': amount, + 'currency_id': move.currency_id.id, + 'id': line.id, + 'move_id': line.move_id.id, + 'date': fields.Date.to_string(line.date), + 'account_payment_id': line.payment_id.id, + }) + + if payments_widget_vals['content']: + move.invoice_outstanding_credits_debits_widget = payments_widget_vals + move.invoice_has_outstanding = True diff --git a/models/account_move_line.py b/models/account_move_line.py new file mode 100644 index 0000000..225b5ec --- /dev/null +++ b/models/account_move_line.py @@ -0,0 +1,30 @@ +from odoo import api, models +from odoo.exceptions import UserError +from odoo.tools.translate import _ + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + @api.depends('move_id', 'account_id', 'tax_line_id') + def _compute_display_type(self): + super()._compute_display_type() + for line in self: + if line.display_type == 'product' and line.move_id.is_invoice(): + if line.account_id and line.account_id.account_type == 'liability_current': + line.display_type = 'payment_term' + + @api.constrains('account_id', 'display_type') + def _check_payable_receivable(self): + for line in self: + account_type = line.account_id.account_type + if line.move_id.is_sale_document(include_receipts=True): + if account_type in ('liability_payable', 'liability_current'): + raise UserError(_("Account %s is of payable type, but is used in a sale operation.", line.account_id.code)) + if (line.display_type == 'payment_term') ^ (account_type == 'asset_receivable'): + raise UserError(_("Any journal item on a receivable account must have a due date and vice versa.")) + if line.move_id.is_purchase_document(include_receipts=True): + if account_type == 'asset_receivable': + raise UserError(_("Account %s is of receivable type, but is used in a purchase operation.", line.account_id.code)) + # Patch: Allow 'liability_current' to also act as a payment_term + if (line.display_type == 'payment_term') ^ (account_type in ('liability_payable', 'liability_current')): + raise UserError(_("Any journal item on a payable account must have a due date and vice versa.")) diff --git a/models/account_payment.py b/models/account_payment.py new file mode 100644 index 0000000..32e5b38 --- /dev/null +++ b/models/account_payment.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models + +class AccountPayment(models.Model): + _inherit = 'account.payment' + + destination_account_id = fields.Many2one( + comodel_name='account.account', + string='Destination Account', + store=True, readonly=False, + compute='_compute_destination_account_id', + domain="[('account_type', 'in', ('asset_receivable', 'liability_payable', 'liability_current'))]", + check_company=True + ) + + @api.model + def _get_valid_payment_account_types(self): + res = super()._get_valid_payment_account_types() + if 'liability_current' not in res: + res.append('liability_current') + return res diff --git a/models/res_partner.py b/models/res_partner.py new file mode 100644 index 0000000..ea1fd66 --- /dev/null +++ b/models/res_partner.py @@ -0,0 +1,13 @@ +from odoo import fields, models + +class ResPartner(models.Model): + _inherit = 'res.partner' + + property_account_payable_id = fields.Many2one( + 'account.account', + company_dependent=True, + string="Account Payable", + domain="[('account_type', 'in', ('liability_payable', 'liability_current')), ('deprecated', '=', False)]", + help="This account will be used instead of the default one as the payable account for the current partner. Extended to allow Current Liabilities.", + required=True + ) diff --git a/views/account_move_line_views.xml b/views/account_move_line_views.xml new file mode 100644 index 0000000..5ffbfe8 --- /dev/null +++ b/views/account_move_line_views.xml @@ -0,0 +1,16 @@ + + + + account.move.line.search.liability + account.move.line + + + + ['|', ('account_id.account_type', '=', 'liability_payable'), '&', ('account_id.account_type', '=', 'liability_current'), ('account_id.reconcile', '=', True)] + + + ['|', ('account_id.account_type', '=', 'asset_receivable'), '&', ('account_id.account_type', '=', 'asset_current'), ('account_id.reconcile', '=', True)] + + + +