1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/l10n_br_avatax/models/account_external_tax_mixin.py
2024-12-10 09:04:09 +07:00

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)