diff --git a/account_avatax_oca/data/avalara_salestax_data.xml b/account_avatax_oca/data/avalara_salestax_data.xml index 8645f89ea..50e8df5e8 100644 --- a/account_avatax_oca/data/avalara_salestax_data.xml +++ b/account_avatax_oca/data/avalara_salestax_data.xml @@ -7,6 +7,7 @@ AvaTax + diff --git a/account_avatax_oca/models/account_move.py b/account_avatax_oca/models/account_move.py index 52996c5ce..77092901d 100644 --- a/account_avatax_oca/models/account_move.py +++ b/account_avatax_oca/models/account_move.py @@ -2,7 +2,6 @@ from odoo import api, fields, models from odoo.exceptions import UserError -from odoo.tools import float_compare _logger = logging.getLogger(__name__) @@ -116,14 +115,13 @@ def onchange_warehouse_id(self): @api.model @api.depends("company_id") def _compute_hide_exemption(self): - avatax_config = self.env.company.get_avatax_config_company() for inv in self: + avatax_config = inv.company_id.avatax_configuration_id inv.hide_exemption = avatax_config.hide_exemption hide_exemption = fields.Boolean( "Hide Exemption & Tax Based on shipping address", compute=_compute_hide_exemption, # For past transactions visibility - default=lambda self: self.env.company.get_avatax_config_company, help="Uncheck the this field to show exemption fields on SO/Invoice form view. " "Also, it will show Tax based on shipping address button", ) @@ -164,7 +162,7 @@ def get_origin_tax_date(self): # Same as v12 def _get_avatax_doc_type(self, commit=True): self.ensure_one() - avatax_config = self.company_id.get_avatax_config_company() + avatax_config = self.company_id.avatax_configuration_id if avatax_config.disable_tax_reporting: commit = False if "refund" in self.move_type: @@ -187,15 +185,15 @@ def _avatax_prepare_lines(self, doc_type=None): ] return [x for x in lines if x] - # Same as v12 def _avatax_compute_tax(self, commit=False): """Contact REST API and recompute taxes for a Sale Order""" # Override to handle lines with split taxes (e.g. TN) self and self.ensure_one() - avatax_config = self.company_id.get_avatax_config_company() - if not avatax_config: + avatax_config = self.company_id.avatax_configuration_id + if not (avatax_config and self.state == "draft"): # 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) @@ -236,69 +234,15 @@ def _avatax_compute_tax(self, commit=False): 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 = {} - for line in self.invoice_line_ids: - tax_result_line = tax_result_lines.get(line.id) - if tax_result_line: - # rate = tax_result_line.get("rate", 0.0) - 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) - 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} - - # Set Taxes on lines in a way that properly triggers onchanges - # This same approach is also used by the official account_taxcloud connector - - # for index, taxes in taxes_to_set: - # # Access the invoice line by index - # line = self.invoice_line_ids[index] - # # Update the tax_ids field - # line.write({"tax_ids": [(6, 0, [tax.id for tax in taxes])]}) - - 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, line_id=line_id: 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}) - # After taxes are changed is needed to force compute taxes again, - # in 16 version change of tax doesn't trigger compute of taxes - # on header for unknown reason - 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}) + tax_result_lines = avatax_config.get_avatax_line_tax(tax_result) + for line in self.invoice_line_ids: + line_tax = tax_result_lines.get(line.id, {}) + tax = line_tax.get("tax_id") + if tax and tax not in line.tax_ids: + line.tax_ids = line.tax_ids.filtered(lambda x: not x.is_avatax) | tax + line.avatax_amt_line = line_tax.get("tax_amount", 0.0) + self.avatax_amount = tax_result.get("totalTax", 0.0) + return tax_result # Same as v13 @@ -318,7 +262,7 @@ def avatax_compute_taxes(self, commit=False): def avatax_commit_taxes(self): for invoice in self: - avatax_config = invoice.company_id.get_avatax_config_company() + avatax_config = invoice.company_id.avatax_configuration_id if not avatax_config.disable_tax_reporting: doc_type = invoice._get_avatax_doc_type() avatax_config.commit_transaction(invoice.name, doc_type) @@ -334,7 +278,7 @@ def is_avatax_calculated(self): def _post(self, soft=True): for invoice in self: if invoice.is_avatax_calculated(): - avatax_config = self.company_id.get_avatax_config_company() + avatax_config = invoice.company_id.avatax_configuration_id if avatax_config and avatax_config.force_address_validation: for addr in [self.partner_id, self.partner_shipping_id]: if not addr.date_validation: @@ -389,7 +333,7 @@ def button_draft(self): ) res = super().button_draft() for invoice in posted_invoices: - avatax_config = invoice.company_id.get_avatax_config_company() + avatax_config = invoice.company_id.avatax_configuration_id if avatax_config: doc_type = invoice._get_avatax_doc_type() avatax_config.void_transaction(invoice.name, doc_type) @@ -403,7 +347,7 @@ def button_draft(self): "partner_id", ) def onchange_avatax_calculation(self): - avatax_config = self.env.company.get_avatax_config_company() + avatax_config = self.company_id.avatax_configuration_id self.calculate_tax_on_save = False if avatax_config.invoice_calculate_tax: if ( @@ -425,29 +369,29 @@ def onchange_avatax_calculation(self): def write(self, vals): result = super().write(vals) - avatax_config = self.env.company.get_avatax_config_company() - for record in self: + for move in self: + avatax_config = move.company_id.avatax_configuration_id if ( avatax_config.invoice_calculate_tax - and record.calculate_tax_on_save - and record.state == "draft" - and not self._context.get("skip_second_write", False) + and move.calculate_tax_on_save + and move.state == "draft" + and not self.env.context.get("skip_second_write", False) ): - record.with_context(skip_second_write=True).write( + move.with_context(skip_second_write=True).write( {"calculate_tax_on_save": False} ) - record.avatax_compute_taxes() + move.avatax_compute_taxes() return result @api.model_create_multi def create(self, vals_list): moves = super().create(vals_list) - avatax_config = self.env.company.get_avatax_config_company() for move in moves: + avatax_config = move.company_id.avatax_configuration_id if ( avatax_config.invoice_calculate_tax and move.calculate_tax_on_save - and not self._context.get("skip_second_write", False) + and not self.env.context.get("skip_second_write", False) ): move.with_context(skip_second_write=True).write( {"calculate_tax_on_save": False} @@ -503,7 +447,7 @@ def _avatax_prepare_line(self, sign=1, doc_type=None): line = self res = {} # Add UPC to product item code - avatax_config = line.company_id.get_avatax_config_company() + avatax_config = line.company_id.avatax_configuration_id product = line.product_id if product.barcode and avatax_config.upc_enable: item_code = "UPC:%d" % product.barcode diff --git a/account_avatax_oca/models/avalara_salestax.py b/account_avatax_oca/models/avalara_salestax.py index 2b8a00105..227149f78 100644 --- a/account_avatax_oca/models/avalara_salestax.py +++ b/account_avatax_oca/models/avalara_salestax.py @@ -1,6 +1,6 @@ import logging -from odoo import api, fields, models +from odoo import _, api, fields, models from odoo.exceptions import UserError from .avatax_rest_api import AvaTaxRESTService @@ -157,13 +157,13 @@ def _get_avatax_supported_countries(self): _sql_constraints = [ ( "code_company_uniq", - "unique (company_code)", - "Avalara setting is already available for this company code", + "unique (company_id)", + _("Only one Avatax configuration per company allowed."), ), ( "account_number_company_uniq", "unique (account_number, company_id)", - "The account number must be unique per company!", + _("The account number must be unique per company!"), ), ] @@ -337,3 +337,25 @@ def ping(self): client = AvaTaxRESTService(config=self) client.ping() return True + + # + # AVATAX RESPONSE PARSING HELPERS + # + + def get_avatax_line_tax(self, avatax_response): + """ + Receives an Avatax API response JSON-like object + Returns a dict mapping line database IDs to the tax amount and ID: + {123: {"taxable": 200.0, "tax_amount": 20.0, "tax_id": account.tax(12)}, ...} + """ + res = {} + for line in avatax_response.get("lines", []): + taxable = line.get("taxableAmount", 0.0) + tax_amount = line.get("tax", 0.0) + rate = round(sum(x["rate"] for x in line.get("details", [])) * 100, 4) + real_rate = round(tax_amount * 100 / taxable if taxable else 0.0, 4) + Tax = self.env["account.tax"].sudo().with_company(self.company_id) + tax = Tax.get_avalara_tax(real_rate, display_rate=rate) + line_id = int(line["lineNumber"]) + res[line_id] = {"taxable": taxable, "tax_amount": tax_amount, "tax_id": tax} + return res diff --git a/account_avatax_oca/models/partner.py b/account_avatax_oca/models/partner.py index 6a837fbb4..8031e2abf 100644 --- a/account_avatax_oca/models/partner.py +++ b/account_avatax_oca/models/partner.py @@ -156,7 +156,9 @@ def get_valid_address_vals(self, validation_on_save=False): partner.display_name, ) return False - avatax_config = self.env.company.get_avatax_config_company() + company = self.company_id or self.env.company + avatax_config = company.avatax_configuration_id + # Skip automatic validation for countries not supported by Avatax supported_countries = [x.code for x in avatax_config.country_ids] country_code = partner.country_id.code @@ -213,8 +215,9 @@ def button_avatax_validate_address(self): @api.model_create_multi def create(self, vals_list): partners = super().create(vals_list) - avatax_config = self.env.company.get_avatax_config_company() for partner in partners: + company = partner.company_id or self.env.company + avatax_config = company.avatax_configuration_id # Auto populate customer code, if not provided if not partner.customer_code: partner.generate_cust_code() @@ -231,7 +234,8 @@ def write(self, vals): x in vals for x in address_fields ): partner = self.with_context(avatax_writing=True) - avatax_config = self.env.company.get_avatax_config_company() + company = partner.company_id or self.env.company + avatax_config = company.avatax_configuration_id if avatax_config.validation_on_save: partner.multi_address_validation(validation_on_save=True) partner.validated_on_save = True diff --git a/account_avatax_oca/models/res_company.py b/account_avatax_oca/models/res_company.py index 9127e3ac8..c41fd799b 100644 --- a/account_avatax_oca/models/res_company.py +++ b/account_avatax_oca/models/res_company.py @@ -1,29 +1,8 @@ -import logging - -from odoo import models - -_LOGGER = logging.getLogger(__name__) +from odoo import fields, models class Company(models.Model): _inherit = "res.company" - def get_avatax_config_company(self): - """Returns the AvaTax configuration for the Company""" - if self: - self.ensure_one() - AvataxConfig = self.env["avalara.salestax"] - res = AvataxConfig.search( - [("company_id", "=", self.id), ("disable_tax_calculation", "=", False)] - ) - if len(res) > 1: - _LOGGER.warning( - self.env._("Company %s has too many Avatax configurations!"), - self.display_name, - ) - if len(res) < 1: - _LOGGER.warning( - self.env._("Company %s has no Avatax configuration."), - self.display_name, - ) - return res and res[0] + # Although it is a One2many field, is is ensured to be one or zero records + avatax_configuration_id = fields.One2many("avalara.salestax", "company_id") diff --git a/account_avatax_oca/tests/test_avatax.py b/account_avatax_oca/tests/test_avatax.py index 01211b673..5b9485882 100644 --- a/account_avatax_oca/tests/test_avatax.py +++ b/account_avatax_oca/tests/test_avatax.py @@ -76,9 +76,6 @@ def test_101_moves_onchange(self): self.invoice.action_post() self.invoice.button_draft() - @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 ) @@ -89,7 +86,6 @@ def test_avatax_compute_tax( self, mock_void_transaction, mock_create_transaction, - mock_get_avatax_config_company, ): avatax_config = self.env["avalara.salestax"].create( { @@ -98,9 +94,9 @@ def test_avatax_compute_tax( "company_code": "DEFAULT2", "disable_tax_calculation": False, "invoice_calculate_tax": False, + "company_id": self.env.company.id, } ) - mock_get_avatax_config_company.return_value = avatax_config # Force empty taxes to check only avatax taxes self.invoice.invoice_line_ids.write( @@ -165,7 +161,6 @@ def test_avatax_compute_tax( self.invoice.amount_tax + self.invoice.amount_untaxed, self.invoice.amount_residual, ) - mock_get_avatax_config_company.assert_called() mock_create_transaction.assert_called() mock_void_transaction.return_value = {"status": "success"} diff --git a/account_avatax_sale_oca/models/sale_order.py b/account_avatax_sale_oca/models/sale_order.py index b0a916d23..d26166b07 100644 --- a/account_avatax_sale_oca/models/sale_order.py +++ b/account_avatax_sale_oca/models/sale_order.py @@ -1,5 +1,9 @@ +import logging + from odoo import api, fields, models +_logger = logging.getLogger(__name__) + class SaleOrder(models.Model): _inherit = "sale.order" @@ -16,15 +20,14 @@ def _compute_tax_totals(self): @api.model @api.depends("company_id", "partner_id", "partner_invoice_id", "state") def _compute_hide_exemption(self): - avatax_config = self.env.company.get_avatax_config_company() for order in self: + avatax_config = order.company_id.avatax_configuration_id order.hide_exemption = avatax_config.hide_exemption hide_exemption = fields.Boolean( "Hide Exemption & Tax Based on shipping address", compute="_compute_hide_exemption", # For past transactions visibility - default=lambda self: self.env.company.get_avatax_config_company, - help="Uncheck this field to show exemption fields on SO/Invoice form view. " + help="Uncheck the this field to show exemption fields on SO/Invoice form view. " "Also, it will show Tax based on shipping address button", ) tax_amount = fields.Monetary(string="AvaTax") @@ -172,16 +175,21 @@ def _avatax_prepare_lines(self, order_lines, doc_type=None): def _avatax_compute_tax(self): """Contact REST API and recompute taxes for a Sale Order""" - # Override to handle lines with split taxes (e.g. TN) 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 self.state == "cancel" or self.locked: + # Exit early if no tax is to be recalculated + _logger.warning( + "Order %s is locked or cancelled, can't recompute tax", self.name + ) + return False + + avatax_config = self.company_id.avatax_configuration_id if not avatax_config: return False partner = self.partner_id if avatax_config.use_partner_invoice_id: partner = self.partner_invoice_id + doc_type = self._get_avatax_doc_type() taxable_lines = self._avatax_prepare_lines(self.order_line) tax_result = avatax_config.create_transaction( self.date_order, @@ -197,30 +205,13 @@ def _avatax_compute_tax(self): currency_id=self.currency_id, log_to_record=self, ) - tax_result_lines = {int(x["lineNumber"]): x for x in tax_result["lines"]} + tax_result_lines = avatax_config.get_avatax_line_tax(tax_result) for line in self.order_line: - tax_result_line = tax_result_lines.get(line.id) - if tax_result_line: - # Should we check the rate with the tax amount? - # tax_amount = tax_result_line["taxCalculated"] - # rate = round(tax_amount / line.price_subtotal * 100, 2) - # rate = tax_result_line["rate"] - 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) - 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"] + line_tax = tax_result_lines.get(line.id, {}) + tax = line_tax.get("tax_id") + if tax and tax not in line.tax_id: + line.tax_id = line.tax_id.filtered(lambda x: not x.is_avatax) | tax + line.tax_amt = line_tax.get("tax_amount", 0.0) self.tax_amount = tax_result.get("totalTax") return True @@ -235,15 +226,16 @@ def avalara_compute_taxes(self): return True def action_confirm(self): - avatax_config = self.company_id.get_avatax_config_company() - if avatax_config and avatax_config.force_address_validation: - for addr in [self.partner_id, self.partner_shipping_id]: - if not addr.date_validation: - # The Confirm action will be interrupted - # if the address is not validated - return addr.button_avatax_validate_address() - if avatax_config: - self.avalara_compute_taxes() + for order in self: + avatax_config = order.company_id.avatax_configuration_id + if avatax_config and avatax_config.force_address_validation: + for addr in [order.partner_id, order.partner_shipping_id]: + if not addr.date_validation: + # The Confirm action will be interrupted + # if the address is not validated + return addr.button_avatax_validate_address() + if avatax_config: + order.avalara_compute_taxes() return super().action_confirm() @api.onchange( @@ -253,7 +245,7 @@ def action_confirm(self): "partner_id", ) def onchange_avatax_calculation(self): - avatax_config = self.env.company.get_avatax_config_company() + avatax_config = self.company_id.avatax_configuration_id self.calculate_tax_on_save = False if avatax_config.sale_calculate_tax: if ( @@ -276,37 +268,33 @@ def onchange_avatax_calculation(self): @api.model_create_multi def create(self, vals_list): sales = super().create(vals_list) - avatax_config = self.env.company.get_avatax_config_company() for sale in sales: + avatax_config = sale.company_id.avatax_configuration_id if ( avatax_config.sale_calculate_tax and sale.calculate_tax_on_save - and not self._context.get("skip_second_write", False) + and not self.env.context.get("skip_second_write", False) ): sale.with_context(skip_second_write=True).write( - { - "calculate_tax_on_save": False, - } + {"calculate_tax_on_save": False} ) sale.avalara_compute_taxes() return sales def write(self, vals): result = super().write(vals) - avatax_config = self.env.company.get_avatax_config_company() - for record in self: + for sale in self: + avatax_config = sale.company_id.avatax_configuration_id if ( avatax_config.sale_calculate_tax - and record.calculate_tax_on_save - and record.state != "done" - and not self._context.get("skip_second_write", False) + and sale.calculate_tax_on_save + and sale.state != "done" + and not self.env.context.get("skip_second_write", False) ): - record.with_context(skip_second_write=True).write( - { - "calculate_tax_on_save": False, - } + sale.with_context(skip_second_write=True).write( + {"calculate_tax_on_save": False} ) - record.avalara_compute_taxes() + sale.avalara_compute_taxes() return result @@ -323,7 +311,7 @@ def _avatax_prepare_line(self, sign=1, doc_type=None): line = self res = {} # Add UPC to product item code - avatax_config = line.company_id.get_avatax_config_company() + avatax_config = line.company_id.avatax_configuration_id product = line.product_id if product.barcode and avatax_config.upc_enable: item_code = "UPC:%d" % product.barcode diff --git a/account_avatax_sale_oca/views/sale_order_view.xml b/account_avatax_sale_oca/views/sale_order_view.xml index cbc213042..f7789d9ba 100644 --- a/account_avatax_sale_oca/views/sale_order_view.xml +++ b/account_avatax_sale_oca/views/sale_order_view.xml @@ -13,7 +13,7 @@ name="avalara_compute_taxes" type="object" string="Compute Taxes" - invisible="is_avatax == False or state in ('done', 'cancel')" + invisible="is_avatax == False or state in ('done', 'cancel') or locked" />