forked from Mapan/odoo17e
463 lines
21 KiB
Python
463 lines
21 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from json import dumps
|
|
from pprint import pformat
|
|
|
|
from odoo import models, fields, _, registry, api, SUPERUSER_ID
|
|
from odoo.addons.iap.tools.iap_tools import iap_jsonrpc
|
|
from odoo.exceptions import UserError, ValidationError, RedirectWarning
|
|
from odoo.tools import float_round, DEFAULT_SERVER_DATETIME_FORMAT, json_float_round
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
IAP_SERVICE_NAME = 'l10n_br_avatax_proxy'
|
|
DEFAULT_IAP_ENDPOINT = 'https://l10n-br-avatax.api.odoo.com'
|
|
DEFAULT_IAP_TEST_ENDPOINT = 'https://l10n-br-avatax.test.odoo.com'
|
|
ICP_LOG_NAME = 'l10n_br_avatax.log.end.date'
|
|
AVATAX_PRECISION_DIGITS = 2 # defined by API
|
|
|
|
|
|
class AccountExternalTaxMixinL10nBR(models.AbstractModel):
|
|
_inherit = 'account.external.tax.mixin'
|
|
|
|
def _l10n_br_is_avatax(self):
|
|
self.ensure_one()
|
|
return self.country_code == 'BR' and self.fiscal_position_id.l10n_br_is_avatax
|
|
|
|
def _compute_is_tax_computed_externally(self):
|
|
super()._compute_is_tax_computed_externally()
|
|
self.filtered(lambda record: record._l10n_br_is_avatax()).is_tax_computed_externally = True
|
|
|
|
def _l10n_br_avatax_log(self):
|
|
self.env['account.external.tax.mixin']._enable_external_tax_logging(ICP_LOG_NAME)
|
|
return True
|
|
|
|
def _l10n_br_get_date_avatax(self):
|
|
""" Returns the transaction date for this record. """
|
|
raise NotImplementedError()
|
|
|
|
def _l10n_br_get_avatax_lines(self):
|
|
""" Returns line dicts for this record created with _l10n_br_build_avatax_line(). """
|
|
raise NotImplementedError()
|
|
|
|
def _l10n_br_get_operation_type(self):
|
|
""" Returns the operationType to be used for requests to Avatax. By default, it's "standardSales", but
|
|
can be overriden. """
|
|
return 'standardSales'
|
|
|
|
def _l10n_br_get_invoice_refs(self):
|
|
""" Should return a dict of invoiceRefs, as specified by the Avatax API. These are required for
|
|
credit and debit notes. """
|
|
return {}
|
|
|
|
def _l10n_br_line_model_name(self):
|
|
return self._name + '.line'
|
|
|
|
def _l10n_br_avatax_handle_response(self, response, title):
|
|
if response.get('error'):
|
|
logger.warning(pformat(response), stack_info=True)
|
|
|
|
inner_errors = []
|
|
for error in response['error'].get('innerError', []):
|
|
# Useful inner errors are line-specific. Ones that aren't are typically not useful for the user.
|
|
if 'lineCode' not in error:
|
|
continue
|
|
|
|
product_name = self.env[self._l10n_br_line_model_name()].browse(error['lineCode']).product_id.display_name
|
|
|
|
inner_errors.append(_('What:'))
|
|
inner_errors.append('- %s: %s' % (product_name, error['message']))
|
|
|
|
where = error.get('where', {})
|
|
if where:
|
|
inner_errors.append(_('Where:'))
|
|
for where_key, where_value in sorted(where.items()):
|
|
if where_key == 'date':
|
|
continue
|
|
inner_errors.append('- %s: %s' % (where_key, where_value))
|
|
|
|
return '%s\n%s\n%s' % (title, response['error']['message'], '\n'.join(inner_errors))
|
|
|
|
def _l10n_br_avatax_allow_services(self):
|
|
""" Override to allow services. """
|
|
return False
|
|
|
|
def _l10n_br_avatax_validate_lines(self, lines):
|
|
""" Avoids doing requests to Avatax that are guaranteed to fail. """
|
|
errors = []
|
|
for line in lines:
|
|
product = line['tempProduct']
|
|
cean = line['itemDescriptor']['cean']
|
|
if not product:
|
|
errors.append(_('- A product is required on each line when using Avatax.'))
|
|
elif not self._l10n_br_avatax_allow_services() and product.type == 'service':
|
|
errors.append(_('- Install the "Brazilian Accounting EDI for services" app to electronically invoice services.'))
|
|
elif not product.l10n_br_ncm_code_id:
|
|
errors.append(_('- Please configure a Mercosul NCM Code on %s.', product.display_name))
|
|
elif line['lineAmount'] < 0:
|
|
errors.append(_("- Avatax Brazil doesn't support negative lines."))
|
|
elif cean and (not cean.isdigit() or not (len(cean) == 8 or 12 <= len(cean) <= 14)):
|
|
errors.append(_("- The barcode of %s must have either 8, or 12 to 14 digits when using Avatax.", product.display_name))
|
|
|
|
if errors:
|
|
raise ValidationError('\n'.join(errors))
|
|
|
|
def _l10n_br_build_avatax_line(self, product, qty, unit_price, total, discount, line_id):
|
|
""" Prepares the line data for the /calculations API call. temp* values are here to help with post-processing
|
|
and will be removed before sending by _remove_temp_values_lines.
|
|
|
|
:param product.product product: product on the line
|
|
:param float qty: the number of items on the line
|
|
:param float unit_price: the unit_price on the line
|
|
:param float total: the amount on the line without taxes or discount
|
|
:param float discount: the discount amount on the line
|
|
:param int line_id: the database ID of the line record, this is used to uniquely identify it in Avatax
|
|
:return dict: the basis for the 'lines' value in the /calculations API call
|
|
"""
|
|
return {
|
|
'lineCode': line_id,
|
|
'useType': product.l10n_br_use_type,
|
|
'otherCostAmount': 0,
|
|
'freightAmount': 0,
|
|
'insuranceAmount': 0,
|
|
'lineTaxedDiscount': discount,
|
|
'lineAmount': total,
|
|
'lineUnitPrice': unit_price,
|
|
'numberOfItems': qty,
|
|
'itemDescriptor': {
|
|
'description': product.display_name or '',
|
|
'cest': product.l10n_br_cest_code or '',
|
|
# Remove periods from hsCode for goods (standard case).
|
|
'hsCode': (product.l10n_br_ncm_code_id.code or '').replace('.', ''),
|
|
'source': product.l10n_br_source_origin or '',
|
|
'productType': product.l10n_br_sped_type or '',
|
|
'cean': product.barcode or '',
|
|
},
|
|
'tempTransportCostType': product.l10n_br_transport_cost_type,
|
|
'tempProduct': product,
|
|
}
|
|
|
|
def _l10n_br_distribute_transport_cost_over_lines(self, lines, transport_cost_type):
|
|
""" Avatax requires transport costs to be specified per line. This distributes transport costs (indicated by
|
|
their product's l10n_br_transport_cost_type) over the lines in proportion to their subtotals. """
|
|
type_to_api_field = {
|
|
'freight': 'freightAmount',
|
|
'insurance': 'insuranceAmount',
|
|
'other': 'otherCostAmount',
|
|
}
|
|
api_field = type_to_api_field[transport_cost_type]
|
|
|
|
transport_lines = [line for line in lines if line['tempTransportCostType'] == transport_cost_type]
|
|
regular_lines = [line for line in lines if not line['tempTransportCostType']]
|
|
total = sum(line['lineAmount'] for line in regular_lines)
|
|
|
|
if not regular_lines:
|
|
raise UserError(_('Avatax requires at least one non-transport line.'))
|
|
|
|
for transport_line in transport_lines:
|
|
transport_net = transport_line['lineAmount'] - transport_line['lineTaxedDiscount']
|
|
remaining = transport_net
|
|
for line in regular_lines[:-1]:
|
|
current_cost = float_round(
|
|
transport_net * (line['lineAmount'] / total),
|
|
precision_digits=AVATAX_PRECISION_DIGITS
|
|
)
|
|
remaining -= current_cost
|
|
line[api_field] += current_cost
|
|
|
|
# put remainder on last line to avoid rounding issues
|
|
regular_lines[-1][api_field] += remaining
|
|
|
|
return [line for line in lines if line['tempTransportCostType'] != transport_cost_type]
|
|
|
|
def _l10n_br_remove_temp_values_lines(self, lines):
|
|
for line in lines:
|
|
del line['tempTransportCostType']
|
|
del line['tempProduct']
|
|
|
|
def _l10n_br_repr_amounts(self, lines):
|
|
""" Ensures all amount fields have the right amount of decimals before sending it to the API. """
|
|
for line in lines:
|
|
for amount_field in ('lineAmount', 'freightAmount', 'insuranceAmount', 'otherCostAmount'):
|
|
line[amount_field] = json_float_round(line[amount_field], AVATAX_PRECISION_DIGITS)
|
|
|
|
def _l10n_br_call_avatax_taxes(self):
|
|
"""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 {}
|
|
|
|
company_sudo = self.company_id.sudo()
|
|
api_id, api_key = company_sudo.l10n_br_avatax_api_identifier, company_sudo.l10n_br_avatax_api_key
|
|
if not api_id or not api_key:
|
|
raise RedirectWarning(
|
|
_('Please create an Avatax account'),
|
|
self.env.ref('base_setup.action_general_configuration').id,
|
|
_('Go to the configuration panel'),
|
|
)
|
|
|
|
transactions = {record: record._l10n_br_get_calculate_payload() for record in self}
|
|
return {
|
|
record: record._l10n_br_iap_calculate_tax(transaction)
|
|
for record, transaction in transactions.items()
|
|
}
|
|
|
|
def _l10n_br_get_partner_type(self, partner):
|
|
if partner.country_code not in ('BR', False):
|
|
return 'foreign'
|
|
elif partner.is_company:
|
|
return 'business'
|
|
else:
|
|
return 'individual'
|
|
|
|
def _l10n_br_get_calculate_payload(self):
|
|
""" Returns the full payload containing one record to be used in a /transactions API call. """
|
|
self.ensure_one()
|
|
transaction_date = self._get_date_for_external_taxes()
|
|
partner = self.partner_id
|
|
company = self.company_id.partner_id
|
|
|
|
lines = [
|
|
self._l10n_br_build_avatax_line(
|
|
line['product_id'],
|
|
line['qty'],
|
|
line['price_unit'],
|
|
line['qty'] * line['price_unit'],
|
|
line['qty'] * line['price_unit'] * (line['discount'] / 100.0),
|
|
line['id'],
|
|
)
|
|
for line
|
|
in self._get_line_data_for_external_taxes()
|
|
]
|
|
lines = self._l10n_br_distribute_transport_cost_over_lines(lines, 'freight')
|
|
lines = self._l10n_br_distribute_transport_cost_over_lines(lines, 'insurance')
|
|
lines = self._l10n_br_distribute_transport_cost_over_lines(lines, 'other')
|
|
|
|
self._l10n_br_avatax_validate_lines(lines)
|
|
self._l10n_br_remove_temp_values_lines(lines)
|
|
self._l10n_br_repr_amounts(lines)
|
|
|
|
simplifiedTaxesSettings = {}
|
|
if company.l10n_br_tax_regime == 'simplified':
|
|
simplifiedTaxesSettings = {'pCredSN': self.company_id.l10n_br_icms_rate}
|
|
|
|
return {
|
|
'header': {
|
|
'transactionDate': (transaction_date or fields.Date.today()).isoformat(),
|
|
'amountCalcType': 'gross',
|
|
'documentCode': '%s_%s' % (self._name, self.id),
|
|
'messageType': 'goods',
|
|
'companyLocation': '',
|
|
'operationType': self._l10n_br_get_operation_type(),
|
|
**self._l10n_br_get_invoice_refs(),
|
|
'locations': {
|
|
'entity': { # the customer
|
|
'type': self._l10n_br_get_partner_type(partner),
|
|
'activitySector': {
|
|
'code': partner.l10n_br_activity_sector,
|
|
},
|
|
'taxesSettings': {
|
|
'icmsTaxPayer': partner.l10n_br_taxpayer == "icms",
|
|
},
|
|
'taxRegime': partner.l10n_br_tax_regime,
|
|
'address': {
|
|
'zipcode': partner.zip,
|
|
},
|
|
'federalTaxId': partner.vat,
|
|
'suframa': partner.l10n_br_isuf_code or '',
|
|
},
|
|
'establishment': { # the seller
|
|
'type': 'business',
|
|
'activitySector': {
|
|
'code': company.l10n_br_activity_sector,
|
|
},
|
|
'taxesSettings': {
|
|
'icmsTaxPayer': company.l10n_br_taxpayer == "icms",
|
|
**simplifiedTaxesSettings,
|
|
},
|
|
'taxRegime': company.l10n_br_tax_regime,
|
|
'address': {
|
|
'zipcode': company.zip,
|
|
},
|
|
'federalTaxId': company.vat,
|
|
'suframa': company.l10n_br_isuf_code or '',
|
|
},
|
|
},
|
|
},
|
|
'lines': lines,
|
|
}
|
|
|
|
def _l10n_br_get_line_total(self, line_result):
|
|
"""To be overridden for non-goods APIs."""
|
|
return line_result["lineNetFigure"] - line_result["lineTaxedDiscount"]
|
|
|
|
def _get_external_taxes(self):
|
|
""" Override. """
|
|
details, summary = super()._get_external_taxes()
|
|
|
|
def find_or_create_tax(doc, tax_name, price_include):
|
|
def repartition_line(repartition_type):
|
|
return (0, 0, {
|
|
'repartition_type': repartition_type,
|
|
'company_id': doc.company_id.id,
|
|
})
|
|
|
|
key = (tax_name, price_include, doc.company_id)
|
|
if key not in tax_cache:
|
|
# It's possible for multiple taxes to have the needed l10n_br_avatax_code. E.g.:
|
|
# - existing customer install l10n_br_avatax
|
|
# - computes taxes without reloading the fiscal localization, this creates fallback taxes
|
|
# - reloads the fiscal localization
|
|
# In this case take the most recent tax (the one included in the fiscal localization), that one is
|
|
# most likely the one the user wants and will have the right accounts and tags.
|
|
tax_cache[key] = self.env['account.tax'].with_context(active_test=False).search([
|
|
('l10n_br_avatax_code', '=', tax_name),
|
|
('price_include', '=', price_include),
|
|
('company_id', '=', doc.company_id.id)
|
|
], limit=1, order='create_date desc')
|
|
|
|
# all these taxes are archived by default, unarchive when used
|
|
tax_cache[key].active = True
|
|
|
|
if not tax_cache[key]: # fall back on creating a bare-bones tax
|
|
tax_cache[key] = self.env['account.tax'].sudo().with_company(doc.company_id).create({
|
|
'name': tax_name,
|
|
'l10n_br_avatax_code': tax_name,
|
|
'amount': 1, # leaving it at the default 0 causes accounting to ignore these
|
|
'amount_type': 'percent',
|
|
'price_include': price_include,
|
|
'refund_repartition_line_ids': [
|
|
repartition_line('base'),
|
|
repartition_line('tax'),
|
|
],
|
|
'invoice_repartition_line_ids': [
|
|
repartition_line('base'),
|
|
repartition_line('tax'),
|
|
],
|
|
})
|
|
|
|
return tax_cache[key]
|
|
tax_cache = {}
|
|
|
|
br_records = self.filtered(lambda record: record._l10n_br_is_avatax())
|
|
for record in br_records:
|
|
if record.currency_id.name != 'BRL':
|
|
raise UserError(_('%s has to use Brazilian Real to calculate taxes with Avatax.', record.display_name))
|
|
|
|
query_results = br_records._l10n_br_call_avatax_taxes()
|
|
errors = []
|
|
for document, query_result in query_results.items():
|
|
error = self._l10n_br_avatax_handle_response(query_result, _(
|
|
'Odoo could not fetch the taxes related to %(document)s.',
|
|
document=document.display_name,
|
|
))
|
|
if error:
|
|
errors.append(error)
|
|
if errors:
|
|
raise UserError('\n\n'.join(errors))
|
|
|
|
for document, query_result in query_results.items():
|
|
subtracted_tax_types = set()
|
|
tax_type_to_price_include = {}
|
|
is_return = document._l10n_br_get_operation_type() == 'salesReturn'
|
|
for line_result in query_result['lines']:
|
|
record_id = line_result['lineCode']
|
|
record = self.env[self._l10n_br_line_model_name()].browse(int(record_id))
|
|
details[record] = {}
|
|
details[record]['total'] = self._l10n_br_get_line_total(line_result)
|
|
details[record]['tax_amount'] = 0
|
|
details[record]['tax_ids'] = self.env['account.tax']
|
|
for detail in line_result['taxDetails']:
|
|
if detail['taxImpact']['impactOnNetAmount'] != 'Informative':
|
|
tax_amount = detail['tax']
|
|
if is_return:
|
|
tax_amount = -tax_amount
|
|
|
|
if detail['taxImpact']['impactOnNetAmount'] == 'Subtracted':
|
|
tax_amount = -tax_amount
|
|
subtracted_tax_types.add(detail['taxType'])
|
|
|
|
price_include = detail['taxImpact']['impactOnNetAmount'] == 'Included'
|
|
|
|
# In the unlikely event there is an included and excluded tax with the same tax type we take
|
|
# whichever comes first. The tax computation will still be correct and the taxByType summary
|
|
# later will group them together.
|
|
tax_type_to_price_include.setdefault(detail['taxType'], price_include)
|
|
tax = find_or_create_tax(document, detail['taxType'], price_include)
|
|
|
|
details[record]['tax_amount'] += tax_amount
|
|
details[record]['tax_ids'] += tax
|
|
|
|
summary[document] = {}
|
|
for tax_type, type_details in query_result['summary']['taxByType'].items():
|
|
tax = find_or_create_tax(document, tax_type, tax_type_to_price_include.get(tax_type, False))
|
|
|
|
amount = type_details['tax']
|
|
if is_return:
|
|
amount = -amount
|
|
|
|
if tax_type in subtracted_tax_types:
|
|
amount = -amount
|
|
|
|
# Tax avatax returns is opposite from aml balance (avatax is positive on invoice, negative on refund)
|
|
summary[document][tax] = -amount
|
|
|
|
details = {record: taxes for record, taxes in details.items() if taxes['tax_ids']}
|
|
|
|
return details, summary
|
|
|
|
# IAP related methods
|
|
def _l10n_br_iap_request(self, route, json=None, company=None):
|
|
company = company or self.company_id
|
|
avatax_api_id, avatax_api_key = company.sudo().l10n_br_avatax_api_identifier, company.sudo().l10n_br_avatax_api_key
|
|
|
|
default_endpoint = DEFAULT_IAP_ENDPOINT if company.l10n_br_avalara_environment == 'production' else DEFAULT_IAP_TEST_ENDPOINT
|
|
iap_endpoint = self.env['ir.config_parameter'].sudo().get_param('l10n_br_avatax_iap.endpoint', default_endpoint)
|
|
environment = company.l10n_br_avalara_environment
|
|
url = f'{iap_endpoint}/api/l10n_br_avatax/1/{route}'
|
|
|
|
params = {
|
|
'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'),
|
|
'account_token': self.env['iap.account'].get(IAP_SERVICE_NAME).account_token,
|
|
'avatax': {
|
|
'is_production': environment and environment == 'production',
|
|
'json': json or {},
|
|
}
|
|
}
|
|
|
|
if avatax_api_id:
|
|
params['api_id'] = avatax_api_id
|
|
params['api_secret'] = avatax_api_key
|
|
|
|
start = str(datetime.utcnow())
|
|
response = iap_jsonrpc(url, params=params, timeout=60) # longer timeout because create_account can take some time
|
|
end = str(datetime.utcnow())
|
|
|
|
# Avatax support requested that requests and responses be provided in JSON, so they can easily load them in their
|
|
# internal tools for troubleshooting.
|
|
self._log_external_tax_request(
|
|
'Avatax Brazil',
|
|
ICP_LOG_NAME,
|
|
f"start={start}\n"
|
|
f"end={end}\n"
|
|
f"args={pformat(url)}\n"
|
|
f"request={dumps(json, indent=2)}\n"
|
|
f"response={dumps(response, indent=2)}"
|
|
)
|
|
|
|
return response
|
|
|
|
def _l10n_br_iap_ping(self, company):
|
|
# This takes company because this function is called directly from res.config.settings instead of a sale.order or account.move
|
|
return self._l10n_br_iap_request('ping', company=company)
|
|
|
|
def _l10n_br_iap_create_account(self, account_data, company):
|
|
# This takes company because this function is called directly from res.config.settings instead of a sale.order or account.move
|
|
return self._l10n_br_iap_request('create_account', account_data, company=company)
|
|
|
|
def _l10n_br_iap_calculate_tax(self, transaction):
|
|
return self._l10n_br_iap_request('calculate_tax', transaction)
|