oca-financial/odoo-bringout-oca-account-fiscal-rule-account_avatax_oca/account_avatax_oca/models/account_move.py
2025-08-29 15:43:04 +02:00

532 lines
21 KiB
Python

import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
"""Inherit to implement the tax calculation using avatax API"""
_inherit = "account.move"
@api.depends("partner_shipping_id", "partner_id", "company_id")
def _compute_onchange_exemption(self):
"""
Set the exemption to use for the Invoice.
An exemption can be applied if
there an exemption for the delivery address Country + State
- Get the delivery address Country & State
- Search the invoiced commercial partner addresses
for an exemption in this Country & State
- In case there is a "country wide" exemption, use it.
Example:
- ACME company partner, USA CA, has exemption status
- ACME Invoicing address, USA CA, no exemption status
- ACME Delivery address, USA CA, no exemption status
Invoice to ACME Invoicing, Shipped to ACME Delivery with be exempt.
For this to work properly, the "exemption_lock" is no longer supported.
"""
for invoice in self.filtered(lambda x: x.state == "draft"):
invoice_partner = invoice.partner_id.commercial_partner_id
ship_to_address = (
hasattr(invoice, "partner_shipping_id")
and invoice.partner_shipping_id
or invoice_partner
)
# Find an exemption address matching the Country + State
# of the Delivery address
exemption_addresses = (
invoice_partner | invoice_partner.child_ids
).filtered("property_tax_exempt")
exemption_address_naive = exemption_addresses.filtered(
lambda a: a.country_id == ship_to_address.country_id
and (
a.state_id == ship_to_address.state_id
or invoice_partner.property_exemption_country_wide
)
)[:1]
# Force Company to get the correct values from the Property fields
exemption_address = exemption_address_naive.with_company(
invoice.company_id.id
)
invoice.exemption_code = exemption_address.property_exemption_number
invoice.exemption_code_id = exemption_address.property_exemption_code_id
@api.onchange("warehouse_id")
def onchange_warehouse_id(self):
if self.warehouse_id:
if self.warehouse_id.company_id:
self.company_id = self.warehouse_id.company_id
if self.warehouse_id.code:
self.location_code = self.warehouse_id.code
is_avatax = fields.Boolean(related="fiscal_position_id.is_avatax")
invoice_doc_no = fields.Char(
"Source/Ref Invoice No",
readonly=True,
states={"draft": [("readonly", False)]},
help="Reference of the invoice",
)
exemption_code = fields.Char(
"Exemption Number",
compute=_compute_onchange_exemption,
readonly=False, # New computed writeable fields
store=True,
help="It show the customer exemption number",
)
exemption_code_id = fields.Many2one(
"exemption.code",
"Exemption Code",
compute=_compute_onchange_exemption,
readonly=False, # New computed writeable fields
store=True,
help="It show the customer exemption code",
)
exemption_locked = fields.Boolean(
help="Exemption code won't be automatically changed, "
"for instance, when changing the Customer."
)
tax_on_shipping_address = fields.Boolean(
"Tax based on shipping address", default=True
)
tax_address_id = fields.Many2one(
"res.partner", "Tax Shipping Address", compute="_compute_tax_address_id"
)
location_code = fields.Char(readonly=True, states={"draft": [("readonly", False)]})
warehouse_id = fields.Many2one("stock.warehouse", "Warehouse")
avatax_amount = fields.Float(string="AvaTax", copy=False)
calculate_tax_on_save = fields.Boolean()
so_partner_id = fields.Many2one(comodel_name="res.partner", string="SO Partner")
avatax_request_log = fields.Text(
"Avatax API Request Log", readonly=True, copy=False
)
avatax_response_log = fields.Text(
"Avatax API Response Log", readonly=True, copy=False
)
@api.model
@api.depends("company_id")
def _compute_hide_exemption(self):
avatax_config = self.env.company.get_avatax_config_company()
for inv in self:
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",
)
@api.depends("tax_on_shipping_address", "partner_id", "partner_shipping_id")
def _compute_tax_address_id(self):
for invoice in self:
invoice.tax_address_id = (
invoice.partner_shipping_id
if invoice.tax_on_shipping_address
else invoice.partner_id
)
@api.onchange("tax_address_id", "fiscal_position_id")
def onchange_reset_avatax_amount(self):
"""
When changing quantities or prices, reset the Avatax computed amount.
The Odoo computed tax amount will then be shown, as a reference.
The Avatax amount will be recomputed upon document validation.
"""
for inv in self:
inv.avatax_amount = 0
for line in inv.invoice_line_ids:
line.avatax_amt_line = 0
# Same as v12
def get_origin_tax_date(self):
if self.invoice_doc_no:
orig_invoice = self.search(
[
("name", "=", self.invoice_doc_no),
("partner_id", "=", self.partner_id.id),
]
)
return orig_invoice.invoice_date
return False
# Same as v12
def _get_avatax_doc_type(self, commit=True):
self.ensure_one()
avatax_config = self.company_id.get_avatax_config_company()
if avatax_config.disable_tax_reporting:
commit = False
if "refund" in self.move_type:
doc_type = "ReturnInvoice" if commit else "ReturnOrder"
else:
doc_type = "SalesInvoice" if commit else "SalesOrder"
return doc_type
# Same as v12
def _avatax_prepare_lines(self, doc_type=None):
"""
Prepare the lines to use for Avatax computation.
Returns a list of dicts
"""
sign = 1 if self.move_type.startswith("out") else -1
lines = [
line._avatax_prepare_line(sign, doc_type)
for line in self.invoice_line_ids
if line.price_subtotal or line.quantity
]
return [x for x in lines if x]
def update_tax_details(self, tax, line, tax_result_line):
"""Method to update details in tax"""
return tax, line
# 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:
# 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 commiting, 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-commiting.",
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 = {}
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)
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}
# Set Taxes on lines in a way that properly triggers onchanges
# This same approach is also used by the official account_taxcloud connector
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})
# 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})
return tax_result
# Same as v13
def avatax_compute_taxes(self, commit=False):
"""
Called from Invoice's Action menu.
Forces computation of the Invoice taxes
"""
for invoice in self:
if (
invoice.move_type in ["out_invoice", "out_refund"]
and invoice.fiscal_position_id.is_avatax
and (invoice.state == "draft" or commit)
):
invoice._avatax_compute_tax(commit=commit)
return True
def avatax_commit_taxes(self):
for invoice in self:
avatax_config = invoice.company_id.get_avatax_config_company()
if not avatax_config.disable_tax_reporting:
doc_type = invoice._get_avatax_doc_type()
avatax_config.commit_transaction(invoice.name, doc_type)
return True
def is_avatax_calculated(self):
"""
Only apply Avatax for these types of documents.
Can be extended to support other types.
"""
return self.is_sale_document()
def _post(self, soft=True):
for invoice in self:
if invoice.is_avatax_calculated():
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 Validate action will be interrupted
# if the address is not validated
raise UserError(_("Avatax address is not validated!"))
# We should compute taxes before validating the invoice
# to ensure correct account moves
# However, we can't save the invoice because it wasn't assigned a
# number yet
invoice.avatax_compute_taxes(commit=False)
res = super()._post(soft=soft)
for invoice in res:
if invoice.is_avatax_calculated():
# We can only commit to Avatax after validating the invoice
# because we need the generated Invoice number
invoice.avatax_compute_taxes(commit=True)
return res
# prepare_return in v12
def _reverse_move_vals(self, default_values, cancel=True):
# OVERRIDE
# Don't keep anglo-saxon lines if not cancelling an existing invoice.
move_vals = super(AccountMove, self)._reverse_move_vals(
default_values, cancel=cancel
)
move_vals.update(
{
"invoice_doc_no": self.name,
"invoice_date": default_values
and default_values.get("invoice_date")
or self.invoice_date,
"tax_on_shipping_address": self.tax_on_shipping_address,
"warehouse_id": self.warehouse_id.id,
"location_code": self.location_code,
"exemption_code": self.exemption_code or "",
"exemption_code_id": self.exemption_code_id.id or None,
"tax_address_id": self.tax_address_id.id,
}
)
return move_vals
# action_cancel in v12
def button_draft(self):
"""
Sets invoice to Draft, either from the Posted or Cancelled states
"""
posted_invoices = self.filtered(
lambda invoice: invoice.move_type in ["out_invoice", "out_refund"]
and invoice.fiscal_position_id.is_avatax
and invoice.state == "posted"
)
res = super(AccountMove, self).button_draft()
for invoice in posted_invoices:
avatax_config = invoice.company_id.get_avatax_config_company()
if avatax_config:
doc_type = invoice._get_avatax_doc_type()
avatax_config.void_transaction(invoice.name, doc_type)
return res
@api.onchange(
"invoice_line_ids",
"warehouse_id",
"tax_address_id",
"tax_on_shipping_address",
"partner_id",
)
def onchange_avatax_calculation(self):
avatax_config = self.env.company.get_avatax_config_company()
self.calculate_tax_on_save = False
if avatax_config.invoice_calculate_tax:
if (
self._origin.warehouse_id != self.warehouse_id
or self._origin.tax_address_id.street != self.tax_address_id.street
or self._origin.partner_id != self.partner_id
or self._origin.tax_on_shipping_address != self.tax_on_shipping_address
):
self.calculate_tax_on_save = True
return
for line in self.invoice_line_ids:
if (
line._origin.price_unit != line.price_unit
or line._origin.discount != line.discount
or line._origin.quantity != line.quantity
) and line.display_type == "product":
self.calculate_tax_on_save = True
break
def write(self, vals):
result = super(AccountMove, self).write(vals)
avatax_config = self.env.company.get_avatax_config_company()
for record in self:
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)
):
record.with_context(skip_second_write=True).write(
{"calculate_tax_on_save": False}
)
record.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:
if (
avatax_config.invoice_calculate_tax
and move.calculate_tax_on_save
and not self._context.get("skip_second_write", False)
):
move.with_context(skip_second_write=True).write(
{"calculate_tax_on_save": False}
)
move.avatax_compute_taxes()
return moves
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
avatax_amt_line = fields.Float(string="AvaTax Line", copy=False)
def _get_avatax_amount(self, qty=None):
"""
Return the company currency line amount, after discounts,
to use for Tax calculation.
Can be used to compute unit price only, using qty=1.
Code extracted from account/models/account_move.py,
from the compute_base_line_taxes() nested function,
adjusted to compute line amount instead of unit price.
"""
self.ensure_one()
base_line = self
move = base_line.move_id
sign = -1 if move.is_inbound() else 1
quantity = qty or base_line.quantity
base_amount = base_line.price_unit * quantity
if base_line.currency_id:
price_unit_foreign_curr = (
sign * base_amount * (1 - (base_line.discount / 100.0))
)
price_unit_comp_curr = base_line.currency_id._convert(
price_unit_foreign_curr,
move.company_id.currency_id,
move.company_id,
move.date,
)
else:
price_unit_comp_curr = (
sign * base_amount * (1 - (base_line.discount / 100.0))
)
return -price_unit_comp_curr
# Same in v12
def _avatax_prepare_line(self, sign=1, doc_type=None):
"""
Prepare a line to use for Avatax computation.
Returns a dict
"""
line = self
res = {}
# Add UPC to product item code
avatax_config = line.company_id.get_avatax_config_company()
product = line.product_id
if product.barcode and avatax_config.upc_enable:
item_code = "UPC:%d" % product.barcode
else:
item_code = product.default_code or ("ID:%d" % product.id)
tax_code = line.product_id.applicable_tax_code_id.name
amount = sign * line._get_avatax_amount()
if line.quantity < 0:
amount = -amount
res = {
"qty": line.quantity,
"itemcode": item_code,
"description": line.name,
"amount": amount,
"tax_code": tax_code,
"id": line,
"account_id": line.account_id.id,
"tax_id": line.tax_ids,
}
return res
@api.onchange("price_unit", "discount", "quantity")
def onchange_reset_tax_amt(self):
"""
When changing quantities or prices, reset the Avatax computed amount.
The Odoo computed tax amount will then be shown, as a reference.
The Avatax amount will be recomputed upon document validation.
"""
for line in self:
line.avatax_amt_line = 0.0
line.move_id.avatax_amount = 0.0