From 8f1c8cfebb1c63f305602485c362896173d991ed Mon Sep 17 00:00:00 2001 From: Guillaume MASSON Date: Thu, 30 Apr 2026 17:45:27 -1000 Subject: [PATCH] [ADD] hr_expense_tax_distribution : add module --- hr_expense_tax_distribution/README.rst | 238 +++++++ hr_expense_tax_distribution/__init__.py | 5 + hr_expense_tax_distribution/__manifest__.py | 25 + .../models/__init__.py | 6 + .../models/hr_expense.py | 372 +++++++++++ .../models/hr_expense_tax_line.py | 105 +++ hr_expense_tax_distribution/pyproject.toml | 3 + .../readme/CONFIGURE.md | 20 + .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 37 ++ hr_expense_tax_distribution/readme/INSTALL.md | 7 + hr_expense_tax_distribution/readme/ROADMAP.md | 11 + hr_expense_tax_distribution/readme/USAGE.md | 60 ++ .../security/ir.model.access.csv | 3 + .../static/description/icon.png | Bin 0 -> 10254 bytes .../static/description/index.html | 598 ++++++++++++++++++ hr_expense_tax_distribution/tests/__init__.py | 5 + .../tests/test_hr_expense_tax_distribution.py | 463 ++++++++++++++ .../hr_expense_tax_distribution_views.xml | 65 ++ 19 files changed, 2025 insertions(+) create mode 100644 hr_expense_tax_distribution/README.rst create mode 100644 hr_expense_tax_distribution/__init__.py create mode 100644 hr_expense_tax_distribution/__manifest__.py create mode 100644 hr_expense_tax_distribution/models/__init__.py create mode 100644 hr_expense_tax_distribution/models/hr_expense.py create mode 100644 hr_expense_tax_distribution/models/hr_expense_tax_line.py create mode 100644 hr_expense_tax_distribution/pyproject.toml create mode 100644 hr_expense_tax_distribution/readme/CONFIGURE.md create mode 100644 hr_expense_tax_distribution/readme/CONTRIBUTORS.md create mode 100644 hr_expense_tax_distribution/readme/DESCRIPTION.md create mode 100644 hr_expense_tax_distribution/readme/INSTALL.md create mode 100644 hr_expense_tax_distribution/readme/ROADMAP.md create mode 100644 hr_expense_tax_distribution/readme/USAGE.md create mode 100644 hr_expense_tax_distribution/security/ir.model.access.csv create mode 100644 hr_expense_tax_distribution/static/description/icon.png create mode 100644 hr_expense_tax_distribution/static/description/index.html create mode 100644 hr_expense_tax_distribution/tests/__init__.py create mode 100644 hr_expense_tax_distribution/tests/test_hr_expense_tax_distribution.py create mode 100644 hr_expense_tax_distribution/views/hr_expense_tax_distribution_views.xml diff --git a/hr_expense_tax_distribution/README.rst b/hr_expense_tax_distribution/README.rst new file mode 100644 index 000000000..6ecfd34bf --- /dev/null +++ b/hr_expense_tax_distribution/README.rst @@ -0,0 +1,238 @@ +=========================== +HR Expense Tax Distribution +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0476c5fd066cdf175cd41e20c63f841a57c19060386e77869a5fd7fde731aa38 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr--expense-lightgray.png?logo=github + :target: https://github.com/OCA/hr-expense/tree/18.0/hr_expense_tax_distribution + :alt: OCA/hr-expense +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-expense-18-0/hr-expense-18-0-hr_expense_tax_distribution + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/hr-expense&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +In standard Odoo, an expense record accepts a **single set of taxes** +applied to the full total amount. This works well when a receipt is +entirely subject to one VAT rate, but breaks down for mixed receipts +where different line items are taxed at different rates. + +A typical example in France is a **restaurant bill**, where the +applicable VAT rates depend on what was consumed: + +- **5.5 %** on food (solid items) +- **10 %** on non-alcoholic beverages +- **20 %** on alcoholic beverages + +With the standard module, the user is forced to pick a single tax for +the whole amount, which leads to an incorrect tax breakdown in the final +accounting entry and, consequently, to wrong VAT reporting figures. + +This module solves the problem by introducing **tax distribution lines** +on the ``hr.expense`` form. When an expense carries more than one tax, +the user can split the total receipt amount across as many distribution +lines as needed — one per applicable tax rate. Each line holds: + +- the **tax** that applies to that portion of the receipt, +- the **base amount (tax excluded)** entered by the user, +- the **tax amount** and the **total (tax included)** computed + automatically. + +A validation constraint ensures that the sum of the distribution line +totals equals the expense total before the expense report can be +submitted. + +When the expense report is posted, the accounting entry is built from +the distribution lines instead of the single ``tax_ids`` / total pair, +producing a correct and auditable VAT breakdown for each applicable +rate. + +The module is fully **backward compatible**: expenses with a single tax +or no distribution lines behave exactly as in standard Odoo. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module depends on the standard ``hr_expense`` module. No +third-party dependency is required. + +Install the module in the usual way (Apps menu or +``-i hr_expense_tax_distribution`` on the command line). The *Tax +Distribution* table appears automatically on the expense form as soon as +the module is installed — no migration of existing data is needed. + +Configuration +============= + +No specific configuration is required after installation. + +The *Tax Distribution* table is displayed automatically on the expense +form whenever distribution lines exist on the record. Distribution lines +are generated automatically when more than one tax is added to the +*Taxes* field (via the ``tax_ids`` onchange), but they can also be +created or modified manually. + +**Purchase taxes** + +Only taxes with *Tax Scope* set to ``Purchase`` or ``All`` are available +in the distribution line *Tax* field. Make sure your VAT rates are +configured accordingly (Accounting > Configuration > Taxes). + +**Company currency** + +All amounts in the distribution lines are expressed in the **expense +currency** (``currency_id``), consistent with the standard *Total* field +on the expense. Multi-currency conversion is handled by the existing +Odoo expense mechanism and is not affected by this module. + +Usage +===== + +**Typical workflow — restaurant receipt (France)** + +Consider a receipt for 86.75 € (tax included) with three different VAT +rates: + +======================= ========= ===== ======== =========== +Item Base (HT) Rate Tax Total (TTC) +======================= ========= ===== ======== =========== +Food 50.00 EUR 5.5 % 2.75 EUR 52.75 EUR +Non-alcoholic beverages 20.00 EUR 10 % 2.00 EUR 22.00 EUR +Alcoholic beverages 10.00 EUR 20 % 2.00 EUR 12.00 EUR +**Total** 80.00 EUR 6.75 EUR 86.75 EUR +======================= ========= ===== ======== =========== + +**Step-by-step** + +1. Go to **Expenses > My Expenses** and create a new expense (or open an + existing draft one). + +2. Fill in the standard fields: *Product*, *Total* (``86.75``), + *Employee*, *Date*, etc. + +3. In the **Taxes** field, add **all** the VAT rates that appear on the + receipt (e.g. *TVA 5.5%*, *TVA 10%*, *TVA 20%*). + + As soon as more than one tax is present, the **Tax Distribution** + table appears automatically below the *Taxes* field, with one + pre-created line per tax. + +4. For each line in the *Tax Distribution* table, enter the **Base + Amount (Tax Excl.)** — the portion of the receipt that is subject to + that particular rate. + + The *Tax Amount* and *Total (Tax Incl.)* columns are computed and + updated instantly. + +5. Verify that the sum of the *Total (Tax Incl.)* column equals the + expense *Total* field. Odoo will block submission if there is a + discrepancy. + +6. Submit and approve the expense report as usual. + +**Result in the accounting entry** + +Instead of a single expense line with a blended tax, the generated +journal entry contains **one base line and one tax line per distribution +entry**, providing an accurate and auditable VAT breakdown that feeds +correctly into the tax declaration (e.g. French CA3 return). + +**Single-tax expenses** + +If the expense has only one tax in the *Taxes* field, the *Tax +Distribution* table is not shown and the standard Odoo behaviour applies +unchanged. You can still manually add a distribution line if needed (for +instance, to split a single-rate receipt across two accounts), but this +is an edge case. + +**Manual adjustments** + +Distribution lines can be added, removed, or edited manually at any time +while the expense is in draft or submitted state (subject to the usual +edit permissions). Use the *handle* icon to reorder them. + +Known issues / Roadmap +====================== + +- **Analytic distribution per line** — the current implementation copies + the ``analytic_distribution`` from the parent expense to every + distribution line. A future improvement could allow setting a distinct + analytic distribution on each tax distribution line. +- **Automatic total update** — when the user adjusts the distribution + lines, the expense *Total* field is not automatically recalculated. + The user must ensure consistency manually. A future improvement could + offer a *Recompute Total* helper button. +- **Import / OCR integration** — receipts parsed by the Odoo AI/OCR + feature do not currently populate distribution lines. Integration with + the attachment extraction pipeline is a possible future improvement. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- `Akretion `__: + + - Guillaume Masson + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-metaminux| image:: https://github.com/metaminux.png?size=40px + :target: https://github.com/metaminux + :alt: metaminux + +Current `maintainer `__: + +|maintainer-metaminux| + +This module is part of the `OCA/hr-expense `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_expense_tax_distribution/__init__.py b/hr_expense_tax_distribution/__init__.py new file mode 100644 index 000000000..e271a29bd --- /dev/null +++ b/hr_expense_tax_distribution/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/hr_expense_tax_distribution/__manifest__.py b/hr_expense_tax_distribution/__manifest__.py new file mode 100644 index 000000000..1fec91ef3 --- /dev/null +++ b/hr_expense_tax_distribution/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "HR Expense Tax Distribution", + "summary": ( + "Allows to distribute a single expense amount across multiple tax rates " + "by defining per-tax base amounts. Produces correct tax lines in the " + "accounting entry when multiple VAT rates apply to the same receipt." + ), + "version": "18.0.1.0.0", + "category": "Human Resources/Expenses", + "website": "https://github.com/OCA/hr-expense", + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["hr_expense"], + "data": [ + "security/ir.model.access.csv", + "views/hr_expense_tax_distribution_views.xml", + ], + "installable": True, + "development_status": "Beta", + "maintainers": ["metaminux"], +} diff --git a/hr_expense_tax_distribution/models/__init__.py b/hr_expense_tax_distribution/models/__init__.py new file mode 100644 index 000000000..b0b6fab86 --- /dev/null +++ b/hr_expense_tax_distribution/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import hr_expense +from . import hr_expense_tax_line diff --git a/hr_expense_tax_distribution/models/hr_expense.py b/hr_expense_tax_distribution/models/hr_expense.py new file mode 100644 index 000000000..45389e83c --- /dev/null +++ b/hr_expense_tax_distribution/models/hr_expense.py @@ -0,0 +1,372 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, _, api, fields, models +from odoo.exceptions import ValidationError + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + tax_line_ids = fields.One2many( + comodel_name="hr.expense.tax.line", + inverse_name="expense_id", + string="Tax Distribution Lines", + copy=True, + ) + has_tax_distribution = fields.Boolean( + compute="_compute_has_tax_distribution", + store=True, + ) + # Disable precompute on these three fields: our _compute overrides depend + # on hr.expense.tax.line.tax_amount_currency which is not precomputable + # itself (it lives on a new model). Keeping precompute=True would trigger + # an Odoo UserWarning at startup and silently break the precompute chain. + tax_amount_currency = fields.Monetary(precompute=False) + untaxed_amount_currency = fields.Monetary(precompute=False) + tax_amount = fields.Monetary(precompute=False) + + # ------------------------------------------------------------------------- + # Compute + # ------------------------------------------------------------------------- + + @api.depends("tax_line_ids", "tax_line_ids.base_amount_currency") + def _compute_has_tax_distribution(self): + for expense in self: + expense.has_tax_distribution = bool(expense.tax_line_ids) + + # ------------------------------------------------------------------------- + # Onchange + # ------------------------------------------------------------------------- + + @api.onchange("tax_ids") + def _onchange_tax_ids_generate_distribution_lines(self): + """Maintain tax distribution lines in sync with tax_ids. + + Rules: + - If tax_ids has 0 or 1 tax: clear all distribution lines (single-tax + expenses use the standard Odoo flow; no distribution needed). + - If tax_ids has 2+ taxes: one line per tax is maintained. + Existing lines whose tax is still present are preserved (base amounts + are kept). Lines for removed taxes are deleted. Lines for newly + added taxes are created with base_amount_currency = 0. + """ + if len(self.tax_ids) <= 1: + self.tax_line_ids = [Command.clear()] + return + self.tax_line_ids = self._sync_tax_distribution_lines(self.tax_ids) + + def _sync_tax_distribution_lines(self, taxes): + """Return a list of ORM Commands to sync tax distribution lines with + the given ``taxes`` recordset. + + Lines covering a single tax still present in ``taxes`` are preserved + via Command.link() so their base_amount_currency is kept. Lines for + taxes that have been removed are dropped. A new Command.create() is + added for each tax not yet covered by a single-tax line. + + The caller is responsible for assigning the result to ``tax_line_ids`` + (onchange) or passing it to write() / create() (tests, other callers). + This design allows downstream modules to override this method and add + extra fields (e.g. an account_id) to the created lines. + + Uses self._origin.tax_line_ids so that ids are always real DB integers, + even when called from inside an onchange where self is a virtual record. + On a new (unsaved) expense, _origin.tax_line_ids is an empty recordset, + which is the correct starting point. + """ + self.ensure_one() + # _origin gives us real DB records (ids are plain ints). + # On a new expense, _origin.tax_line_ids is empty — that is correct. + existing_by_tax_id = { + line.tax_ids.ids[0]: line + for line in self.tax_line_ids + if len(line.tax_ids) == 1 + } + current_tax_ids = set(taxes.ids) + + commands = [] + covered = set() + + # Keep existing single-tax lines whose tax is still selected. + for tax_id, line in existing_by_tax_id.items(): + if tax_id in current_tax_ids: + commands.append(Command.link(line.id)) + else: + commands.append(Command.delete(line.id)) + covered.add(tax_id) + + # Create new lines for taxes not yet covered. + for tax in taxes._origin.filtered(lambda t: t.id not in covered): + commands.append( + Command.create( + { + "tax_ids": [Command.set(tax.ids)], + "base_amount_currency": 0.0, + } + ) + ) + + return commands + + @api.depends( + "total_amount_currency", + "tax_ids", + "tax_line_ids.tax_amount_currency", + "tax_line_ids.total_amount_currency", + ) + def _compute_tax_amount_currency(self): + """When distribution lines exist *and* have non-zero amounts, derive + tax_amount_currency and untaxed_amount_currency from their sum. + Falls back to super() otherwise (standard Odoo behavior).""" + dist_expenses = self.filtered( + lambda he: he.has_tax_distribution + and any(dl.base_amount_currency for dl in he.tax_line_ids) + ) + for expense in dist_expenses: + tax_sum = sum(expense.tax_line_ids.mapped("tax_amount_currency")) + expense.tax_amount_currency = tax_sum + expense.untaxed_amount_currency = expense.total_amount_currency - tax_sum + return super(HrExpense, self - dist_expenses)._compute_tax_amount_currency() + + @api.depends( + "total_amount", + "currency_rate", + "tax_ids", + "is_multiple_currency", + "tax_line_ids.tax_amount_currency", + ) + def _compute_tax_amount(self): + """When distribution lines exist *and* have non-zero amounts, derive + tax_amount (company currency) from the distribution line sums.""" + dist_expenses = self.filtered( + lambda he: he.has_tax_distribution + and any(dl.base_amount_currency for dl in he.tax_line_ids) + ) + for expense in dist_expenses: + if expense.is_multiple_currency: + tax_sum_currency = sum( + expense.tax_line_ids.mapped("tax_amount_currency") + ) + expense.tax_amount = expense.currency_id._convert( + tax_sum_currency, + expense.company_currency_id, + expense.company_id, + expense.date or fields.Date.context_today(expense), + ) + else: + expense.tax_amount = sum( + expense.tax_line_ids.mapped("tax_amount_currency") + ) + return super(HrExpense, self - dist_expenses)._compute_tax_amount() + + def _check_tax_distribution_total(self): + """Validate that distribution line totals match the expense total. + + Called explicitly from action_submit_expenses instead of via + @api.constrains, to avoid false positives during onchange when lines + are being built incrementally (some lines may still be at zero). + """ + for expense in self: + if not expense.tax_line_ids: + continue + if not expense.total_amount_currency: + continue + if any( + expense.currency_id.is_zero(tl.base_amount_currency) + for tl in expense.tax_line_ids + ): + raise ValidationError( + _( + 'Expense "%(name)s" has tax distribution lines with a ' + "zero base amount. Please fill in all base amounts before " + "submitting.", + name=expense.name, + ) + ) + distributed_total = sum( + expense.tax_line_ids.mapped("total_amount_currency") + ) + diff = abs(distributed_total - expense.total_amount_currency) + # Use the currency rounding to allow for floating-point drift + if not expense.currency_id.is_zero(diff): + raise ValidationError( + _( + "The sum of tax distribution line totals (%(distributed)s) " + "does not match the expense total amount (%(total)s) on " + 'expense "%(name)s". ' + "Please adjust the base amounts so that the totals match.", + distributed=expense.currency_id.format(distributed_total), + total=expense.currency_id.format(expense.total_amount_currency), + name=expense.name, + ) + ) + + def action_submit_expenses(self): + """Validate tax distribution totals before submission.""" + self._check_tax_distribution_total() + return super().action_submit_expenses() + + # ------------------------------------------------------------------------- + # Accounting entry generation + # ------------------------------------------------------------------------- + + def _get_tax_distribution_move_lines_vals(self): + """Build account.move.line value dicts for one expense when tax + distribution lines are defined. + + Reusable by both the 'own_account' (vendor bill) and 'company_account' + (direct payment) flows. The caller is responsible for appending the + balancing destination line. + + ``price_unit`` is set to ``total_amount_currency / quantity`` (TTC) so + that Odoo's invoice recompute extracts the base amount and the tax + amount correctly, mirroring the behaviour of the standard + ``_prepare_move_lines_vals`` which passes ``self.price_unit`` (also TTC + for expenses). ``quantity`` is taken from the parent expense when the + product has a cost (``product_has_cost`` is True), falling back to 1.0 + otherwise. + """ + self.ensure_one() + move_lines = [] + account_src = self._get_base_account() + partner_id = ( + False + if self.payment_mode == "company_account" + else self.employee_id.sudo().work_contact_id.id + ) + quantity = self.quantity if self.product_has_cost and self.quantity else 1.0 + + for dist_line in self.tax_line_ids: + price_unit = dist_line.total_amount_currency / quantity if quantity else 0.0 + move_lines.append( + { + "name": self._get_move_line_name(), + "account_id": account_src.id, + "product_id": self.product_id.id, + "product_uom_id": self.product_uom_id.id, + "analytic_distribution": self.analytic_distribution, + "expense_id": self.id, + "tax_ids": [Command.set(dist_line.tax_ids.ids)], + "price_unit": price_unit, + "quantity": quantity, + "currency_id": self.currency_id.id, + "partner_id": partner_id, + } + ) + + return move_lines + + def _prepare_payments_vals(self): + """Override for the 'company_account' flow.""" + if not self.has_tax_distribution: + return super()._prepare_payments_vals() + + self.ensure_one() + journal = self.sheet_id.journal_id + payment_method_line = self.sheet_id.payment_method_line_id + if not payment_method_line: + raise ValidationError( + _( + "You need to add a manual payment method on the journal (%s)", + journal.name, + ) + ) + + move_lines = self._get_tax_distribution_move_lines_vals() + move_lines.append( + { + "name": self._get_move_line_name(), + "account_id": self.sheet_id._get_expense_account_destination(), + "balance": -self.total_amount, + "amount_currency": self.currency_id.round(-self.total_amount_currency), + "currency_id": self.currency_id.id, + "partner_id": self.vendor_id.id, + } + ) + + payment_vals = { + "date": self.date, + "memo": self.name, + "journal_id": journal.id, + "amount": self.total_amount_currency, + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_id.id, + "currency_id": self.currency_id.id, + "payment_method_line_id": payment_method_line.id, + "company_id": self.company_id.id, + } + move_vals = { + **self.sheet_id._prepare_move_vals(), + "ref": self.name, + "date": self.date, + "journal_id": journal.id, + "partner_id": self.vendor_id.id, + "currency_id": self.currency_id.id, + "company_id": self.company_id.id, + "line_ids": [Command.create(line) for line in move_lines], + "attachment_ids": [ + Command.create( + attachment.copy_data( + { + "res_model": "account.move", + "res_id": False, + "raw": attachment.raw, + } + )[0] + ) + for attachment in self.attachment_ids + ], + } + return move_vals, payment_vals + + +class HrExpenseSheet(models.Model): + _inherit = "hr.expense.sheet" + + def _prepare_bills_vals(self): + """Override for the 'own_account' flow.""" + if not any(exp.has_tax_distribution for exp in self.expense_line_ids): + return super()._prepare_bills_vals() + + move_vals = self._prepare_move_vals() + if self.employee_id.sudo().bank_account_id: + move_vals["partner_bank_id"] = self.employee_id.sudo().bank_account_id.id + + all_line_vals = [] + for expense in self.expense_line_ids: + if expense.has_tax_distribution: + # Multi-line build from distribution lines + exp_lines = expense._get_tax_distribution_move_lines_vals() + all_line_vals.extend(exp_lines) + else: + # Standard single-line build + all_line_vals.append(expense._prepare_move_lines_vals()) + + attachment_ids = [ + Command.create( + attachment.copy_data( + { + "res_model": "account.move", + "res_id": False, + "raw": attachment.raw, + } + )[0] + ) + for attachment in self.expense_line_ids.attachment_ids + ] + + return { + **move_vals, + "journal_id": self.journal_id.id, + "ref": self.name, + "move_type": "in_invoice", + "partner_id": self.employee_id.sudo().work_contact_id.id, + "commercial_partner_id": self.employee_id.user_partner_id.id, + "currency_id": self.currency_id.id, + "company_id": self.company_id.id, + "line_ids": [Command.create(line) for line in all_line_vals], + "attachment_ids": attachment_ids, + } diff --git a/hr_expense_tax_distribution/models/hr_expense_tax_line.py b/hr_expense_tax_distribution/models/hr_expense_tax_line.py new file mode 100644 index 000000000..9a7b8c9f2 --- /dev/null +++ b/hr_expense_tax_distribution/models/hr_expense_tax_line.py @@ -0,0 +1,105 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class HrExpenseTaxLine(models.Model): + """Distribution line linking one or more tax rates to a base (untaxed) amount. + + When a receipt contains goods subject to different VAT rates (e.g. a + restaurant bill with 5.5 %, 10 % and 20 % items), this model lets the user + split the expense total across as many distribution lines as needed. + + Each line carries one or more taxes (``tax_ids``) and a base amount + (``base_amount_currency``). Tax and total amounts are computed + automatically. The accounting entry is then generated from these lines + instead of from the single ``tax_ids`` / ``total_amount`` pair on the parent + expense. + """ + + _name = "hr.expense.tax.line" + _description = "Expense Tax Distribution Line" + _order = "sequence, id" + + expense_id = fields.Many2one( + comodel_name="hr.expense", + string="Expense", + required=True, + ondelete="cascade", + index=True, + ) + sequence = fields.Integer(default=10) + currency_id = fields.Many2one( + related="expense_id.currency_id", + store=True, + ) + company_id = fields.Many2one( + related="expense_id.company_id", + store=True, + ) + # Many2many: allows combining taxes on one line (e.g. VAT + eco-tax). + # By default the onchange creates one line per tax in hr.expense.tax_ids; + # the user can merge lines manually by adding taxes to an existing line. + tax_ids = fields.Many2many( + comodel_name="account.tax", + string="Taxes", + domain="[('company_id', '=', company_id), " + "('type_tax_use', 'in', ['purchase', 'all'])]", + check_company=True, + ) + # Base amount in the expense currency (HT / tax-excluded) + base_amount_currency = fields.Monetary( + string="Base Amount (Tax Excl.)", + currency_field="currency_id", + required=True, + ) + # Read-only computed fields ----------------------------------------------- + tax_amount_currency = fields.Monetary( + string="Tax Amount", + currency_field="currency_id", + compute="_compute_amounts", + store=True, + ) + total_amount_currency = fields.Monetary( + string="Total (Tax Incl.)", + currency_field="currency_id", + compute="_compute_amounts", + store=True, + ) + + # ------------------------------------------------------------------------- + # Compute + # ------------------------------------------------------------------------- + + @api.depends( + "tax_ids", "base_amount_currency", "currency_id", "expense_id.employee_id" + ) + def _compute_amounts(self): + for line in self: + if not line.tax_ids or not line.base_amount_currency: + line.tax_amount_currency = 0.0 + line.total_amount_currency = line.base_amount_currency + continue + partner = line.expense_id.employee_id.sudo().work_contact_id + taxes = line.tax_ids.compute_all( + line.base_amount_currency, + currency=line.currency_id, + partner=partner, + ) + line.tax_amount_currency = taxes["total_included"] - taxes["total_excluded"] + line.total_amount_currency = taxes["total_included"] + + # ------------------------------------------------------------------------- + # Constraints + # ------------------------------------------------------------------------- + + @api.constrains("base_amount_currency") + def _check_base_amount_positive(self): + for line in self: + if line.base_amount_currency < 0: + raise ValidationError( + _("The base amount on a tax distribution line cannot be negative.") + ) diff --git a/hr_expense_tax_distribution/pyproject.toml b/hr_expense_tax_distribution/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/hr_expense_tax_distribution/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_expense_tax_distribution/readme/CONFIGURE.md b/hr_expense_tax_distribution/readme/CONFIGURE.md new file mode 100644 index 000000000..da1bf1bd5 --- /dev/null +++ b/hr_expense_tax_distribution/readme/CONFIGURE.md @@ -0,0 +1,20 @@ +No specific configuration is required after installation. + +The *Tax Distribution* table is displayed automatically on the expense +form whenever distribution lines exist on the record. Distribution lines +are generated automatically when more than one tax is added to the +*Taxes* field (via the `tax_ids` onchange), but they can also be created +or modified manually. + +**Purchase taxes** + +Only taxes with *Tax Scope* set to `Purchase` or `All` are available in +the distribution line *Tax* field. Make sure your VAT rates are +configured accordingly (Accounting \> Configuration \> Taxes). + +**Company currency** + +All amounts in the distribution lines are expressed in the **expense +currency** (`currency_id`), consistent with the standard *Total* field +on the expense. Multi-currency conversion is handled by the existing +Odoo expense mechanism and is not affected by this module. diff --git a/hr_expense_tax_distribution/readme/CONTRIBUTORS.md b/hr_expense_tax_distribution/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..ac26a2a03 --- /dev/null +++ b/hr_expense_tax_distribution/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Akretion](https://www.akretion.com/): + - Guillaume Masson \<\> diff --git a/hr_expense_tax_distribution/readme/DESCRIPTION.md b/hr_expense_tax_distribution/readme/DESCRIPTION.md new file mode 100644 index 000000000..7331bf2fa --- /dev/null +++ b/hr_expense_tax_distribution/readme/DESCRIPTION.md @@ -0,0 +1,37 @@ +In standard Odoo, an expense record accepts a **single set of taxes** +applied to the full total amount. This works well when a receipt is +entirely subject to one VAT rate, but breaks down for mixed receipts +where different line items are taxed at different rates. + +A typical example in France is a **restaurant bill**, where the +applicable VAT rates depend on what was consumed: + +- **5.5 %** on food (solid items) +- **10 %** on non-alcoholic beverages +- **20 %** on alcoholic beverages + +With the standard module, the user is forced to pick a single tax for +the whole amount, which leads to an incorrect tax breakdown in the final +accounting entry and, consequently, to wrong VAT reporting figures. + +This module solves the problem by introducing **tax distribution lines** +on the `hr.expense` form. When an expense carries more than one tax, the +user can split the total receipt amount across as many distribution +lines as needed — one per applicable tax rate. Each line holds: + +- the **tax** that applies to that portion of the receipt, +- the **base amount (tax excluded)** entered by the user, +- the **tax amount** and the **total (tax included)** computed + automatically. + +A validation constraint ensures that the sum of the distribution line +totals equals the expense total before the expense report can be +submitted. + +When the expense report is posted, the accounting entry is built from +the distribution lines instead of the single `tax_ids` / total pair, +producing a correct and auditable VAT breakdown for each applicable +rate. + +The module is fully **backward compatible**: expenses with a single tax +or no distribution lines behave exactly as in standard Odoo. diff --git a/hr_expense_tax_distribution/readme/INSTALL.md b/hr_expense_tax_distribution/readme/INSTALL.md new file mode 100644 index 000000000..f36f51efd --- /dev/null +++ b/hr_expense_tax_distribution/readme/INSTALL.md @@ -0,0 +1,7 @@ +This module depends on the standard `hr_expense` module. No third-party +dependency is required. + +Install the module in the usual way (Apps menu or +`-i hr_expense_tax_distribution` on the command line). The *Tax +Distribution* table appears automatically on the expense form as soon as +the module is installed — no migration of existing data is needed. diff --git a/hr_expense_tax_distribution/readme/ROADMAP.md b/hr_expense_tax_distribution/readme/ROADMAP.md new file mode 100644 index 000000000..474ed06db --- /dev/null +++ b/hr_expense_tax_distribution/readme/ROADMAP.md @@ -0,0 +1,11 @@ +- **Analytic distribution per line** — the current implementation copies + the `analytic_distribution` from the parent expense to every + distribution line. A future improvement could allow setting a distinct + analytic distribution on each tax distribution line. +- **Automatic total update** — when the user adjusts the distribution + lines, the expense *Total* field is not automatically recalculated. + The user must ensure consistency manually. A future improvement could + offer a *Recompute Total* helper button. +- **Import / OCR integration** — receipts parsed by the Odoo AI/OCR + feature do not currently populate distribution lines. Integration with + the attachment extraction pipeline is a possible future improvement. diff --git a/hr_expense_tax_distribution/readme/USAGE.md b/hr_expense_tax_distribution/readme/USAGE.md new file mode 100644 index 000000000..763fe02e0 --- /dev/null +++ b/hr_expense_tax_distribution/readme/USAGE.md @@ -0,0 +1,60 @@ +**Typical workflow — restaurant receipt (France)** + +Consider a receipt for 86.75 € (tax included) with three different VAT +rates: + +| Item | Base (HT) | Rate | Tax | Total (TTC) | +|-------------------------|-----------|-------|----------|-------------| +| Food | 50.00 EUR | 5.5 % | 2.75 EUR | 52.75 EUR | +| Non-alcoholic beverages | 20.00 EUR | 10 % | 2.00 EUR | 22.00 EUR | +| Alcoholic beverages | 10.00 EUR | 20 % | 2.00 EUR | 12.00 EUR | +| **Total** | 80.00 EUR | | 6.75 EUR | 86.75 EUR | + +**Step-by-step** + +1. Go to **Expenses \> My Expenses** and create a new expense (or open + an existing draft one). + +2. Fill in the standard fields: *Product*, *Total* (`86.75`), + *Employee*, *Date*, etc. + +3. In the **Taxes** field, add **all** the VAT rates that appear on the + receipt (e.g. *TVA 5.5%*, *TVA 10%*, *TVA 20%*). + + As soon as more than one tax is present, the **Tax Distribution** + table appears automatically below the *Taxes* field, with one + pre-created line per tax. + +4. For each line in the *Tax Distribution* table, enter the **Base + Amount (Tax Excl.)** — the portion of the receipt that is subject to + that particular rate. + + The *Tax Amount* and *Total (Tax Incl.)* columns are computed and + updated instantly. + +5. Verify that the sum of the *Total (Tax Incl.)* column equals the + expense *Total* field. Odoo will block submission if there is a + discrepancy. + +6. Submit and approve the expense report as usual. + +**Result in the accounting entry** + +Instead of a single expense line with a blended tax, the generated +journal entry contains **one base line and one tax line per distribution +entry**, providing an accurate and auditable VAT breakdown that feeds +correctly into the tax declaration (e.g. French CA3 return). + +**Single-tax expenses** + +If the expense has only one tax in the *Taxes* field, the *Tax +Distribution* table is not shown and the standard Odoo behaviour applies +unchanged. You can still manually add a distribution line if needed (for +instance, to split a single-rate receipt across two accounts), but this +is an edge case. + +**Manual adjustments** + +Distribution lines can be added, removed, or edited manually at any time +while the expense is in draft or submitted state (subject to the usual +edit permissions). Use the *handle* icon to reorder them. diff --git a/hr_expense_tax_distribution/security/ir.model.access.csv b/hr_expense_tax_distribution/security/ir.model.access.csv new file mode 100644 index 000000000..fc519f4e5 --- /dev/null +++ b/hr_expense_tax_distribution/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_hr_expense_tax_line_employee,hr.expense.tax.line employee,model_hr_expense_tax_line,hr_expense.group_hr_expense_user,1,1,1,1 +access_hr_expense_tax_line_manager,hr.expense.tax.line manager,model_hr_expense_tax_line,hr_expense.group_hr_expense_team_approver,1,1,1,1 diff --git a/hr_expense_tax_distribution/static/description/icon.png b/hr_expense_tax_distribution/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +HR Expense Tax Distribution + + + +
+

HR Expense Tax Distribution

+ + +

Beta License: AGPL-3 OCA/hr-expense Translate me on Weblate Try me on Runboat

+

In standard Odoo, an expense record accepts a single set of taxes +applied to the full total amount. This works well when a receipt is +entirely subject to one VAT rate, but breaks down for mixed receipts +where different line items are taxed at different rates.

+

A typical example in France is a restaurant bill, where the +applicable VAT rates depend on what was consumed:

+
    +
  • 5.5 % on food (solid items)
  • +
  • 10 % on non-alcoholic beverages
  • +
  • 20 % on alcoholic beverages
  • +
+

With the standard module, the user is forced to pick a single tax for +the whole amount, which leads to an incorrect tax breakdown in the final +accounting entry and, consequently, to wrong VAT reporting figures.

+

This module solves the problem by introducing tax distribution lines +on the hr.expense form. When an expense carries more than one tax, +the user can split the total receipt amount across as many distribution +lines as needed — one per applicable tax rate. Each line holds:

+
    +
  • the tax that applies to that portion of the receipt,
  • +
  • the base amount (tax excluded) entered by the user,
  • +
  • the tax amount and the total (tax included) computed +automatically.
  • +
+

A validation constraint ensures that the sum of the distribution line +totals equals the expense total before the expense report can be +submitted.

+

When the expense report is posted, the accounting entry is built from +the distribution lines instead of the single tax_ids / total pair, +producing a correct and auditable VAT breakdown for each applicable +rate.

+

The module is fully backward compatible: expenses with a single tax +or no distribution lines behave exactly as in standard Odoo.

+

Table of contents

+ +
+

Installation

+

This module depends on the standard hr_expense module. No +third-party dependency is required.

+

Install the module in the usual way (Apps menu or +-i hr_expense_tax_distribution on the command line). The Tax +Distribution table appears automatically on the expense form as soon as +the module is installed — no migration of existing data is needed.

+
+
+

Configuration

+

No specific configuration is required after installation.

+

The Tax Distribution table is displayed automatically on the expense +form whenever distribution lines exist on the record. Distribution lines +are generated automatically when more than one tax is added to the +Taxes field (via the tax_ids onchange), but they can also be +created or modified manually.

+

Purchase taxes

+

Only taxes with Tax Scope set to Purchase or All are available +in the distribution line Tax field. Make sure your VAT rates are +configured accordingly (Accounting > Configuration > Taxes).

+

Company currency

+

All amounts in the distribution lines are expressed in the expense +currency (currency_id), consistent with the standard Total field +on the expense. Multi-currency conversion is handled by the existing +Odoo expense mechanism and is not affected by this module.

+
+
+

Usage

+

Typical workflow — restaurant receipt (France)

+

Consider a receipt for 86.75 € (tax included) with three different VAT +rates:

+ +++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemBase (HT)RateTaxTotal (TTC)
Food50.00 EUR5.5 %2.75 EUR52.75 EUR
Non-alcoholic beverages20.00 EUR10 %2.00 EUR22.00 EUR
Alcoholic beverages10.00 EUR20 %2.00 EUR12.00 EUR
Total80.00 EUR 6.75 EUR86.75 EUR
+

Step-by-step

+
    +
  1. Go to Expenses > My Expenses and create a new expense (or open an +existing draft one).

    +
  2. +
  3. Fill in the standard fields: Product, Total (86.75), +Employee, Date, etc.

    +
  4. +
  5. In the Taxes field, add all the VAT rates that appear on the +receipt (e.g. TVA 5.5%, TVA 10%, TVA 20%).

    +

    As soon as more than one tax is present, the Tax Distribution +table appears automatically below the Taxes field, with one +pre-created line per tax.

    +
  6. +
  7. For each line in the Tax Distribution table, enter the Base +Amount (Tax Excl.) — the portion of the receipt that is subject to +that particular rate.

    +

    The Tax Amount and Total (Tax Incl.) columns are computed and +updated instantly.

    +
  8. +
  9. Verify that the sum of the Total (Tax Incl.) column equals the +expense Total field. Odoo will block submission if there is a +discrepancy.

    +
  10. +
  11. Submit and approve the expense report as usual.

    +
  12. +
+

Result in the accounting entry

+

Instead of a single expense line with a blended tax, the generated +journal entry contains one base line and one tax line per distribution +entry, providing an accurate and auditable VAT breakdown that feeds +correctly into the tax declaration (e.g. French CA3 return).

+

Single-tax expenses

+

If the expense has only one tax in the Taxes field, the Tax +Distribution table is not shown and the standard Odoo behaviour applies +unchanged. You can still manually add a distribution line if needed (for +instance, to split a single-rate receipt across two accounts), but this +is an edge case.

+

Manual adjustments

+

Distribution lines can be added, removed, or edited manually at any time +while the expense is in draft or submitted state (subject to the usual +edit permissions). Use the handle icon to reorder them.

+
+
+

Known issues / Roadmap

+
    +
  • Analytic distribution per line — the current implementation copies +the analytic_distribution from the parent expense to every +distribution line. A future improvement could allow setting a distinct +analytic distribution on each tax distribution line.
  • +
  • Automatic total update — when the user adjusts the distribution +lines, the expense Total field is not automatically recalculated. +The user must ensure consistency manually. A future improvement could +offer a Recompute Total helper button.
  • +
  • Import / OCR integration — receipts parsed by the Odoo AI/OCR +feature do not currently populate distribution lines. Integration with +the attachment extraction pipeline is a possible future improvement.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

metaminux

+

This module is part of the OCA/hr-expense project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/hr_expense_tax_distribution/tests/__init__.py b/hr_expense_tax_distribution/tests/__init__.py new file mode 100644 index 000000000..1200fba74 --- /dev/null +++ b/hr_expense_tax_distribution/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_hr_expense_tax_distribution diff --git a/hr_expense_tax_distribution/tests/test_hr_expense_tax_distribution.py b/hr_expense_tax_distribution/tests/test_hr_expense_tax_distribution.py new file mode 100644 index 000000000..a52e96263 --- /dev/null +++ b/hr_expense_tax_distribution/tests/test_hr_expense_tax_distribution.py @@ -0,0 +1,463 @@ +# Copyright 2026 Akretion +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import Form, tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestHrExpenseTaxDistribution(TransactionCase): + """Tests for hr_expense_tax_distribution. + + Main scenario: a restaurant receipt (86.75 EUR TTC) with three VAT rates: + - Food: base 50.00, tax 5.5% → tax 2.75, total 52.75 + - Soft drinks: base 20.00, tax 10% → tax 2.00, total 22.00 + - Alcoholic drinks: base 10.00, tax 20% → tax 2.00, total 12.00 + Total TTC: 86.75 + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.company = cls.env.company + cls.currency = cls.company.currency_id + + # Taxes (purchase, percent, price-excluded) + def _make_tax(name, amount): + return cls.env["account.tax"].create( + { + "name": name, + "type_tax_use": "purchase", + "amount_type": "percent", + "amount": amount, + "price_include": False, + "company_id": cls.company.id, + } + ) + + cls.tax_5 = _make_tax("TVA 5.5%", 5.5) + cls.tax_10 = _make_tax("TVA 10%", 10.0) + cls.tax_20 = _make_tax("TVA 20%", 20.0) + cls.tax_eco = _make_tax("Eco-tax 2%", 2.0) + + # Employee + cls.employee = cls.env["hr.employee"].create({"name": "Test Employee"}) + + # Product (generic expense) + cls.product = cls.env["product.product"].search( + [("can_be_expensed", "=", True)], limit=1 + ) + if not cls.product: + cls.product = cls.env["product.product"].create( + {"name": "Generic Expense", "can_be_expensed": True} + ) + cls.product.supplier_taxes_id = False + + # Product with fixed cost (product_has_cost = True) for quantity tests + cls.product_with_cost = cls.env["product.product"].create( + { + "name": "Expense With Cost", + "can_be_expensed": True, + "standard_price": 10.0, + } + ) + + def _make_expense( + self, total_amount_currency, tax_ids=None, product=None, quantity=1.0 + ): + vals = { + "name": "Restaurant Test", + "employee_id": self.employee.id, + "product_id": (product or self.product).id, + "total_amount_currency": total_amount_currency, + "quantity": quantity, + "company_id": self.company.id, + } + if tax_ids is not None: + vals["tax_ids"] = [Command.set(tax_ids.ids)] + return self.env["hr.expense"].create(vals) + + def _make_dist_lines(self, expense, specs): + """Helper: create distribution lines by calling _sync_tax_distribution_lines + with an explicit base amount per tax. + + ``specs`` is a list of (tax_recordset, base_amount) tuples. + This helper calls _sync_tax_distribution_lines to create the initial + lines (zero base), then sets the base amounts individually, keeping + the helper in sync with the real creation code path. + """ + taxes = self.env["account.tax"] + base_by_tax = {} + for tax_rs, base in specs: + taxes |= tax_rs + base_by_tax[tax_rs.id] = base + + commands = expense._sync_tax_distribution_lines(taxes) + expense.write({"tax_line_ids": commands}) + + # Set base amounts after creation (lines start at 0.0) + for line in expense.tax_line_ids: + if len(line.tax_ids) == 1 and line.tax_ids.id in base_by_tax: + line.base_amount_currency = base_by_tax[line.tax_ids.id] + + return expense.tax_line_ids + + # ------------------------------------------------------------------ + # 1. Distribution line computation + # ------------------------------------------------------------------ + + def test_single_tax_compute_amounts(self): + """Tax and total amounts computed correctly for a single tax on a line.""" + expense = self._make_expense(22.0) + line = self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [Command.set(self.tax_10.ids)], + "base_amount_currency": 20.0, + } + ) + self.assertAlmostEqual(line.tax_amount_currency, 2.0, places=2) + self.assertAlmostEqual(line.total_amount_currency, 22.0, places=2) + + def test_multi_tax_on_one_line(self): + """Two taxes on a single distribution line are cumulated correctly.""" + expense = self._make_expense(100.0) + # 10% + 2% eco-tax on 80 EUR base → tax = 9.6, total = 89.6 + line = self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [Command.set((self.tax_10 | self.tax_eco).ids)], + "base_amount_currency": 80.0, + } + ) + # 80 * (1 + 0.10 + 0.02) = 89.6 + self.assertAlmostEqual(line.tax_amount_currency, 9.6, places=2) + self.assertAlmostEqual(line.total_amount_currency, 89.6, places=2) + + def test_zero_base_gives_zero_amounts(self): + """A line with base_amount_currency = 0 computes 0 for all amounts.""" + expense = self._make_expense(86.75) + line = self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [Command.set(self.tax_20.ids)], + "base_amount_currency": 0.0, + } + ) + self.assertEqual(line.tax_amount_currency, 0.0) + self.assertEqual(line.total_amount_currency, 0.0) + + def test_no_tax_on_line(self): + """A line with no tax_ids computes 0 tax and total = base.""" + expense = self._make_expense(50.0) + line = self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [], + "base_amount_currency": 50.0, + } + ) + self.assertEqual(line.tax_amount_currency, 0.0) + self.assertAlmostEqual(line.total_amount_currency, 50.0, places=2) + + # ------------------------------------------------------------------ + # 2. has_tax_distribution flag + # ------------------------------------------------------------------ + + def test_has_tax_distribution_true(self): + expense = self._make_expense(86.75) + self._make_dist_lines(expense, [(self.tax_5, 50.0)]) + expense.invalidate_recordset() + self.assertTrue(expense.has_tax_distribution) + + def test_has_tax_distribution_false_when_no_lines(self): + expense = self._make_expense(50.0) + self.assertFalse(expense.has_tax_distribution) + + # ------------------------------------------------------------------ + # 3. Onchange: lines generated only when tax_ids has 2+ taxes + # All onchange tests use Form to simulate real UI interactions. + # ------------------------------------------------------------------ + + def test_onchange_single_tax_no_lines(self): + """Adding a single tax via the form must not create distribution lines.""" + with Form(self.env["hr.expense"]) as f: + f.name = "Restaurant Test" + f.employee_id = self.employee + f.product_id = self.product + f.total_amount_currency = 50.0 + f.tax_ids.add(self.tax_10) + expense = f.save() + self.assertFalse(expense.tax_line_ids) + + def test_onchange_two_taxes_creates_two_lines(self): + """Adding two taxes via the form creates one distribution line per tax.""" + with Form(self.env["hr.expense"]) as f: + f.name = "Restaurant Test" + f.employee_id = self.employee + f.product_id = self.product + f.total_amount_currency = 74.75 + f.tax_ids.add(self.tax_5) + f.tax_ids.add(self.tax_10) + expense = f.save() + self.assertEqual(len(expense.tax_line_ids), 2) + line_taxes = expense.tax_line_ids.mapped("tax_ids") + self.assertIn(self.tax_5, line_taxes) + self.assertIn(self.tax_10, line_taxes) + + def test_onchange_three_taxes_creates_three_lines(self): + """Adding three taxes creates three distribution lines (main scenario).""" + with Form(self.env["hr.expense"]) as f: + f.name = "Restaurant Test" + f.employee_id = self.employee + f.product_id = self.product + f.total_amount_currency = 86.75 + f.tax_ids.add(self.tax_5) + f.tax_ids.add(self.tax_10) + f.tax_ids.add(self.tax_20) + expense = f.save() + self.assertEqual(len(expense.tax_line_ids), 3) + + def test_onchange_removing_tax_to_single_clears_all_lines(self): + """Removing taxes until only one remains must clear all distribution lines.""" + # Create with 3 taxes + with Form(self.env["hr.expense"]) as f: + f.name = "Restaurant Test" + f.employee_id = self.employee + f.product_id = self.product + f.total_amount_currency = 86.75 + f.tax_ids.add(self.tax_5) + f.tax_ids.add(self.tax_10) + f.tax_ids.add(self.tax_20) + expense = f.save() + self.assertEqual(len(expense.tax_line_ids), 3) + + # Remove two taxes via the form, leaving only one + with Form(expense) as f: + f.tax_ids.remove(index=0) # removes first tax in the list + f.tax_ids.remove(index=0) # removes second tax (now index 0) + expense = f.save() + self.assertFalse( + expense.tax_line_ids, + "All distribution lines must be cleared when only one tax remains.", + ) + + def test_onchange_adding_tax_preserves_existing_base_amounts(self): + """Adding a new tax to an expense that already has distribution lines + must preserve the base_amount_currency of the existing lines.""" + # Start with two taxes and fill in base amounts + with Form(self.env["hr.expense"]) as f: + f.name = "Restaurant Test" + f.employee_id = self.employee + f.product_id = self.product + f.total_amount_currency = 86.75 + f.tax_ids.add(self.tax_5) + f.tax_ids.add(self.tax_10) + expense = f.save() + + for line in expense.tax_line_ids: + if self.tax_5 in line.tax_ids: + line.base_amount_currency = 50.0 + elif self.tax_10 in line.tax_ids: + line.base_amount_currency = 20.0 + + # Add a third tax via the form + with Form(expense) as f: + f.tax_ids.add(self.tax_20) + expense = f.save() + + self.assertEqual(len(expense.tax_line_ids), 3) + for line in expense.tax_line_ids: + if self.tax_5 in line.tax_ids: + self.assertAlmostEqual(line.base_amount_currency, 50.0) + elif self.tax_10 in line.tax_ids: + self.assertAlmostEqual(line.base_amount_currency, 20.0) + elif self.tax_20 in line.tax_ids: + # Newly added line must start at 0 + self.assertAlmostEqual(line.base_amount_currency, 0.0) + + def test_onchange_removing_one_tax_preserves_other_base_amounts(self): + """Removing one tax (while 2+ remain) must keep the base amounts of + the surviving lines intact.""" + with Form(self.env["hr.expense"]) as f: + f.name = "Restaurant Test" + f.employee_id = self.employee + f.product_id = self.product + f.total_amount_currency = 86.75 + f.tax_ids.add(self.tax_5) + f.tax_ids.add(self.tax_10) + f.tax_ids.add(self.tax_20) + expense = f.save() + + for line in expense.tax_line_ids: + if self.tax_5 in line.tax_ids: + line.base_amount_currency = 50.0 + elif self.tax_10 in line.tax_ids: + line.base_amount_currency = 20.0 + elif self.tax_20 in line.tax_ids: + line.base_amount_currency = 10.0 + + # Remove tax_20 from the form, two taxes remain + with Form(expense) as f: + f.tax_ids.remove(self.tax_20.id) + expense = f.save() + + self.assertEqual(len(expense.tax_line_ids), 2) + for line in expense.tax_line_ids: + if self.tax_5 in line.tax_ids: + self.assertAlmostEqual(line.base_amount_currency, 50.0) + elif self.tax_10 in line.tax_ids: + self.assertAlmostEqual(line.base_amount_currency, 20.0) + + # ------------------------------------------------------------------ + # 4. Expense-level tax amounts derived from distribution lines + # ------------------------------------------------------------------ + + def test_expense_tax_amounts_from_distribution(self): + """tax_amount_currency and untaxed_amount_currency on hr.expense + reflect the distribution lines once base amounts are non-zero.""" + expense = self._make_expense(86.75) + self._make_dist_lines( + expense, + [ + (self.tax_5, 50.0), # tax 2.75, total 52.75 + (self.tax_10, 20.0), # tax 2.00, total 22.00 + (self.tax_20, 10.0), # tax 2.00, total 12.00 + ], + ) + expense.invalidate_recordset() + self.assertAlmostEqual(expense.tax_amount_currency, 6.75, places=2) + self.assertAlmostEqual(expense.untaxed_amount_currency, 80.0, places=2) + + def test_expense_tax_amounts_standard_when_lines_all_zero(self): + """When all distribution lines have base_amount = 0, standard Odoo + computation is used for tax_amount_currency (not overridden).""" + expense = self._make_expense(120.0, tax_ids=self.tax_20) + # Create a single line but leave base at 0 + self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [Command.set(self.tax_20.ids)], + "base_amount_currency": 0.0, + } + ) + expense.invalidate_recordset() + # Standard: 120 TTC at 20% → tax = 20.0, untaxed = 100.0 + self.assertAlmostEqual(expense.tax_amount_currency, 20.0, places=2) + + # ------------------------------------------------------------------ + # 5. Constraint: total must match + # ------------------------------------------------------------------ + + def test_constraint_total_matches(self): + """No ValidationError when distribution totals equal expense total.""" + expense = self._make_expense(86.75) + self._make_dist_lines( + expense, + [(self.tax_5, 50.0), (self.tax_10, 20.0), (self.tax_20, 10.0)], + ) + expense._check_tax_distribution_total() # must not raise + + def test_constraint_total_mismatch_raises(self): + expense = self._make_expense(100.0) + self._make_dist_lines(expense, [(self.tax_20, 10.0)]) # total = 12, not 100 + with self.assertRaises(ValidationError): + expense._check_tax_distribution_total() + + def test_no_constraint_when_no_lines(self): + expense = self._make_expense(100.0) + expense._check_tax_distribution_total() # must not raise + + def test_no_constraint_when_total_is_zero(self): + expense = self._make_expense(0.0) + self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [Command.set(self.tax_20.ids)], + "base_amount_currency": 0.0, + } + ) + expense._check_tax_distribution_total() # must not raise + + # ------------------------------------------------------------------ + # 6. Constraint: base_amount cannot be negative + # ------------------------------------------------------------------ + + def test_constraint_negative_base_raises(self): + expense = self._make_expense(10.0) + with self.assertRaises(ValidationError): + self.env["hr.expense.tax.line"].create( + { + "expense_id": expense.id, + "tax_ids": [Command.set(self.tax_20.ids)], + "base_amount_currency": -5.0, + } + ) + + # ------------------------------------------------------------------ + # 7. Move line vals: price_unit and quantity + # ------------------------------------------------------------------ + + def test_move_lines_price_unit_quantity_1(self): + """Without product_has_cost, quantity=1 and price_unit=total_amount (TTC).""" + expense = self._make_expense(86.75, product=self.product) + self._make_dist_lines( + expense, + [(self.tax_5, 50.0), (self.tax_10, 20.0), (self.tax_20, 10.0)], + ) + lines = expense._get_tax_distribution_move_lines_vals() + self.assertEqual(len(lines), 3) + for line in lines: + self.assertEqual(line["quantity"], 1.0) + # price_unit must be total_amount_currency (TTC) of each dist line + # 5.5%: 50 * 1.055 = 52.75 / 10%: 20 * 1.10 = 22.0 / 20%: 10 * 1.20 = 12.0 + self.assertIn(round(line["price_unit"], 2), [52.75, 22.0, 12.0]) + + def test_move_lines_price_unit_with_quantity(self): + """With product_has_cost and quantity=2, price_unit = total_ttc / 2.""" + # 40 HT * 1.10 = 44.0 TTC → price_unit = 44.0 / 2 = 22.0 + expense = self._make_expense(44.0, product=self.product_with_cost, quantity=2.0) + self._make_dist_lines(expense, [(self.tax_10, 40.0)]) + lines = expense._get_tax_distribution_move_lines_vals() + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0]["quantity"], 2.0) + self.assertAlmostEqual(lines[0]["price_unit"], 22.0, places=2) + + def test_move_lines_account_move_amounts(self): + """End-to-end: the generated account.move has the correct base and tax + amounts for each distribution line (own_account flow).""" + expense = self._make_expense( + 86.75, + tax_ids=self.tax_5 | self.tax_10 | self.tax_20, + product=self.product, + ) + self._make_dist_lines( + expense, + [(self.tax_5, 50.0), (self.tax_10, 20.0), (self.tax_20, 10.0)], + ) + sheet_act = expense.action_submit_expenses() + sheet = self.env[sheet_act["res_model"]].browse(sheet_act["res_id"]) + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + sheet.action_sheet_move_post() + move = self.env["account.move"].search( + [("expense_sheet_id", "=", sheet.id)], limit=1 + ) + self.assertTrue(move) + # Collect product lines (display_type = 'product') and tax lines + product_lines = move.line_ids.filtered(lambda ml: ml.display_type == "product") + tax_lines = move.line_ids.filtered(lambda ml: ml.display_type == "tax") + self.assertEqual( + len(product_lines), 3, "One product line per distribution entry" + ) + self.assertEqual(len(tax_lines), 3, "One tax line per distribution entry") + # Total TTC on the move must equal expense total + self.assertAlmostEqual(move.amount_total, 86.75, places=2) + # HT sum: 50 + 20 + 10 = 80 + self.assertAlmostEqual(move.amount_untaxed, 80.0, places=2) + # Tax sum: 2.75 + 2.00 + 2.00 = 6.75 + self.assertAlmostEqual(move.amount_tax, 6.75, places=2) diff --git a/hr_expense_tax_distribution/views/hr_expense_tax_distribution_views.xml b/hr_expense_tax_distribution/views/hr_expense_tax_distribution_views.xml new file mode 100644 index 000000000..9806c0792 --- /dev/null +++ b/hr_expense_tax_distribution/views/hr_expense_tax_distribution_views.xml @@ -0,0 +1,65 @@ + + + + + hr.expense.tax.line.tree + hr.expense.tax.line + + + + + + + + + + + + + + hr.expense.form.tax.distribution + hr.expense + + + + + + + + + +
+
Tax Distribution
+
+ Distribute the expense total across the applicable tax rates. + The sum of the "Total (Tax Incl.)" column must equal the + expense total. +
+ + + + + + + + + + +
+
+
+
+