forked from Mapan/odoo17e
397 lines
17 KiB
Python
397 lines
17 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import logging
|
|
from pprint import pformat
|
|
|
|
from odoo import models, api, fields, _
|
|
from odoo.addons.account_avatax.lib.avatax_client import AvataxClient
|
|
from odoo.exceptions import UserError, ValidationError, RedirectWarning
|
|
from odoo.release import version
|
|
from odoo.tools import float_repr, float_round
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountExternalTaxMixin(models.AbstractModel):
|
|
_inherit = 'account.external.tax.mixin'
|
|
|
|
# Technical field used for the visibility of fields and buttons
|
|
is_avatax = fields.Boolean(compute='_compute_is_avatax')
|
|
|
|
@api.depends('fiscal_position_id')
|
|
def _compute_is_avatax(self):
|
|
for record in self:
|
|
record.is_avatax = record.fiscal_position_id.is_avatax
|
|
|
|
def _compute_is_tax_computed_externally(self):
|
|
super()._compute_is_tax_computed_externally()
|
|
self.filtered('is_avatax').is_tax_computed_externally = True
|
|
|
|
def _get_external_taxes(self):
|
|
""" Override. """
|
|
def find_or_create_tax(doc, detail):
|
|
def repartition_line(repartition_type, account=None):
|
|
return (0, 0, {
|
|
'repartition_type': repartition_type,
|
|
'tag_ids': [],
|
|
'company_id': doc.company_id.id,
|
|
'account_id': account and account.id,
|
|
})
|
|
|
|
fixed = detail.get('unitOfBasis') == 'FlatAmount'
|
|
rate = detail['rate'] if fixed else detail['rate'] * 100
|
|
# 4 precision digits is the same as is used on the amount field of account.tax
|
|
name_precision = 4
|
|
tax_name = '%s [%s] (%s)' % (
|
|
detail['taxName'],
|
|
detail['jurisCode'],
|
|
("$ %s" if fixed else "%s %%") % float_repr(float_round(rate, name_precision), name_precision),
|
|
)
|
|
key = (tax_name, doc.company_id)
|
|
if key not in tax_cache:
|
|
tax_cache[key] = self.env['account.tax'].search([
|
|
*self.env['account.tax']._check_company_domain(doc.company_id),
|
|
('name', '=', tax_name),
|
|
]) or self.env['account.tax'].sudo().with_company(self._find_avatax_credentials_company(doc.company_id)).create({
|
|
'name': tax_name,
|
|
'amount': rate,
|
|
'amount_type': 'fixed' if fixed else 'percent',
|
|
'refund_repartition_line_ids': [
|
|
repartition_line('base'),
|
|
repartition_line('tax', doc.fiscal_position_id.avatax_refund_account_id),
|
|
],
|
|
'invoice_repartition_line_ids': [
|
|
repartition_line('base'),
|
|
repartition_line('tax', doc.fiscal_position_id.avatax_invoice_account_id),
|
|
],
|
|
})
|
|
return tax_cache[key]
|
|
|
|
details, summary = super()._get_external_taxes()
|
|
tax_cache = {}
|
|
|
|
query_results = self.filtered('is_avatax')._query_avatax_taxes()
|
|
errors = []
|
|
for document, query_result in query_results.items():
|
|
error = self._handle_response(query_result, _(
|
|
'Odoo could not fetch the taxes related to %(document)s.\n'
|
|
'Please check the status of `%(technical)s` in the AvaTax portal.',
|
|
document=document.display_name,
|
|
technical=document.avatax_unique_code,
|
|
))
|
|
if error:
|
|
errors.append(error)
|
|
if errors:
|
|
raise UserError('\n\n'.join(errors))
|
|
|
|
for document, query_result in query_results.items():
|
|
is_return = document._get_avatax_document_type() == 'ReturnInvoice'
|
|
line_amounts_sign = -1 if is_return else 1
|
|
|
|
for line_result in query_result['lines']:
|
|
record_id = line_result['lineNumber'].split(',')
|
|
record = self.env[record_id[0]].browse(int(record_id[1]))
|
|
details.setdefault(record, {})
|
|
details[record]['total'] = line_amounts_sign * line_result['lineAmount']
|
|
details[record]['tax_amount'] = line_amounts_sign * line_result['tax']
|
|
for detail in line_result['details']:
|
|
tax = find_or_create_tax(document, detail)
|
|
details[record].setdefault('tax_ids', self.env['account.tax'])
|
|
details[record]['tax_ids'] += tax
|
|
|
|
summary[document] = {}
|
|
for summary_line in query_result['summary']:
|
|
tax = find_or_create_tax(document, summary_line)
|
|
|
|
# Tax avatax returns is opposite from aml balance (avatax is positive on invoice, negative on refund)
|
|
summary[document][tax] = -summary_line['tax']
|
|
|
|
return details, summary
|
|
|
|
|
|
@api.constrains('partner_id', 'fiscal_position_id')
|
|
def _check_address(self):
|
|
incomplete_partner_to_records = self._get_partners_with_incomplete_information()
|
|
|
|
if incomplete_partner_to_records:
|
|
error = _("The following customer(s) need to have a zip, state and country when using Avatax:")
|
|
partner_errors = [
|
|
_("- %s (ID: %s) on %s", partner.display_name, partner.id, ", ".join(record.display_name for record in records))
|
|
for partner, records in incomplete_partner_to_records.items()
|
|
]
|
|
raise ValidationError(error + "\n" + "\n".join(partner_errors))
|
|
|
|
def _get_partners_with_incomplete_information(self, partner=None):
|
|
"""
|
|
This getter will return a dict of partner having missing information like country_id, zip or state as the key
|
|
and the move as value
|
|
"""
|
|
incomplete_partner_to_records = {}
|
|
for record in self.filtered(lambda r: r._perform_address_validation()):
|
|
partner = partner or record.partner_id
|
|
country = partner.country_id
|
|
if (
|
|
partner != self.env.ref('base.public_partner')
|
|
and (
|
|
not country
|
|
or (country.zip_required and not partner.zip)
|
|
or (country.state_required and not partner.state_id)
|
|
)
|
|
):
|
|
incomplete_partner_to_records.setdefault(partner, []).append(record)
|
|
|
|
return incomplete_partner_to_records
|
|
|
|
# #############################################################################################
|
|
# TO IMPLEMENT IN BUSINESS DOCUMENT
|
|
# #############################################################################################
|
|
|
|
def _get_avatax_dates(self):
|
|
"""Get the dates related to the document.
|
|
|
|
:return (tuple<date, date>): the document date and the tax computation date
|
|
"""
|
|
raise NotImplementedError() # implement in business document
|
|
|
|
def _get_avatax_document_type(self):
|
|
"""Get the Avatax Document Type.
|
|
|
|
Specifies the type of document to create. A document type ending with Invoice is a
|
|
permanent transaction that will be recorded in AvaTax. A document type ending with Order is
|
|
a temporary estimate that will not be preserved.
|
|
|
|
:return (string): i.e. `SalesInvoice`, `ReturnInvoice` or `SalesOrder`
|
|
"""
|
|
raise NotImplementedError() # implement in business document
|
|
|
|
def _get_avatax_ship_to_partner(self):
|
|
"""Get the customer's shipping address.
|
|
|
|
This assumes that partner_id exists on models using this class.
|
|
|
|
:return (Model): a `res.partner` record
|
|
"""
|
|
return self.partner_shipping_id or self.partner_id
|
|
|
|
def _perform_address_validation(self):
|
|
"""Allows to bypass the _check_address constraint.
|
|
|
|
:return (bool): whether to execute the _check_address constraint
|
|
"""
|
|
return self.fiscal_position_id.is_avatax
|
|
|
|
# #############################################################################################
|
|
# HELPERS
|
|
# #############################################################################################
|
|
|
|
def _get_avatax_invoice_line(self, line_data):
|
|
"""Create a `LineItemModel` based on line_data.
|
|
|
|
:param line_data (dict): data returned by _get_line_data_for_external_taxes()
|
|
"""
|
|
product = line_data['product_id']
|
|
if not product._get_avatax_category_id():
|
|
raise UserError(_(
|
|
'The Avalara Tax Code is required for %(name)s (#%(id)s)\n'
|
|
'See https://taxcode.avatax.avalara.com/',
|
|
name=product.display_name,
|
|
id=product.id,
|
|
))
|
|
item_code = product.code or ""
|
|
if self.env.company.avalara_use_upc and product.barcode:
|
|
item_code = f'UPC:{product.barcode}'
|
|
return {
|
|
'amount': -line_data["price_subtotal"] if line_data["is_refund"] else line_data["price_subtotal"],
|
|
'description': product.display_name,
|
|
'quantity': abs(line_data["qty"]),
|
|
'taxCode': product._get_avatax_category_id().code,
|
|
'itemCode': item_code,
|
|
'number': "%s,%s" % (line_data["model_name"], line_data["id"]),
|
|
}
|
|
|
|
@api.model
|
|
def _find_avatax_credentials_company(self, company):
|
|
has_avatax_credentials = bool(company.sudo().avalara_api_id and company.sudo().avalara_api_key)
|
|
if has_avatax_credentials:
|
|
return company
|
|
elif company.parent_id:
|
|
return self._find_avatax_credentials_company(company.parent_id)
|
|
|
|
# #############################################################################################
|
|
# PRIVATE UTILITIES
|
|
# #############################################################################################
|
|
|
|
def _get_avatax_ref(self):
|
|
"""Get a transaction reference."""
|
|
return self.name or ''
|
|
|
|
def _get_avatax_address_from_partner(self, partner):
|
|
"""Returns a dict containing the values required for an avatax address
|
|
"""
|
|
# Partner contains the partner_shipping_id or the partner_id
|
|
incomplete_partner = self._get_partners_with_incomplete_information(partner)
|
|
if incomplete_partner.get(partner):
|
|
res = {
|
|
'latitude': partner.partner_latitude,
|
|
'longitude': partner.partner_longitude,
|
|
}
|
|
else:
|
|
res = {
|
|
'city': partner.city,
|
|
'country': partner.country_id.code,
|
|
'region': partner.state_id.code,
|
|
'postalCode': partner.zip,
|
|
'line1': partner.street,
|
|
}
|
|
return res
|
|
|
|
def _get_avatax_addresses(self, partner):
|
|
"""Get the addresses related to a partner.
|
|
|
|
:param partner (Model<res.partner>): the partner we need the addresses of.
|
|
:return (dict): the AddressesModel to return to Avatax
|
|
"""
|
|
res = {
|
|
'shipFrom': self._get_avatax_address_from_partner(self.company_id.partner_id),
|
|
'shipTo': self._get_avatax_address_from_partner(partner),
|
|
}
|
|
return res
|
|
|
|
def _get_avatax_invoice_lines(self):
|
|
return [self._get_avatax_invoice_line(line_data) for line_data in self._get_line_data_for_external_taxes()]
|
|
|
|
def _get_avatax_taxes(self, commit):
|
|
"""Get the transaction values.
|
|
|
|
:return (dict): a mapping defined by the AvataxModel `CreateTransactionModel`.
|
|
"""
|
|
self.ensure_one()
|
|
partner = self.partner_id.commercial_partner_id
|
|
document_date, tax_date = self._get_avatax_dates()
|
|
taxes = {
|
|
'addresses': self._get_avatax_addresses(self._get_avatax_ship_to_partner()),
|
|
'companyCode': self.company_id.partner_id.avalara_partner_code or '',
|
|
'customerCode': partner.avalara_partner_code or partner.avatax_unique_code,
|
|
'entityUseCode': partner.with_company(self.company_id).avalara_exemption_id.code or '',
|
|
'businessIdentificationNo': partner.vat or '',
|
|
'date': (document_date or fields.Date.today()).isoformat(),
|
|
'lines': self._get_avatax_invoice_lines(),
|
|
'type': self._get_avatax_document_type(),
|
|
'code': self.avatax_unique_code,
|
|
'referenceCode': self._get_avatax_ref(),
|
|
'currencyCode': self.currency_id.name or '',
|
|
'commit': commit and self.company_id.avalara_commit,
|
|
}
|
|
|
|
if tax_date:
|
|
taxes['taxOverride'] = {
|
|
'type': 'taxDate',
|
|
'reason': 'Manually changed the tax calculation date',
|
|
'taxDate': tax_date.isoformat(),
|
|
}
|
|
|
|
return taxes
|
|
|
|
def _commit_avatax_taxes(self):
|
|
self._query_avatax_taxes(commit=True)
|
|
|
|
def _query_avatax_taxes(self, commit=False):
|
|
"""Query Avatax with all the transactions linked to `self`.
|
|
|
|
:return (dict<Model, dict>): a mapping between document records and the response from Avatax
|
|
"""
|
|
if not self:
|
|
return {}
|
|
client = self._get_client(self.company_id)
|
|
transactions = {record: record._get_avatax_taxes(commit) for record in self}
|
|
# TODO batch the `create_transaction`
|
|
return {
|
|
record: client.create_transaction(transaction, include='Lines')
|
|
for record, transaction in transactions.items()
|
|
}
|
|
|
|
def _uncommit_external_taxes(self):
|
|
for record in self.filtered('is_avatax'):
|
|
if not record.company_id.avalara_commit:
|
|
continue
|
|
client = self._get_client(record.company_id)
|
|
query_result = client.uncommit_transaction(
|
|
companyCode=record.company_id.partner_id.avalara_partner_code,
|
|
transactionCode=record.avatax_unique_code,
|
|
)
|
|
error = self._handle_response(query_result, _(
|
|
'Odoo could not change the state of the transaction related to %(document)s in'
|
|
' AvaTax\nPlease check the status of `%(technical)s` in the AvaTax portal.',
|
|
document=record.display_name,
|
|
technical=record.avatax_unique_code,
|
|
))
|
|
if error:
|
|
raise UserError(error)
|
|
|
|
return super()._uncommit_external_taxes()
|
|
|
|
def _void_external_taxes(self):
|
|
for record in self.filtered('is_avatax'):
|
|
if not record.company_id.avalara_commit:
|
|
continue
|
|
client = self._get_client(record.company_id)
|
|
query_result = client.void_transaction(
|
|
companyCode=record.company_id.partner_id.avalara_partner_code,
|
|
transactionCode=record.avatax_unique_code,
|
|
model={"code": "DocVoided"},
|
|
)
|
|
|
|
# There's nothing to void when a draft record is deleted without ever being sent to Avatax.
|
|
if query_result.get('error', {}).get('code') == 'EntityNotFoundError':
|
|
_logger.info(pformat(query_result))
|
|
continue
|
|
|
|
error = self._handle_response(query_result, _(
|
|
'Odoo could not void the transaction related to %(document)s in AvaTax\nPlease '
|
|
'check the status of `%(technical)s` in the AvaTax portal.',
|
|
document=record.display_name,
|
|
technical=record.avatax_unique_code,
|
|
))
|
|
if error:
|
|
raise UserError(error)
|
|
|
|
return super()._void_external_taxes()
|
|
|
|
# #############################################################################################
|
|
# COMMUNICATION
|
|
# #############################################################################################
|
|
|
|
def _handle_response(self, response, title):
|
|
if response.get('errors'): # http error
|
|
_logger.warning(pformat(response), stack_info=True)
|
|
return '%s\n%s' % (title, _(
|
|
'%(response)s',
|
|
response=response.get('title', ''),
|
|
))
|
|
if response.get('error'): # avatax error
|
|
_logger.warning(pformat(response), stack_info=True)
|
|
messages = '\n'.join(detail['message'] for detail in response['error']['details'])
|
|
return '%s\n%s' % (title, messages)
|
|
|
|
def _get_client(self, company):
|
|
company = self._find_avatax_credentials_company(company)
|
|
if not company:
|
|
raise RedirectWarning(
|
|
_('Please add your AvaTax credentials'),
|
|
self.env.ref('base_setup.action_general_configuration').id,
|
|
_("Go to the configuration panel"),
|
|
)
|
|
|
|
client = AvataxClient(
|
|
app_name='Odoo',
|
|
app_version=version,
|
|
environment=company.avalara_environment,
|
|
)
|
|
client.add_credentials(
|
|
company.sudo().avalara_api_id or '',
|
|
company.sudo().avalara_api_key or '',
|
|
)
|
|
client.logger = lambda message: self._log_external_tax_request(
|
|
'Avatax US', 'account_avatax.log.end.date', message
|
|
)
|
|
return client
|