diff --git a/account_avatax_oca_line_grouping/README.rst b/account_avatax_oca_line_grouping/README.rst new file mode 100644 index 000000000..60dfebd33 --- /dev/null +++ b/account_avatax_oca_line_grouping/README.rst @@ -0,0 +1,457 @@ +============================ +Avalara Avatax Line Grouping +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6fcfc98092432096bab3590615cae7add7028070f3912aa487c1091367f0c783 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Faccount--fiscal--rule-lightgray.png?logo=github + :target: https://github.com/OCA/account-fiscal-rule/tree/16.0/account_avatax_oca_line_grouping + :alt: OCA/account-fiscal-rule +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-fiscal-rule-16-0/account-fiscal-rule-16-0-account_avatax_oca_line_grouping + :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/account-fiscal-rule&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon extends the AvaTax connector provided by +``account_avatax_oca`` (OCA *account-fiscal-rule* repository). + +It adds an optional feature to **group all document lines into a single +line** when sending data to AvaTax, so that tax is computed on the +**total document amount** instead of line by line. + +This behaviour is useful in jurisdictions where multiple items sold +together are legally treated as a **single sale** or a **working unit** +for tax purposes (for example, some discretionary surtax cap rules +on bulk/material or project invoices). + +Main Features +------------- + +* Optional company-level setting: **Group document lines for AvaTax**. +* When enabled: + + - For **Sales Orders**: + + - All ``sale.order.line`` records are aggregated. + - AvaTax receives **one line** with: + + - ``qty = 1`` + - ``amount = total untaxed amount of all lines`` + - ``itemCode`` and ``taxCode`` taken from the first line’s product + (or a safe fallback if no product is set). + - A combined discount amount if any line has a discount. + + - For **Customer Invoices (account.move)**: + + - All invoice lines are aggregated in the same way. + - Only the **first invoice line** generates an AvaTax payload line. + - AvaTax tax result is applied to that first line in Odoo. + - The invoice total (including tax) reflects tax on the **entire** + document, matching single-sale / working-unit requirements. + +* When the option is **disabled**, the standard behaviour from + ``account_avatax_oca`` is preserved: AvaTax receives one line per Odoo line. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This addon is an extension of the AvaTax connector provided by +``account_avatax_oca``. It does **not** replace the base connector and +depends on it being correctly installed and configured first. + +Python dependency +----------------- + +The same Python client library used by ``account_avatax_oca`` is required: + +- Avalara Python client: https://pypi.org/project/Avalara + +If you already installed it for the base module, you do not need to do +anything else. Otherwise, install it with ``pip`` in your Odoo +environment:: + + pip3 install Avalara + +Module dependencies +------------------- + +This module depends on: + +- ``account_avatax_oca`` (AvaTax support for Customer Invoices) +- ``account_avatax_sale_oca`` (AvaTax support for Quotations / Sales Orders) + +Make sure both are available and installed in your Odoo instance +(or at least ``account_avatax_oca`` if you only use Invoices). + +Install the addon +----------------- + +To install ``account_avatax_oca_line_grouping``: + +1. Clone or download the OCA *account-fiscal-rule* repository (or your fork) + that contains this addon. +2. Ensure the directory ``account_avatax_oca_line_grouping`` is in your + Odoo addons path. +3. Restart the Odoo server. +4. Log into Odoo as an Administrator and enable **Developer Mode** + in *Settings*. +5. Go to **Apps**, click **Update Apps List** so that Odoo detects + the new addon. +6. Search for **AvaTax Line Grouping** or ``account_avatax_oca_line_grouping``. +7. Click **Install**. + +After installation, you can enable the line-grouping behaviour in: + +- **Accounting/Invoicing >> Configuration >> AvaTax >> AvaTax API** + (see the configuration guide for the *Group document lines for AvaTax* option). + +Configuration +============= + +This addon extends the AvaTax connector provided by ``account_avatax_oca`` +and allows grouping all document lines (Sales Orders and Customer Invoices) +into a single line when sending data to AvaTax. + +This behaviour is useful for “single sale” or bulk / working-unit scenarios, +for example where local rules require applying surtax caps on the **total** +of the invoice instead of per line. + +Prerequisites +------------- + +Before using this module, you must: + +- Install and configure ``account_avatax_oca`` (and ``account_avatax_sale_oca``). +- Follow the configuration steps described in the **CONFIGURE.rst** of + ``account_avatax_oca`` (AvaTax API connection, company taxes, customers, + products, fiscal positions, etc.). + +Once the base connector is working and taxes are correctly retrieved from AvaTax, +you can enable line grouping as described below. + +Enable Line Grouping +-------------------- + +1. Go to: **Accounting/Invoicing App >> Configuration >> AvaTax >> AvaTax API**. +2. Open the AvaTax configuration used by your company. +3. In the *Tax Calculation* (or equivalent) tab, enable: + + - **Group document lines for AvaTax** + +4. Save the configuration. + +Functional Behaviour +-------------------- + +When **Group document lines for AvaTax** is enabled on the company: + +For **Sales Orders**: + + - All ``sale.order.line`` records of the order are aggregated. + - The connector sends a **single line** to AvaTax with ``qty = 1`` and + ``amount = total untaxed amount of all lines`` (including discounts, + using the same base as the standard AvaTax computation), and using + ``itemCode`` and ``taxCode`` taken from the first order line’s product + (or a fallback identifier if no product is set). + - A combined discount amount is used if any line has a discount. + + +- For **Customer Invoices (account.move)**: + + - All invoice lines are aggregated in the same way. + - Only the **first invoice line** produces an AvaTax payload entry; + the remaining lines are not sent individually. + - AvaTax returns tax values for that single line, and the connector + applies the resulting tax amount to that first line in Odoo. + - The invoice total (including tax) reflects tax computed on the **entire document**, + which is required in certain single-sale / bulk / working-unit scenarios. + +When the option is **disabled**, the standard behaviour from +``account_avatax_oca`` is used: AvaTax receives one line per Odoo line, +and taxes are calculated line by line. + +Usage Notes and Caveats +----------------------- + +- Line grouping changes how the tax base is presented to AvaTax + and can significantly affect calculated taxes and possible surtax caps. +- It should only be enabled when the entire document is legally treated as + a **single sale** or as a **working unit** (e.g. certain construction + or bulk material contracts). +- If your invoices contain lines with different taxability types or + different product tax codes that must be distinguished by AvaTax, + you should **not** enable line grouping. +- Always consult with your tax advisor before enabling this option + in a production environment. + +Usage +===== + +This addon does not change **how** you create or validate +Sales Orders and Customer Invoices in Odoo. +It only changes **what is sent to AvaTax** when +*Group document lines for AvaTax* is enabled. + +Prerequisites +------------- + +- ``account_avatax_oca`` (and optionally ``account_avatax_sale_oca``) + are installed and correctly configured. +- Fiscal Positions, AvaTax API connection, Products and Customers + are already working with AvaTax in the standard way. +- The company option **Group document lines for AvaTax** is enabled + in **Accounting/Invoicing >> Configuration >> AvaTax >> AvaTax API**. + +Customer Invoices (grouped) +--------------------------- + +When **Group document lines for AvaTax** is enabled, customer invoices +still follow the standard AvaTax flow, but the **payload** sent to AvaTax +is different. + +- You create an invoice as usual in: + + - **Accounting / Invoicing >> Customers >> Invoices** + +- You add as many lines as needed (products, quantities, discounts). +- You ensure the fiscal position and AvaTax tax are correctly set + (same as with the base connector). +- When you click **Validate**: + + - The connector computes taxes through AvaTax. + - Instead of sending one line per invoice line, it sends a **single** + aggregated line, representing the **total untaxed amount** of + the invoice. + - AvaTax returns tax for that single line, and the connector maps + the resulting tax amount back to the **first invoice line**. + +Effects in Odoo +--------------- + +- The **invoice total** (tax included) reflects tax calculated on the + **entire document**, rather than per line. +- The **first invoice line** will show the AvaTax tax result + (tax lines are attached to that line). +- Remaining invoice lines do not carry separate tax amounts, but the + overall accounting is correct. + +Effects in AvaTax +------------------ + +In the AvaTax transaction log: + +- You will see **one line** for the invoice with ``quantity = 1`` and + ``amount = total untaxed amount`` of the invoice, using the item code / + tax code of the first invoice line’s product (or a fallback code if no + product is set). +- The transaction status (Uncommitted / Committed / Voided) behaves as + usual and is controlled by the base connector. + + +Refunds and credit notes +-------------------------------- + +Customer refunds (credit notes) behave like invoices: + +- If line grouping is enabled, the refund is also sent as a **single** + aggregated line with a negative amount. +- AvaTax will show a transaction with one line and a negative total, + consistent with standard refund behaviour, but using the grouped base. + +Sales Orders (grouped) +---------------------- + +When using ``account_avatax_sale_oca`` together with this module, +Sales Orders can also use grouped tax computation. + +- You create a Sales Order in: + + - **Sales >> Orders >> Orders** + +- You add multiple lines (products, quantities, discounts). +- When you: + + - Confirm the order, or + - Use **Action >> Update taxes with AvaTax** + + the connector: + + - Aggregates all order lines into a **single** virtual line. + - Sends one line to AvaTax with: + + - ``qty = 1`` + - ``amount = total untaxed amount`` of the order + - Combined discount amount if any lines have discounts. + - Retrieves the tax amount and updates the order accordingly. + +- As with the base module, Sales Orders are typically **not** recorded + as committed transactions in the AvaTax dashboard; they are used to + estimate tax, and the real transaction is created on invoice. + +Effects in Odoo + + +- From the user perspective, creating and managing Sales Orders does + not change. +- Tax amounts on the order are computed on the **total** of all lines, + rather than line by line, when grouping is active. + +Effects in AvaTax +----------------- + +- The request sent to AvaTax during tax calculation is a single line + representing the full order. +- The tax result is then used in Odoo for that order’s totals. +- The behaviour of recording / not recording transactions in AvaTax + follows the logic of the base addon (orders vs invoices). + +Practical Example +----------------- + +1. Create a Sales Order with several lines that together form a + single project / working unit (e.g. all materials for one roof). +2. Ensure the company has **Group document lines for AvaTax** enabled. +3. Confirm the order or use **Update taxes with AvaTax**: + - Odoo shows a total tax based on the **entire order**. +4. Create and validate the Customer Invoice from that order: + - The invoice sends **one line** to AvaTax with the total base. + - AvaTax applies tax (and any applicable caps) on the total. + - In Odoo, the first invoice line carries the tax result. + +When NOT to Use Line Grouping +----------------------------- + +You should **not** enable the grouping option if: + +- Different lines need **different AvaTax product tax codes** + that must be distinguished by AvaTax. +- The invoice mixes goods and services that should be taxed + differently at the line level. +- Your tax advisor requires per-line tax visibility in AvaTax. + +In those cases, simply disable **Group document lines for AvaTax** and +the standard per-line behaviour from ``account_avatax_oca`` will apply. + +Known issues / Roadmap +====================== + +The main goal of this addon is to support **single-sale / bulk / working-unit** +tax scenarios by grouping all document lines into a single line for AvaTax. + +The current implementation provides a simple, company-wide switch: +when enabled, all Sales Orders and Customer Invoices for that company +are sent to AvaTax as a single aggregated line. + +Possible future improvements +---------------------------- + +The following enhancements are candidates for future versions: + +- **Per-fiscal-position control** + + Allow enabling line grouping per *Fiscal Position* instead of (or in + addition to) a global company flag. + Example: only apply grouping when a dedicated “Single Sale / Bulk” + fiscal position is used. + +- **Per-document or per-partner control** + + Add an option on Sales Orders / Invoices and/or on partners to override + the company setting, so that grouping can be enabled/disabled on a + case-by-case basis. + +- **Grouping strategies** + + Support different grouping strategies, such as: + + - Group only lines that share the same product tax code. + - Group only lines marked with a specific tag or analytic account. + - Allow multiple aggregated lines per document (one per group), instead + of a single line per document. + +- **Validation and warnings** + + Add checks and warnings when line grouping is enabled but: + + - Lines have heterogeneous tax codes that may lead to inaccurate + results in AvaTax. + - The document does not match the expected “single sale” / “working + unit” business rules defined by the company. + +- **Extended jurisdiction coverage** + + Document and test line grouping behaviour for additional jurisdictions + and tax rules where a cap or special treatment applies to a total + transaction amount (beyond the initial US use cases). + +- **Monitoring and logging** + + Improve logging and diagnostics for grouped transactions, making it + easier for users and accountants to review: + + - The original line breakdown in Odoo. + - The single-line payload sent to AvaTax. + - The returned tax amounts and how they are mapped back to Odoo lines. + +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 +~~~~~~~ + +* Binhex + +Contributors +~~~~~~~~~~~~ + +* `Binhex `_: + + * Carlos R. Rodriguez + +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. + +This module is part of the `OCA/account-fiscal-rule `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_avatax_oca_line_grouping/__init__.py b/account_avatax_oca_line_grouping/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/account_avatax_oca_line_grouping/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_avatax_oca_line_grouping/__manifest__.py b/account_avatax_oca_line_grouping/__manifest__.py new file mode 100644 index 000000000..ed821c50f --- /dev/null +++ b/account_avatax_oca_line_grouping/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Avalara Avatax Line Grouping", + "version": "16.0.1.0.0", + "category": "Accounting", + "summary": "Group document lines as a single line for Avatax computation", + "author": "Binhex, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-fiscal-rule", + "license": "AGPL-3", + "depends": [ + "account_avatax_oca", + "account_avatax_sale_oca", + ], + "data": [ + "views/avalara_salestax_view.xml", + ], + "demo": [], + "installable": True, + "application": False, +} diff --git a/account_avatax_oca_line_grouping/models/__init__.py b/account_avatax_oca_line_grouping/models/__init__.py new file mode 100644 index 000000000..7c9725eb1 --- /dev/null +++ b/account_avatax_oca_line_grouping/models/__init__.py @@ -0,0 +1,6 @@ +from . import res_company +from . import avalara_salestax +from . import account_move +from . import account_move_line +from . import sale_order +from . import sale_order_line diff --git a/account_avatax_oca_line_grouping/models/account_move.py b/account_avatax_oca_line_grouping/models/account_move.py new file mode 100644 index 000000000..118f71e09 --- /dev/null +++ b/account_avatax_oca_line_grouping/models/account_move.py @@ -0,0 +1,183 @@ +import logging + +from odoo import fields, models +from odoo.tools import float_compare, float_round + +_logger = logging.getLogger(__name__) + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _avatax_compute_tax(self, commit=False): # noqa: C901 + """Contact REST API and recompute taxes for an Invoice.""" + self and self.ensure_one() + avatax_config = self.company_id.get_avatax_config_company() + if not avatax_config: + # Skip Avatax computation if no configuration is found + return + + doc_type = self._get_avatax_doc_type(commit=commit) + tax_date = self.get_origin_tax_date() or self.invoice_date + taxable_lines = self._avatax_prepare_lines(doc_type) + + tax_result = avatax_config.create_transaction( + self.invoice_date or fields.Date.today(), + self.name, + doc_type, + ( + self.so_partner_id + if self.so_partner_id and avatax_config.use_so_partner_id + else self.partner_id + ), + self.warehouse_id.partner_id or self.company_id.partner_id, + self.tax_address_id or self.partner_id, + taxable_lines, + self.user_id, + self.exemption_code or None, + self.exemption_code_id.code or None, + commit, + tax_date, + # TODO: can we report self.invoice_doc_no? + self.name if self.move_type == "out_refund" else "", + self.location_code or "", + is_override=self.move_type == "out_refund", + currency_id=self.currency_id, + ignore_error=300 if commit else None, + log_to_record=self, + ) + + # If committing, and document exists, try unvoiding it + # Error number 300 = GetTaxError, Expected Saved|Posted + if commit and tax_result.get("number") == 300: + _logger.info( + "Document %s (%s) already exists in Avatax. " + "Should be a voided transaction. " + "Unvoiding and re-committing.", + self.name, + doc_type, + ) + avatax_config.unvoid_transaction(self.name, doc_type) + avatax_config.commit_transaction(self.name, doc_type) + return tax_result + + if self.state == "draft": + Tax = self.env["account.tax"] + tax_result_lines = {int(x["lineNumber"]): x for x in tax_result["lines"]} + taxes_to_set = {} + + if avatax_config.avatax_group_lines and len(tax_result_lines) == 1: + single_result_line = list(tax_result_lines.values())[0] + + total_tax = ( + single_result_line.get("taxCalculated") + or single_result_line.get("tax") + or 0.0 + ) + + inv_lines = self.invoice_line_ids.filtered(lambda l: not l.display_type) + if inv_lines and total_tax: + + def _line_base(line): + amount = line._get_avatax_amount() + if line.quantity < 0: + amount = -amount + return amount + + total_base = sum(_line_base(line) for line in inv_lines) + + if total_base: + tax_calculation = 0.0 + if single_result_line.get("taxableAmount"): + tax_calculation = ( + single_result_line["taxCalculated"] + / single_result_line["taxableAmount"] + ) + rate = round(tax_calculation * 100, 4) + tax = Tax.get_avalara_tax(rate, doc_type) + + remaining_tax = total_tax + currency = self.currency_id + line_count = len(inv_lines) + + for idx, line in enumerate(inv_lines, start=1): + base = _line_base(line) + + if idx == line_count: + line_tax = remaining_tax + else: + share = base / total_base if total_base else 0.0 + line_tax = float_round( + total_tax * share, + precision_rounding=currency.rounding, + ) + remaining_tax -= line_tax + + tax_line, line_obj = self.update_tax_details( + tax, line, single_result_line + ) + + if tax_line: + base_taxes = line.tax_ids.filtered( + lambda x: not x.is_avatax + ) + if avatax_config.override_line_taxes: + taxes_to_set[line.id] = tax_line + else: + taxes_to_set[line.id] = base_taxes | tax_line + + line.avatax_amt_line = line_tax + else: + for line in self.invoice_line_ids: + tax_result_line = tax_result_lines.get(line.id) + if tax_result_line: + tax_calculation = 0.0 + if tax_result_line["taxableAmount"]: + tax_calculation = ( + tax_result_line["taxCalculated"] + / tax_result_line["taxableAmount"] + ) + rate = round(tax_calculation * 100, 4) + tax = Tax.get_avalara_tax(rate, doc_type) + tax, line = self.update_tax_details(tax, line, tax_result_line) + if tax and tax not in line.tax_ids: + line_taxes = line.tax_ids.filtered( + lambda x: not x.is_avatax + ) + taxes_to_set[line.id] = line_taxes | tax + line.avatax_amt_line = tax_result_line["tax"] + + self.with_context(check_move_validity=False).avatax_amount = tax_result[ + "totalTax" + ] + container = {"records": self} + + with self.with_context( + avatax_invoice=self, check_move_validity=False + )._sync_dynamic_lines(container), self.line_ids.mapped( + "move_id" + )._check_balanced( + container + ): + for line_id in taxes_to_set.keys(): + line = self.invoice_line_ids.filtered(lambda x: x.id == line_id) + line.write({"tax_ids": [(6, 0, [])]}) + line.with_context( + avatax_invoice=self, check_move_validity=False + ).write({"tax_ids": taxes_to_set.get(line_id).ids}) + + self._compute_amount() + + if float_compare( + self.amount_untaxed + max(self.amount_tax, abs(self.avatax_amount)), + self.amount_residual, + precision_rounding=self.currency_id.rounding or 0.001, + ): + taxes_data = { + iline.id: iline.tax_ids for iline in self.invoice_line_ids + } + self.invoice_line_ids.write({"tax_ids": [(6, 0, [])]}) + for line in self.invoice_line_ids: + line.write({"tax_ids": taxes_data[line.id].ids}) + + return tax_result diff --git a/account_avatax_oca_line_grouping/models/account_move_line.py b/account_avatax_oca_line_grouping/models/account_move_line.py new file mode 100644 index 000000000..068dcf7b2 --- /dev/null +++ b/account_avatax_oca_line_grouping/models/account_move_line.py @@ -0,0 +1,65 @@ +from odoo import models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _avatax_prepare_line(self, sign=1, doc_type=None): + """ + Prepare invoice line data for Avatax. + + When company.avatax_group_lines is enabled, only the first invoice line + of the move will produce a dict, aggregating the amounts of all lines. + All other lines will return {} so they are ignored in the Avatax payload. + """ + move = self.move_id + company = move.company_id + + if company and getattr(company, "avatax_group_lines", False): + first_line = move.invoice_line_ids[:1] + if not first_line or self != first_line[0]: + return {} + + total_amount = 0.0 + for line in move.invoice_line_ids: + amount = sign * line._get_avatax_amount() + if hasattr(line, "quantity") and line.quantity < 0: + amount = -amount + total_amount += amount + + line = first_line[0] + product = line.product_id + + item_code = None + tax_code = None + upc_enable = False + avatax_config = None + + if hasattr(company, "get_avatax_config_company"): + avatax_config = company.get_avatax_config_company() + if avatax_config: + upc_enable = bool(getattr(avatax_config, "upc_enable", False)) + + if product: + if product.barcode and upc_enable: + item_code = "UPC:%s" % product.barcode + else: + item_code = product.default_code or "ID:%d" % product.id + tax_code = product.applicable_tax_code_id.name + + if not item_code: + item_code = "DOC:%s" % (move.name or move.ref or move.id) + description = line.name or move.name or "Combined Items" + + return { + "qty": 1, + "itemcode": item_code, + "description": description, + "amount": total_amount, + "tax_code": tax_code, + "id": line, + "account_id": line.account_id.id, + "tax_id": line.tax_ids, + } + + return super()._avatax_prepare_line(sign=sign, doc_type=doc_type) diff --git a/account_avatax_oca_line_grouping/models/avalara_salestax.py b/account_avatax_oca_line_grouping/models/avalara_salestax.py new file mode 100644 index 000000000..b2cbef5d4 --- /dev/null +++ b/account_avatax_oca_line_grouping/models/avalara_salestax.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class AvalaraSalestax(models.Model): + _inherit = "avalara.salestax" + + avatax_group_lines = fields.Boolean( + string="Group document lines for Avatax", + related="company_id.avatax_group_lines", + readonly=False, + help=( + "If enabled on the company, Avatax will receive a single aggregated " + "line per document instead of one line per order/invoice line." + ), + ) diff --git a/account_avatax_oca_line_grouping/models/res_company.py b/account_avatax_oca_line_grouping/models/res_company.py new file mode 100644 index 000000000..dfe8c8a86 --- /dev/null +++ b/account_avatax_oca_line_grouping/models/res_company.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + avatax_group_lines = fields.Boolean( + string="Group document lines for Avatax", + help=( + "If enabled, all lines of a Sales Order or Invoice will be sent to " + "Avatax as a single aggregated line for tax computation." + ), + default=False, + ) diff --git a/account_avatax_oca_line_grouping/models/sale_order.py b/account_avatax_oca_line_grouping/models/sale_order.py new file mode 100644 index 000000000..7578758af --- /dev/null +++ b/account_avatax_oca_line_grouping/models/sale_order.py @@ -0,0 +1,130 @@ +from odoo import models +from odoo.tools import float_round + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _avatax_compute_tax(self): + """Contact REST API and recompute taxes for a Sale Order.""" + self and self.ensure_one() + doc_type = self._get_avatax_doc_type() + Tax = self.env["account.tax"] + avatax_config = self.company_id.get_avatax_config_company() + if not avatax_config: + return False + + partner = self.partner_id + if avatax_config.use_partner_invoice_id: + partner = self.partner_invoice_id + + taxable_lines = self._avatax_prepare_lines(self.order_line) + tax_result = avatax_config.create_transaction( + self.date_order, + self.name, + doc_type, + partner, + self.warehouse_id.partner_id or self.company_id.partner_id, + self.tax_address_id or self.partner_id, + taxable_lines, + self.user_id, + self.exemption_code or None, + self.exemption_code_id.code or None, + currency_id=self.currency_id, + log_to_record=self, + ) + + tax_result_lines = {int(x["lineNumber"]): x for x in tax_result["lines"]} + + if avatax_config.avatax_group_lines and len(tax_result_lines) == 1: + single_result_line = list(tax_result_lines.values())[0] + + total_tax = ( + single_result_line.get("taxCalculated") + or single_result_line.get("tax") + or 0.0 + ) + + order_lines = self.order_line.filtered(lambda l: not l.display_type) + if not order_lines or not total_tax: + self.tax_amount = tax_result.get("totalTax") + return True + + def _line_base(line): + return ( + line.price_unit + * line.product_uom_qty + * (1 - (line.discount or 0.0) / 100.0) + ) + + total_base = sum(_line_base(line) for line in order_lines) + if not total_base: + self.tax_amount = tax_result.get("totalTax") + return True + + tax_calculation = 0.0 + if single_result_line.get("taxableAmount"): + tax_calculation = ( + single_result_line["taxCalculated"] + / single_result_line["taxableAmount"] + ) + rate = round(tax_calculation * 100, 4) + tax = Tax.get_avalara_tax(rate, doc_type) + + remaining_tax = total_tax + currency = self.currency_id + line_count = len(order_lines) + + for idx, line in enumerate(order_lines, start=1): + base = _line_base(line) + + if idx == line_count: + line_tax = remaining_tax + else: + share = base / total_base if total_base else 0.0 + line_tax = float_round( + total_tax * share, + precision_rounding=currency.rounding, + ) + remaining_tax -= line_tax + + tax_line, line_obj = self.update_tax_details( + tax, line, single_result_line + ) + + if tax_line not in line.tax_id: + line_taxes = ( + tax_line + if avatax_config.override_line_taxes + else tax_line | line.tax_id.filtered(lambda x: not x.is_avatax) + ) + line.tax_id = line_taxes + + line.tax_amt = line_tax + + self.tax_amount = tax_result.get("totalTax") + return True + + for line in self.order_line: + tax_result_line = tax_result_lines.get(line.id) + if tax_result_line: + tax_calculation = 0.0 + if tax_result_line["taxableAmount"]: + tax_calculation = ( + tax_result_line["taxCalculated"] + / tax_result_line["taxableAmount"] + ) + rate = round(tax_calculation * 100, 4) + tax = Tax.get_avalara_tax(rate, doc_type) + tax, line = self.update_tax_details(tax, line, tax_result_line) + if tax not in line.tax_id: + line_taxes = ( + tax + if avatax_config.override_line_taxes + else tax | line.tax_id.filtered(lambda x: not x.is_avatax) + ) + line.tax_id = line_taxes + line.tax_amt = tax_result_line["tax"] + + self.tax_amount = tax_result.get("totalTax") + return True diff --git a/account_avatax_oca_line_grouping/models/sale_order_line.py b/account_avatax_oca_line_grouping/models/sale_order_line.py new file mode 100644 index 000000000..6e35eea8d --- /dev/null +++ b/account_avatax_oca_line_grouping/models/sale_order_line.py @@ -0,0 +1,79 @@ +from odoo import models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _avatax_prepare_line(self, sign=1, doc_type=None): + """ + Prepare sales order line data for Avatax. + + When company.avatax_group_lines is enabled, only the first order line + of the order will produce a dict, aggregating all lines. + """ + order = self.order_id + company = order.company_id + + if company and getattr(company, "avatax_group_lines", False): + first_line = order.order_line[:1] + if not first_line or self != first_line[0]: + return {} + + total_amount = 0.0 + total_discount_amount = 0.0 + + for line in order.order_line: + line_net = ( + line.price_unit * line.product_uom_qty * (1 - line.discount / 100.0) + ) + total_amount += sign * line_net + + if line.discount: + total_discount_amount += ( + sign + * line.price_unit + * line.product_uom_qty + * line.discount + / 100.0 + ) + + is_discounted = bool(total_discount_amount) + + line = first_line[0] + product = line.product_id + + item_code = None + tax_code = None + upc_enable = False + avatax_config = None + + if hasattr(company, "get_avatax_config_company"): + avatax_config = company.get_avatax_config_company() + if avatax_config: + upc_enable = bool(getattr(avatax_config, "upc_enable", False)) + + if product: + if product.barcode and upc_enable: + item_code = "UPC:%s" % product.barcode + else: + item_code = product.default_code or "ID:%d" % product.id + tax_code = product.applicable_tax_code_id.name + + if not item_code: + item_code = "SO:%s" % (order.name or order.id) + + description = line.name or order.name or "Combined Items" + + return { + "qty": 1, + "itemcode": item_code, + "description": description, + "discounted": is_discounted, + "discount": total_discount_amount, + "amount": total_amount, + "tax_code": tax_code, + "id": line, + "tax_id": line.tax_id, + } + + return super()._avatax_prepare_line(sign=sign, doc_type=doc_type) diff --git a/account_avatax_oca_line_grouping/readme/CONFIGURE.rst b/account_avatax_oca_line_grouping/readme/CONFIGURE.rst new file mode 100644 index 000000000..f6b67fc07 --- /dev/null +++ b/account_avatax_oca_line_grouping/readme/CONFIGURE.rst @@ -0,0 +1,75 @@ +This addon extends the AvaTax connector provided by ``account_avatax_oca`` +and allows grouping all document lines (Sales Orders and Customer Invoices) +into a single line when sending data to AvaTax. + +This behaviour is useful for “single sale” or bulk / working-unit scenarios, +for example where local rules require applying surtax caps on the **total** +of the invoice instead of per line. + +Prerequisites +------------- + +Before using this module, you must: + +- Install and configure ``account_avatax_oca`` (and ``account_avatax_sale_oca``). +- Follow the configuration steps described in the **CONFIGURE.rst** of + ``account_avatax_oca`` (AvaTax API connection, company taxes, customers, + products, fiscal positions, etc.). + +Once the base connector is working and taxes are correctly retrieved from AvaTax, +you can enable line grouping as described below. + +Enable Line Grouping +-------------------- + +1. Go to: **Accounting/Invoicing App >> Configuration >> AvaTax >> AvaTax API**. +2. Open the AvaTax configuration used by your company. +3. In the *Tax Calculation* (or equivalent) tab, enable: + + - **Group document lines for AvaTax** + +4. Save the configuration. + +Functional Behaviour +-------------------- + +When **Group document lines for AvaTax** is enabled on the company: + +For **Sales Orders**: + + - All ``sale.order.line`` records of the order are aggregated. + - The connector sends a **single line** to AvaTax with ``qty = 1`` and + ``amount = total untaxed amount of all lines`` (including discounts, + using the same base as the standard AvaTax computation), and using + ``itemCode`` and ``taxCode`` taken from the first order line’s product + (or a fallback identifier if no product is set). + - A combined discount amount is used if any line has a discount. + + +- For **Customer Invoices (account.move)**: + + - All invoice lines are aggregated in the same way. + - Only the **first invoice line** produces an AvaTax payload entry; + the remaining lines are not sent individually. + - AvaTax returns tax values for that single line, and the connector + applies the resulting tax amount to that first line in Odoo. + - The invoice total (including tax) reflects tax computed on the **entire document**, + which is required in certain single-sale / bulk / working-unit scenarios. + +When the option is **disabled**, the standard behaviour from +``account_avatax_oca`` is used: AvaTax receives one line per Odoo line, +and taxes are calculated line by line. + +Usage Notes and Caveats +----------------------- + +- Line grouping changes how the tax base is presented to AvaTax + and can significantly affect calculated taxes and possible surtax caps. +- It should only be enabled when the entire document is legally treated as + a **single sale** or as a **working unit** (e.g. certain construction + or bulk material contracts). +- If your invoices contain lines with different taxability types or + different product tax codes that must be distinguished by AvaTax, + you should **not** enable line grouping. +- Always consult with your tax advisor before enabling this option + in a production environment. diff --git a/account_avatax_oca_line_grouping/readme/CONTRIBUTORS.rst b/account_avatax_oca_line_grouping/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..7bf1433aa --- /dev/null +++ b/account_avatax_oca_line_grouping/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Binhex `_: + + * Carlos R. Rodriguez diff --git a/account_avatax_oca_line_grouping/readme/DESCRIPTION.rst b/account_avatax_oca_line_grouping/readme/DESCRIPTION.rst new file mode 100644 index 000000000..1bbfdd7eb --- /dev/null +++ b/account_avatax_oca_line_grouping/readme/DESCRIPTION.rst @@ -0,0 +1,39 @@ +This addon extends the AvaTax connector provided by +``account_avatax_oca`` (OCA *account-fiscal-rule* repository). + +It adds an optional feature to **group all document lines into a single +line** when sending data to AvaTax, so that tax is computed on the +**total document amount** instead of line by line. + +This behaviour is useful in jurisdictions where multiple items sold +together are legally treated as a **single sale** or a **working unit** +for tax purposes (for example, some discretionary surtax cap rules +on bulk/material or project invoices). + +Main Features +------------- + +* Optional company-level setting: **Group document lines for AvaTax**. +* When enabled: + + - For **Sales Orders**: + + - All ``sale.order.line`` records are aggregated. + - AvaTax receives **one line** with: + + - ``qty = 1`` + - ``amount = total untaxed amount of all lines`` + - ``itemCode`` and ``taxCode`` taken from the first line’s product + (or a safe fallback if no product is set). + - A combined discount amount if any line has a discount. + + - For **Customer Invoices (account.move)**: + + - All invoice lines are aggregated in the same way. + - Only the **first invoice line** generates an AvaTax payload line. + - AvaTax tax result is applied to that first line in Odoo. + - The invoice total (including tax) reflects tax on the **entire** + document, matching single-sale / working-unit requirements. + +* When the option is **disabled**, the standard behaviour from + ``account_avatax_oca`` is preserved: AvaTax receives one line per Odoo line. diff --git a/account_avatax_oca_line_grouping/readme/INSTALL.rst b/account_avatax_oca_line_grouping/readme/INSTALL.rst new file mode 100644 index 000000000..ac70f5ca7 --- /dev/null +++ b/account_avatax_oca_line_grouping/readme/INSTALL.rst @@ -0,0 +1,49 @@ +This addon is an extension of the AvaTax connector provided by +``account_avatax_oca``. It does **not** replace the base connector and +depends on it being correctly installed and configured first. + +Python dependency +----------------- + +The same Python client library used by ``account_avatax_oca`` is required: + +- Avalara Python client: https://pypi.org/project/Avalara + +If you already installed it for the base module, you do not need to do +anything else. Otherwise, install it with ``pip`` in your Odoo +environment:: + + pip3 install Avalara + +Module dependencies +------------------- + +This module depends on: + +- ``account_avatax_oca`` (AvaTax support for Customer Invoices) +- ``account_avatax_sale_oca`` (AvaTax support for Quotations / Sales Orders) + +Make sure both are available and installed in your Odoo instance +(or at least ``account_avatax_oca`` if you only use Invoices). + +Install the addon +----------------- + +To install ``account_avatax_oca_line_grouping``: + +1. Clone or download the OCA *account-fiscal-rule* repository (or your fork) + that contains this addon. +2. Ensure the directory ``account_avatax_oca_line_grouping`` is in your + Odoo addons path. +3. Restart the Odoo server. +4. Log into Odoo as an Administrator and enable **Developer Mode** + in *Settings*. +5. Go to **Apps**, click **Update Apps List** so that Odoo detects + the new addon. +6. Search for **AvaTax Line Grouping** or ``account_avatax_oca_line_grouping``. +7. Click **Install**. + +After installation, you can enable the line-grouping behaviour in: + +- **Accounting/Invoicing >> Configuration >> AvaTax >> AvaTax API** + (see the configuration guide for the *Group document lines for AvaTax* option). diff --git a/account_avatax_oca_line_grouping/readme/ROADMAP.rst b/account_avatax_oca_line_grouping/readme/ROADMAP.rst new file mode 100644 index 000000000..b9580fde4 --- /dev/null +++ b/account_avatax_oca_line_grouping/readme/ROADMAP.rst @@ -0,0 +1,57 @@ +The main goal of this addon is to support **single-sale / bulk / working-unit** +tax scenarios by grouping all document lines into a single line for AvaTax. + +The current implementation provides a simple, company-wide switch: +when enabled, all Sales Orders and Customer Invoices for that company +are sent to AvaTax as a single aggregated line. + +Possible future improvements +---------------------------- + +The following enhancements are candidates for future versions: + +- **Per-fiscal-position control** + + Allow enabling line grouping per *Fiscal Position* instead of (or in + addition to) a global company flag. + Example: only apply grouping when a dedicated “Single Sale / Bulk” + fiscal position is used. + +- **Per-document or per-partner control** + + Add an option on Sales Orders / Invoices and/or on partners to override + the company setting, so that grouping can be enabled/disabled on a + case-by-case basis. + +- **Grouping strategies** + + Support different grouping strategies, such as: + + - Group only lines that share the same product tax code. + - Group only lines marked with a specific tag or analytic account. + - Allow multiple aggregated lines per document (one per group), instead + of a single line per document. + +- **Validation and warnings** + + Add checks and warnings when line grouping is enabled but: + + - Lines have heterogeneous tax codes that may lead to inaccurate + results in AvaTax. + - The document does not match the expected “single sale” / “working + unit” business rules defined by the company. + +- **Extended jurisdiction coverage** + + Document and test line grouping behaviour for additional jurisdictions + and tax rules where a cap or special treatment applies to a total + transaction amount (beyond the initial US use cases). + +- **Monitoring and logging** + + Improve logging and diagnostics for grouped transactions, making it + easier for users and accountants to review: + + - The original line breakdown in Odoo. + - The single-line payload sent to AvaTax. + - The returned tax amounts and how they are mapped back to Odoo lines. diff --git a/account_avatax_oca_line_grouping/readme/USAGE.rst b/account_avatax_oca_line_grouping/readme/USAGE.rst new file mode 100644 index 000000000..3350cf47b --- /dev/null +++ b/account_avatax_oca_line_grouping/readme/USAGE.rst @@ -0,0 +1,144 @@ +This addon does not change **how** you create or validate +Sales Orders and Customer Invoices in Odoo. +It only changes **what is sent to AvaTax** when +*Group document lines for AvaTax* is enabled. + +Prerequisites +------------- + +- ``account_avatax_oca`` (and optionally ``account_avatax_sale_oca``) + are installed and correctly configured. +- Fiscal Positions, AvaTax API connection, Products and Customers + are already working with AvaTax in the standard way. +- The company option **Group document lines for AvaTax** is enabled + in **Accounting/Invoicing >> Configuration >> AvaTax >> AvaTax API**. + +Customer Invoices (grouped) +--------------------------- + +When **Group document lines for AvaTax** is enabled, customer invoices +still follow the standard AvaTax flow, but the **payload** sent to AvaTax +is different. + +- You create an invoice as usual in: + + - **Accounting / Invoicing >> Customers >> Invoices** + +- You add as many lines as needed (products, quantities, discounts). +- You ensure the fiscal position and AvaTax tax are correctly set + (same as with the base connector). +- When you click **Validate**: + + - The connector computes taxes through AvaTax. + - Instead of sending one line per invoice line, it sends a **single** + aggregated line, representing the **total untaxed amount** of + the invoice. + - AvaTax returns tax for that single line, and the connector maps + the resulting tax amount back to the **first invoice line**. + +Effects in Odoo +--------------- + +- The **invoice total** (tax included) reflects tax calculated on the + **entire document**, rather than per line. +- The **first invoice line** will show the AvaTax tax result + (tax lines are attached to that line). +- Remaining invoice lines do not carry separate tax amounts, but the + overall accounting is correct. + +Effects in AvaTax +------------------ + +In the AvaTax transaction log: + +- You will see **one line** for the invoice with ``quantity = 1`` and + ``amount = total untaxed amount`` of the invoice, using the item code / + tax code of the first invoice line’s product (or a fallback code if no + product is set). +- The transaction status (Uncommitted / Committed / Voided) behaves as + usual and is controlled by the base connector. + + +Refunds and credit notes +-------------------------------- + +Customer refunds (credit notes) behave like invoices: + +- If line grouping is enabled, the refund is also sent as a **single** + aggregated line with a negative amount. +- AvaTax will show a transaction with one line and a negative total, + consistent with standard refund behaviour, but using the grouped base. + +Sales Orders (grouped) +---------------------- + +When using ``account_avatax_sale_oca`` together with this module, +Sales Orders can also use grouped tax computation. + +- You create a Sales Order in: + + - **Sales >> Orders >> Orders** + +- You add multiple lines (products, quantities, discounts). +- When you: + + - Confirm the order, or + - Use **Action >> Update taxes with AvaTax** + + the connector: + + - Aggregates all order lines into a **single** virtual line. + - Sends one line to AvaTax with: + + - ``qty = 1`` + - ``amount = total untaxed amount`` of the order + - Combined discount amount if any lines have discounts. + - Retrieves the tax amount and updates the order accordingly. + +- As with the base module, Sales Orders are typically **not** recorded + as committed transactions in the AvaTax dashboard; they are used to + estimate tax, and the real transaction is created on invoice. + +Effects in Odoo + + +- From the user perspective, creating and managing Sales Orders does + not change. +- Tax amounts on the order are computed on the **total** of all lines, + rather than line by line, when grouping is active. + +Effects in AvaTax +----------------- + +- The request sent to AvaTax during tax calculation is a single line + representing the full order. +- The tax result is then used in Odoo for that order’s totals. +- The behaviour of recording / not recording transactions in AvaTax + follows the logic of the base addon (orders vs invoices). + +Practical Example +----------------- + +1. Create a Sales Order with several lines that together form a + single project / working unit (e.g. all materials for one roof). +2. Ensure the company has **Group document lines for AvaTax** enabled. +3. Confirm the order or use **Update taxes with AvaTax**: + - Odoo shows a total tax based on the **entire order**. +4. Create and validate the Customer Invoice from that order: + - The invoice sends **one line** to AvaTax with the total base. + - AvaTax applies tax (and any applicable caps) on the total. + - In Odoo, the first invoice line carries the tax result. + +When NOT to Use Line Grouping +----------------------------- + +You should **not** enable the grouping option if: + +- Different lines need **different AvaTax product tax codes** + that must be distinguished by AvaTax. +- The invoice mixes goods and services that should be taxed + differently at the line level. +- Your tax advisor requires per-line tax visibility in AvaTax. + +In those cases, simply disable **Group document lines for AvaTax** and +the standard per-line behaviour from ``account_avatax_oca`` will apply. diff --git a/account_avatax_oca_line_grouping/security/ir.model.access.csv b/account_avatax_oca_line_grouping/security/ir.model.access.csv new file mode 100644 index 000000000..73130a150 --- /dev/null +++ b/account_avatax_oca_line_grouping/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_avalara_salestax_manager,avalara.salestax.manager,model_avalara_salestax,account.group_account_manager,1,1,1,1 diff --git a/account_avatax_oca_line_grouping/static/description/icon.png b/account_avatax_oca_line_grouping/static/description/icon.png new file mode 100755 index 000000000..6754ecdd1 Binary files /dev/null and b/account_avatax_oca_line_grouping/static/description/icon.png differ diff --git a/account_avatax_oca_line_grouping/static/description/index.html b/account_avatax_oca_line_grouping/static/description/index.html new file mode 100644 index 000000000..e06660641 --- /dev/null +++ b/account_avatax_oca_line_grouping/static/description/index.html @@ -0,0 +1,800 @@ + + + + + +Avalara Avatax Line Grouping + + + +
+

Avalara Avatax Line Grouping

+ + +

Beta License: AGPL-3 OCA/account-fiscal-rule Translate me on Weblate Try me on Runboat

+

This addon extends the AvaTax connector provided by +account_avatax_oca (OCA account-fiscal-rule repository).

+

It adds an optional feature to group all document lines into a single +line when sending data to AvaTax, so that tax is computed on the +total document amount instead of line by line.

+

This behaviour is useful in jurisdictions where multiple items sold +together are legally treated as a single sale or a working unit +for tax purposes (for example, some discretionary surtax cap rules +on bulk/material or project invoices).

+
+

Main Features

+
    +
  • Optional company-level setting: Group document lines for AvaTax.
  • +
  • When enabled:
      +
    • For Sales Orders:
        +
      • All sale.order.line records are aggregated.
      • +
      • AvaTax receives one line with:
          +
        • qty = 1
        • +
        • amount = total untaxed amount of all lines
        • +
        • itemCode and taxCode taken from the first line’s product +(or a safe fallback if no product is set).
        • +
        • A combined discount amount if any line has a discount.
        • +
        +
      • +
      +
    • +
    • For Customer Invoices (account.move):
        +
      • All invoice lines are aggregated in the same way.
      • +
      • Only the first invoice line generates an AvaTax payload line.
      • +
      • AvaTax tax result is applied to that first line in Odoo.
      • +
      • The invoice total (including tax) reflects tax on the entire +document, matching single-sale / working-unit requirements.
      • +
      +
    • +
    +
  • +
  • When the option is disabled, the standard behaviour from +account_avatax_oca is preserved: AvaTax receives one line per Odoo line.
  • +
+

Table of contents

+
+ +
+
+

Installation

+

This addon is an extension of the AvaTax connector provided by +account_avatax_oca. It does not replace the base connector and +depends on it being correctly installed and configured first.

+
+
+
+

Python dependency

+

The same Python client library used by account_avatax_oca is required:

+ +

If you already installed it for the base module, you do not need to do +anything else. Otherwise, install it with pip in your Odoo +environment:

+
+pip3 install Avalara
+
+
+
+

Module dependencies

+

This module depends on:

+
    +
  • account_avatax_oca (AvaTax support for Customer Invoices)
  • +
  • account_avatax_sale_oca (AvaTax support for Quotations / Sales Orders)
  • +
+

Make sure both are available and installed in your Odoo instance +(or at least account_avatax_oca if you only use Invoices).

+
+
+

Install the addon

+

To install account_avatax_oca_line_grouping:

+
    +
  1. Clone or download the OCA account-fiscal-rule repository (or your fork) +that contains this addon.
  2. +
  3. Ensure the directory account_avatax_oca_line_grouping is in your +Odoo addons path.
  4. +
  5. Restart the Odoo server.
  6. +
  7. Log into Odoo as an Administrator and enable Developer Mode +in Settings.
  8. +
  9. Go to Apps, click Update Apps List so that Odoo detects +the new addon.
  10. +
  11. Search for AvaTax Line Grouping or account_avatax_oca_line_grouping.
  12. +
  13. Click Install.
  14. +
+

After installation, you can enable the line-grouping behaviour in:

+
    +
  • Accounting/Invoicing >> Configuration >> AvaTax >> AvaTax API +(see the configuration guide for the Group document lines for AvaTax option).
  • +
+
+

Configuration

+

This addon extends the AvaTax connector provided by account_avatax_oca +and allows grouping all document lines (Sales Orders and Customer Invoices) +into a single line when sending data to AvaTax.

+

This behaviour is useful for “single sale” or bulk / working-unit scenarios, +for example where local rules require applying surtax caps on the total +of the invoice instead of per line.

+
+
+
+

Prerequisites

+

Before using this module, you must:

+
    +
  • Install and configure account_avatax_oca (and account_avatax_sale_oca).
  • +
  • Follow the configuration steps described in the CONFIGURE.rst of +account_avatax_oca (AvaTax API connection, company taxes, customers, +products, fiscal positions, etc.).
  • +
+

Once the base connector is working and taxes are correctly retrieved from AvaTax, +you can enable line grouping as described below.

+
+
+

Enable Line Grouping

+
    +
  1. Go to: Accounting/Invoicing App >> Configuration >> AvaTax >> AvaTax API.
  2. +
  3. Open the AvaTax configuration used by your company.
  4. +
  5. In the Tax Calculation (or equivalent) tab, enable:
      +
    • Group document lines for AvaTax
    • +
    +
  6. +
  7. Save the configuration.
  8. +
+
+
+

Functional Behaviour

+

When Group document lines for AvaTax is enabled on the company:

+

For Sales Orders:

+
+
    +
  • All sale.order.line records of the order are aggregated.
  • +
  • The connector sends a single line to AvaTax with qty = 1 and +amount = total untaxed amount of all lines (including discounts, +using the same base as the standard AvaTax computation), and using +itemCode and taxCode taken from the first order line’s product +(or a fallback identifier if no product is set).
  • +
  • A combined discount amount is used if any line has a discount.
  • +
+
+
    +
  • For Customer Invoices (account.move):
      +
    • All invoice lines are aggregated in the same way.
    • +
    • Only the first invoice line produces an AvaTax payload entry; +the remaining lines are not sent individually.
    • +
    • AvaTax returns tax values for that single line, and the connector +applies the resulting tax amount to that first line in Odoo.
    • +
    • The invoice total (including tax) reflects tax computed on the entire document, +which is required in certain single-sale / bulk / working-unit scenarios.
    • +
    +
  • +
+

When the option is disabled, the standard behaviour from +account_avatax_oca is used: AvaTax receives one line per Odoo line, +and taxes are calculated line by line.

+
+
+

Usage Notes and Caveats

+
    +
  • Line grouping changes how the tax base is presented to AvaTax +and can significantly affect calculated taxes and possible surtax caps.
  • +
  • It should only be enabled when the entire document is legally treated as +a single sale or as a working unit (e.g. certain construction +or bulk material contracts).
  • +
  • If your invoices contain lines with different taxability types or +different product tax codes that must be distinguished by AvaTax, +you should not enable line grouping.
  • +
  • Always consult with your tax advisor before enabling this option +in a production environment.
  • +
+
+

Usage

+

This addon does not change how you create or validate +Sales Orders and Customer Invoices in Odoo. +It only changes what is sent to AvaTax when +Group document lines for AvaTax is enabled.

+
+
+
+

Prerequisites

+
    +
  • account_avatax_oca (and optionally account_avatax_sale_oca) +are installed and correctly configured.
  • +
  • Fiscal Positions, AvaTax API connection, Products and Customers +are already working with AvaTax in the standard way.
  • +
  • The company option Group document lines for AvaTax is enabled +in Accounting/Invoicing >> Configuration >> AvaTax >> AvaTax API.
  • +
+
+
+

Customer Invoices (grouped)

+

When Group document lines for AvaTax is enabled, customer invoices +still follow the standard AvaTax flow, but the payload sent to AvaTax +is different.

+
    +
  • You create an invoice as usual in:
      +
    • Accounting / Invoicing >> Customers >> Invoices
    • +
    +
  • +
  • You add as many lines as needed (products, quantities, discounts).
  • +
  • You ensure the fiscal position and AvaTax tax are correctly set +(same as with the base connector).
  • +
  • When you click Validate:
      +
    • The connector computes taxes through AvaTax.
    • +
    • Instead of sending one line per invoice line, it sends a single +aggregated line, representing the total untaxed amount of +the invoice.
    • +
    • AvaTax returns tax for that single line, and the connector maps +the resulting tax amount back to the first invoice line.
    • +
    +
  • +
+
+
+

Effects in Odoo

+
    +
  • The invoice total (tax included) reflects tax calculated on the +entire document, rather than per line.
  • +
  • The first invoice line will show the AvaTax tax result +(tax lines are attached to that line).
  • +
  • Remaining invoice lines do not carry separate tax amounts, but the +overall accounting is correct.
  • +
+
+
+

Effects in AvaTax

+

In the AvaTax transaction log:

+
    +
  • You will see one line for the invoice with quantity = 1 and +amount = total untaxed amount of the invoice, using the item code / +tax code of the first invoice line’s product (or a fallback code if no +product is set).
  • +
  • The transaction status (Uncommitted / Committed / Voided) behaves as +usual and is controlled by the base connector.
  • +
+
+
+

Refunds and credit notes

+

Customer refunds (credit notes) behave like invoices:

+
    +
  • If line grouping is enabled, the refund is also sent as a single +aggregated line with a negative amount.
  • +
  • AvaTax will show a transaction with one line and a negative total, +consistent with standard refund behaviour, but using the grouped base.
  • +
+
+
+

Sales Orders (grouped)

+

When using account_avatax_sale_oca together with this module, +Sales Orders can also use grouped tax computation.

+
    +
  • You create a Sales Order in:

    +
      +
    • Sales >> Orders >> Orders
    • +
    +
  • +
  • You add multiple lines (products, quantities, discounts).

    +
  • +
  • When you:

    +
      +
    • Confirm the order, or
    • +
    • Use Action >> Update taxes with AvaTax
    • +
    +

    the connector:

    +
      +
    • Aggregates all order lines into a single virtual line.
    • +
    • Sends one line to AvaTax with:
        +
      • qty = 1
      • +
      • amount = total untaxed amount of the order
      • +
      • Combined discount amount if any lines have discounts.
      • +
      +
    • +
    • Retrieves the tax amount and updates the order accordingly.
    • +
    +
  • +
  • As with the base module, Sales Orders are typically not recorded +as committed transactions in the AvaTax dashboard; they are used to +estimate tax, and the real transaction is created on invoice.

    +
  • +
+

Effects in Odoo

+
    +
  • From the user perspective, creating and managing Sales Orders does +not change.
  • +
  • Tax amounts on the order are computed on the total of all lines, +rather than line by line, when grouping is active.
  • +
+
+
+

Effects in AvaTax

+
    +
  • The request sent to AvaTax during tax calculation is a single line +representing the full order.
  • +
  • The tax result is then used in Odoo for that order’s totals.
  • +
  • The behaviour of recording / not recording transactions in AvaTax +follows the logic of the base addon (orders vs invoices).
  • +
+
+
+

Practical Example

+
    +
  1. Create a Sales Order with several lines that together form a +single project / working unit (e.g. all materials for one roof).
  2. +
  3. Ensure the company has Group document lines for AvaTax enabled.
  4. +
  5. Confirm the order or use Update taxes with AvaTax: +- Odoo shows a total tax based on the entire order.
  6. +
  7. Create and validate the Customer Invoice from that order: +- The invoice sends one line to AvaTax with the total base. +- AvaTax applies tax (and any applicable caps) on the total. +- In Odoo, the first invoice line carries the tax result.
  8. +
+
+
+

When NOT to Use Line Grouping

+

You should not enable the grouping option if:

+
    +
  • Different lines need different AvaTax product tax codes +that must be distinguished by AvaTax.
  • +
  • The invoice mixes goods and services that should be taxed +differently at the line level.
  • +
  • Your tax advisor requires per-line tax visibility in AvaTax.
  • +
+

In those cases, simply disable Group document lines for AvaTax and +the standard per-line behaviour from account_avatax_oca will apply.

+
+

Known issues / Roadmap

+

The main goal of this addon is to support single-sale / bulk / working-unit +tax scenarios by grouping all document lines into a single line for AvaTax.

+

The current implementation provides a simple, company-wide switch: +when enabled, all Sales Orders and Customer Invoices for that company +are sent to AvaTax as a single aggregated line.

+
+
+
+

Possible future improvements

+

The following enhancements are candidates for future versions:

+
    +
  • Per-fiscal-position control

    +

    Allow enabling line grouping per Fiscal Position instead of (or in +addition to) a global company flag. +Example: only apply grouping when a dedicated “Single Sale / Bulk” +fiscal position is used.

    +
  • +
  • Per-document or per-partner control

    +

    Add an option on Sales Orders / Invoices and/or on partners to override +the company setting, so that grouping can be enabled/disabled on a +case-by-case basis.

    +
  • +
  • Grouping strategies

    +

    Support different grouping strategies, such as:

    +
      +
    • Group only lines that share the same product tax code.
    • +
    • Group only lines marked with a specific tag or analytic account.
    • +
    • Allow multiple aggregated lines per document (one per group), instead +of a single line per document.
    • +
    +
  • +
  • Validation and warnings

    +

    Add checks and warnings when line grouping is enabled but:

    +
      +
    • Lines have heterogeneous tax codes that may lead to inaccurate +results in AvaTax.
    • +
    • The document does not match the expected “single sale” / “working +unit” business rules defined by the company.
    • +
    +
  • +
  • Extended jurisdiction coverage

    +

    Document and test line grouping behaviour for additional jurisdictions +and tax rules where a cap or special treatment applies to a total +transaction amount (beyond the initial US use cases).

    +
  • +
  • Monitoring and logging

    +

    Improve logging and diagnostics for grouped transactions, making it +easier for users and accountants to review:

    +
      +
    • The original line breakdown in Odoo.
    • +
    • The single-line payload sent to AvaTax.
    • +
    • The returned tax amounts and how they are mapped back to Odoo lines.
    • +
    +
  • +
+
+

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

+
    +
  • Binhex
  • +
+
+
+

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.

+

This module is part of the OCA/account-fiscal-rule project on GitHub.

+

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

+
+
+
+
+ + diff --git a/account_avatax_oca_line_grouping/tests/__init__.py b/account_avatax_oca_line_grouping/tests/__init__.py new file mode 100644 index 000000000..1f0622615 --- /dev/null +++ b/account_avatax_oca_line_grouping/tests/__init__.py @@ -0,0 +1 @@ +from . import test_avatax_grouping diff --git a/account_avatax_oca_line_grouping/tests/test_avatax_grouping.py b/account_avatax_oca_line_grouping/tests/test_avatax_grouping.py new file mode 100644 index 000000000..5eab5c432 --- /dev/null +++ b/account_avatax_oca_line_grouping/tests/test_avatax_grouping.py @@ -0,0 +1,349 @@ +# Copyright 2025 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0.html). + +from unittest.mock import patch + +from odoo.tests import common + + +class TestAvataxLineGrouping(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + + cls.avatax_config = cls.env["avalara.salestax"].create( + { + "company_id": cls.company.id, + "account_number": "123456789", + "license_key": "dummy-key", + "company_code": "DEFAULT", + "disable_tax_calculation": False, + "invoice_calculate_tax": False, + } + ) + + cls.partner = cls.env["res.partner"].create({"name": "Test Customer"}) + + cls.fiscal_position = cls.env["account.fiscal.position"].create( + {"name": "Avatax FP", "is_avatax": True} + ) + cls.partner.property_account_position_id = cls.fiscal_position + + cls.product_1 = cls.env["product.product"].create( + {"name": "Prod 1", "list_price": 100.0} + ) + cls.product_2 = cls.env["product.product"].create( + {"name": "Prod 2", "list_price": 50.0} + ) + + # Simple income account for invoice lines + cls.income_account = cls.env["account.account"].search( + [ + ("account_type", "=", "income"), + ("company_id", "=", cls.company.id), + ("deprecated", "=", False), + ], + limit=1, + ) + if not cls.income_account: + cls.income_account = cls.env["account.account"].create( + { + "name": "Avatax Income", + "code": "AVA999", + "account_type": "income", + "company_id": cls.company.id, + } + ) + + # Basic pricelist for sale orders + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Avatax Test Pricelist", + "currency_id": cls.company.currency_id.id, + } + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _create_invoice_two_lines(self): + """Create a test customer invoice with two lines.""" + move = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.partner.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "product_id": self.product_1.id, + "price_unit": 100.0, + "quantity": 2.0, + "account_id": self.income_account.id, + }, + ), + ( + 0, + 0, + { + "name": "Line 2", + "product_id": self.product_2.id, + "price_unit": 50.0, + "quantity": 3.0, + "account_id": self.income_account.id, + }, + ), + ], + } + ) + return move + + def _create_sale_order_two_lines(self): + """Create a test sale order with two lines.""" + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "pricelist_id": self.pricelist.id, + "order_line": [ + ( + 0, + 0, + { + "name": "SO Line 1", + "product_id": self.product_1.id, + "product_uom_qty": 2.0, + "price_unit": 100.0, + }, + ), + ( + 0, + 0, + { + "name": "SO Line 2", + "product_id": self.product_2.id, + "product_uom_qty": 3.0, + "price_unit": 50.0, + }, + ), + ], + } + ) + return order + + # ------------------------------------------------------------------ + # Tests for account.move.line._avatax_prepare_line + # ------------------------------------------------------------------ + def test_no_grouping_behaviour(self): + """With avatax_group_lines = False, each line prepares its own dict.""" + self.company.avatax_group_lines = False + self.avatax_config.avatax_group_lines = False + + invoice = self._create_invoice_two_lines() + line1, line2 = invoice.invoice_line_ids + + res1 = line1._avatax_prepare_line(sign=1, doc_type="SalesInvoice") + res2 = line2._avatax_prepare_line(sign=1, doc_type="SalesInvoice") + + self.assertTrue( + res1, + "First line should return a dict when grouping is disabled", + ) + self.assertTrue( + res2, + "Second line should return a dict when grouping is disabled", + ) + self.assertNotEqual( + res1, + res2, + "Each line should have its own payload when grouping is disabled", + ) + + def test_grouping_behaviour(self): + """With avatax_group_lines = True only the first line returns aggregate.""" + self.company.avatax_group_lines = True + self.avatax_config.avatax_group_lines = True + + invoice = self._create_invoice_two_lines() + line1, line2 = invoice.invoice_line_ids + + total_expected = line1._get_avatax_amount() + line2._get_avatax_amount() + + res1 = line1._avatax_prepare_line(sign=1, doc_type="SalesInvoice") + res2 = line2._avatax_prepare_line(sign=1, doc_type="SalesInvoice") + + self.assertTrue(res1, "First line should return an aggregated dict") + self.assertEqual( + res1.get("qty"), + 1.0, + "Aggregated line should use qty = 1", + ) + self.assertAlmostEqual( + res1.get("amount", 0.0), + total_expected, + places=2, + msg="Aggregated amount should be the sum of all lines' Avatax base", + ) + self.assertEqual( + res2, + {}, + "Non-first lines must return empty dict when grouping is enabled", + ) + + def test_grouping_with_negative_quantity(self): + """Grouping logic must handle negative quantities by using absolute amounts.""" + self.company.avatax_group_lines = True + self.avatax_config.avatax_group_lines = True + + invoice = self._create_invoice_two_lines() + line1, line2 = invoice.invoice_line_ids + # Force a negative quantity on the second line + line2.quantity = -3.0 + + res1 = line1._avatax_prepare_line(sign=1, doc_type="SalesInvoice") + res2 = line2._avatax_prepare_line(sign=1, doc_type="SalesInvoice") + + expected_amount = abs(line1._get_avatax_amount()) + abs( + line2._get_avatax_amount() + ) + + self.assertTrue(res1) + self.assertAlmostEqual(res1["amount"], expected_amount, places=2) + self.assertEqual(res2, {}) + + # ------------------------------------------------------------------ + # Tests for account.move._avatax_compute_tax + # ------------------------------------------------------------------ + @patch( + "odoo.addons.account_avatax_oca.models.res_company.Company.get_avatax_config_company" + ) + @patch( + "odoo.addons.account_avatax_oca.models.avalara_salestax.AvalaraSalestax.create_transaction" # noqa: B950 + ) + def test_invoice_compute_tax_grouping( + self, mock_create_transaction, mock_get_avatax_config_company + ): + """When Avatax returns a single line, total tax should be stored on the move.""" + self.company.avatax_group_lines = True + self.avatax_config.avatax_group_lines = True + + invoice = self._create_invoice_two_lines() + line1, line2 = invoice.invoice_line_ids + + base1 = line1._get_avatax_amount() + base2 = line2._get_avatax_amount() + total_base = base1 + base2 + total_tax = 10.0 + + mock_get_avatax_config_company.return_value = self.avatax_config + # Only the length of tax_result_lines matters for the grouping branch + mock_create_transaction.return_value = { + "totalTax": total_tax, + "lines": [ + { + "lineNumber": "1", + "taxCalculated": total_tax, + "taxableAmount": total_base, + "tax": total_tax, + } + ], + } + + res = invoice._avatax_compute_tax(commit=False) + + self.assertEqual(res["totalTax"], total_tax) + # The move must store the total Avatax amount coming from Avalara + self.assertAlmostEqual(invoice.avatax_amount, total_tax, places=2) + + @patch( + "odoo.addons.account_avatax_oca.models.res_company.Company.get_avatax_config_company" + ) + @patch( + "odoo.addons.account_avatax_oca.models.avalara_salestax.AvalaraSalestax.create_transaction" # noqa: B950 + ) + def test_invoice_compute_tax_no_grouping( + self, mock_create_transaction, mock_get_avatax_config_company + ): + """Without grouping, each line uses its own Avatax result.""" + self.company.avatax_group_lines = False + self.avatax_config.avatax_group_lines = False + + invoice = self._create_invoice_two_lines() + line1, line2 = invoice.invoice_line_ids + + mock_get_avatax_config_company.return_value = self.avatax_config + mock_create_transaction.return_value = { + "totalTax": 15.0, + "lines": [ + { + "lineNumber": str(line1.id), + "taxCalculated": 10.0, + "taxableAmount": line1._get_avatax_amount(), + "tax": 10.0, + }, + { + "lineNumber": str(line2.id), + "taxCalculated": 5.0, + "taxableAmount": line2._get_avatax_amount(), + "tax": 5.0, + }, + ], + } + + res = invoice._avatax_compute_tax(commit=False) + + self.assertEqual(res["totalTax"], 15.0) + self.assertAlmostEqual(line1.avatax_amt_line, 10.0, places=2) + self.assertAlmostEqual(line2.avatax_amt_line, 5.0, places=2) + self.assertAlmostEqual(invoice.avatax_amount, 15.0, places=2) + + # ------------------------------------------------------------------ + # Tests for sale.order._avatax_compute_tax + # ------------------------------------------------------------------ + @patch( + "odoo.addons.account_avatax_oca.models.res_company.Company.get_avatax_config_company" + ) + @patch( + "odoo.addons.account_avatax_oca.models.avalara_salestax.AvalaraSalestax.create_transaction" # noqa: B950 + ) + def test_sale_order_compute_tax_grouping( + self, mock_create_transaction, mock_get_avatax_config_company + ): + """On SO, single Avatax line must be reflected in tax_amount.""" + self.company.avatax_group_lines = True + self.avatax_config.avatax_group_lines = True + + order = self._create_sale_order_two_lines() + line1, line2 = order.order_line + + def _line_base(line): + return ( + line.price_unit + * line.product_uom_qty + * (1 - (line.discount or 0.0) / 100.0) + ) + + base1 = _line_base(line1) + base2 = _line_base(line2) + total_base = base1 + base2 + total_tax = 7.0 + + mock_get_avatax_config_company.return_value = self.avatax_config + mock_create_transaction.return_value = { + "totalTax": total_tax, + "lines": [ + { + "lineNumber": "1", + "taxCalculated": total_tax, + "taxableAmount": total_base, + "tax": total_tax, + } + ], + } + + res = order._avatax_compute_tax() + + self.assertTrue(res) + self.assertAlmostEqual(order.tax_amount, total_tax, places=2) diff --git a/account_avatax_oca_line_grouping/views/avalara_salestax_view.xml b/account_avatax_oca_line_grouping/views/avalara_salestax_view.xml new file mode 100644 index 000000000..a2f590efb --- /dev/null +++ b/account_avatax_oca_line_grouping/views/avalara_salestax_view.xml @@ -0,0 +1,13 @@ + + + + avalara.salestax.form.avatax.group.lines + avalara.salestax + + + + + + + + diff --git a/setup/account_avatax_oca_line_grouping/odoo/addons/account_avatax_oca_line_grouping b/setup/account_avatax_oca_line_grouping/odoo/addons/account_avatax_oca_line_grouping new file mode 120000 index 000000000..2470f813f --- /dev/null +++ b/setup/account_avatax_oca_line_grouping/odoo/addons/account_avatax_oca_line_grouping @@ -0,0 +1 @@ +../../../../account_avatax_oca_line_grouping \ No newline at end of file diff --git a/setup/account_avatax_oca_line_grouping/setup.py b/setup/account_avatax_oca_line_grouping/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/account_avatax_oca_line_grouping/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)