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 000000000..1dcc49c24 Binary files /dev/null and b/hr_expense_tax_distribution/static/description/icon.png differ diff --git a/hr_expense_tax_distribution/static/description/index.html b/hr_expense_tax_distribution/static/description/index.html new file mode 100644 index 000000000..5e2beebdf --- /dev/null +++ b/hr_expense_tax_distribution/static/description/index.html @@ -0,0 +1,598 @@ + + + + + +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. +
+ + + + + + + + + + +
+
+
+
+