925 lines
42 KiB
Python
925 lines
42 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import json
|
|
import re
|
|
from collections import defaultdict
|
|
from hashlib import sha1
|
|
|
|
from markupsafe import Markup
|
|
|
|
from odoo import models, api, _, fields, Command
|
|
from odoo.addons.iap import InsufficientCreditError
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import format_list
|
|
|
|
|
|
# Copied from account.external.tax.mixin in l10n_br_edi
|
|
def deep_update(d, u):
|
|
"""Like {}.update but handles nested dicts recursively. Based on https://stackoverflow.com/a/3233356."""
|
|
for k, v in u.items():
|
|
if isinstance(v, dict):
|
|
d[k] = deep_update(d.get(k, {}), v)
|
|
else:
|
|
d[k] = v
|
|
return d
|
|
|
|
|
|
# Copied from account.external.tax.mixin in l10n_br_edi
|
|
def deep_clean(d):
|
|
"""Recursively removes keys with a falsy value in dicts. Based on https://stackoverflow.com/a/48152075."""
|
|
cleaned_dict = {}
|
|
for k, v in d.items():
|
|
if isinstance(v, dict):
|
|
v = deep_clean(v)
|
|
if v:
|
|
cleaned_dict[k] = v
|
|
return cleaned_dict or None
|
|
|
|
|
|
class PosOrder(models.Model):
|
|
_name = "pos.order"
|
|
_inherit = ["pos.order", "account.external.tax.mixin"]
|
|
|
|
l10n_br_edi_avatax_data = fields.Json(
|
|
help="Brazil: technical field that remembers the last tax summary returned by Avatax.", copy=False
|
|
)
|
|
l10n_br_avatax_error = fields.Text(
|
|
"Brazil Avatax Error",
|
|
copy=False,
|
|
readonly=True,
|
|
help="Brazil: error details for orders in the 'error' state.",
|
|
)
|
|
l10n_br_last_avatax_status = fields.Selection(
|
|
[
|
|
("accepted", "Accepted"),
|
|
("error", "Error"),
|
|
],
|
|
string="Brazil Avatax Status",
|
|
copy=False,
|
|
tracking=True,
|
|
readonly=True,
|
|
help="Brazil: the state of the most recent e-invoicing attempt.",
|
|
)
|
|
l10n_br_edi_number = fields.Char(
|
|
"NFC-e Number", compute="_compute_l10n_br_edi_number", help="Brazil: NFC-e number linked to this order."
|
|
)
|
|
l10n_br_access_key = fields.Char(
|
|
"Access Key",
|
|
copy=False,
|
|
readonly=True,
|
|
help="Brazil: access key associated with the electronic document. Can be used to get invoice information directly from the government.",
|
|
)
|
|
l10n_br_edi_protocol_authorization_number = fields.Char(
|
|
"Protocol Authorization Number",
|
|
copy=False,
|
|
readonly=True,
|
|
help="Brazil: the protocol authorization number of the e-invoice.",
|
|
)
|
|
l10n_br_edi_authorization_date = fields.Char(
|
|
"Authorization Date", copy=False, readonly=True, help="Brazil: the authorization date of the e-invoice."
|
|
)
|
|
l10n_br_edi_series = fields.Char(
|
|
"Series",
|
|
readonly=True,
|
|
copy=False,
|
|
help="Brazil: series number used for this order.",
|
|
)
|
|
|
|
# Technical field holding the e-invoice PDF data for security reasons.
|
|
l10n_br_edi_pdf_attachment_file = fields.Binary(
|
|
string="Brazil E-Invoice PDF File",
|
|
copy=False,
|
|
attachment=True,
|
|
)
|
|
# Technical field holding the e-invoice XML data for security reasons.
|
|
l10n_br_edi_xml_attachment_file = fields.Binary(
|
|
string="Brazil E-Invoice XML File",
|
|
copy=False,
|
|
attachment=True,
|
|
)
|
|
|
|
# Technical field used to mark the pos order for resending to the edi via cron.
|
|
l10n_br_edi_triggered_user_id = fields.Many2one(
|
|
string="User that triggered EDI Cron",
|
|
comodel_name="res.users",
|
|
readonly=True,
|
|
copy=False
|
|
)
|
|
|
|
l10n_br_edi_processed_by_cron = fields.Boolean(
|
|
string="Processed By EDI Cron",
|
|
readonly=True,
|
|
copy=False,
|
|
)
|
|
|
|
@api.depends("name")
|
|
def _compute_l10n_br_edi_number(self):
|
|
"""Use the longest, rightmost contiguous string of digits as the invoice number. E.g., for Shop 01/1234 the number
|
|
will be 1234. This number can be adjusted with the l10n_br_nfce_next_number field on pos.config, and must be unique
|
|
in combination with l10n_br_invoice_serial."""
|
|
pattern = re.compile(r"\d*$")
|
|
for order in self:
|
|
order.l10n_br_edi_number = pattern.search(order.name).group(0).zfill(9)
|
|
|
|
@api.depends("config_id.l10n_br_is_nfce")
|
|
def _compute_l10n_br_is_avatax(self):
|
|
"""account.external.tax.mixin override. Don't rely on fiscal positions for the POS."""
|
|
for order in self:
|
|
order.l10n_br_is_avatax = order.config_id.l10n_br_is_nfce
|
|
|
|
def _get_and_set_external_taxes_on_eligible_records(self):
|
|
"""account.external.tax.mixin override."""
|
|
eligible_orders = self.filtered(lambda order: order.l10n_br_is_avatax and order.l10n_br_last_avatax_status != "accepted")
|
|
for order in eligible_orders:
|
|
try:
|
|
order._set_external_taxes(order._get_external_taxes())
|
|
except (UserError, ValidationError, InsufficientCreditError) as e: # Don't block the POS
|
|
order.l10n_br_last_avatax_status = "error"
|
|
order.l10n_br_avatax_error = str(e)
|
|
else:
|
|
order.l10n_br_last_avatax_status = False
|
|
order.l10n_br_avatax_error = False
|
|
|
|
return super()._get_and_set_external_taxes_on_eligible_records()
|
|
|
|
def _get_l10n_br_avatax_service_params(self):
|
|
# EXTENDS 'account.external.tax.mixin'
|
|
res = super()._get_l10n_br_avatax_service_params()
|
|
access_key = self._l10n_br_generate_access_key()
|
|
res.update({
|
|
# This should be the time at which the transaction happens, i.e., now. If it's more
|
|
# than 5 minutes in the past, we get an error.
|
|
'document_date': fields.Datetime.context_timestamp(self, fields.Datetime.now()),
|
|
|
|
'partner_shipping': self.partner_id,
|
|
'operation_type': self.env.ref("l10n_br_avatax.operation_type_1"),
|
|
'access_key': access_key,
|
|
'edi_number': self.l10n_br_edi_number,
|
|
'invoice_serial': self.config_id.l10n_br_invoice_serial,
|
|
'url_key': self._l10n_br_get_url_key(),
|
|
'nfce_qr_code': self._l10n_br_generate_nfce_qr_code(access_key),
|
|
**self._l10n_br_prepare_payment_info(),
|
|
})
|
|
|
|
if refunded_order := self.refunded_order_id:
|
|
if refunded_order.l10n_br_last_avatax_status != "accepted" or not refunded_order.l10n_br_access_key:
|
|
raise ValidationError(
|
|
_(
|
|
"%(order_name)s must be successfully invoiced before invoicing this refund.",
|
|
order_name=refunded_order.display_name,
|
|
)
|
|
)
|
|
|
|
res['invoice_refs'] = {
|
|
"invoicesRefs": [
|
|
{
|
|
"type": "refNFe",
|
|
"refNFe": refunded_order.l10n_br_access_key,
|
|
}
|
|
]
|
|
}
|
|
|
|
return res
|
|
|
|
def _get_line_data_for_external_taxes(self):
|
|
"""account.external.tax.mixin override."""
|
|
if not any([order.l10n_br_is_avatax for order in self]):
|
|
return super()._get_line_data_for_external_taxes()
|
|
|
|
AccountTax = self.env['account.tax']
|
|
base_lines = self._prepare_tax_base_line_values()
|
|
AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id)
|
|
AccountTax._round_base_lines_tax_details(base_lines, self.company_id)
|
|
AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines, self.company_id)
|
|
|
|
operation_type = self.env.ref("l10n_br_avatax.operation_type_1")
|
|
res = []
|
|
for line in base_lines:
|
|
res.append({
|
|
'base_line': line,
|
|
'description': line['record'].product_id.name,
|
|
'operation_type': operation_type,
|
|
})
|
|
|
|
return res
|
|
|
|
def _l10n_br_do_edi(self, save_avalara_pdf=False):
|
|
"""Do both tax calculation and EDI in one step. Unlike for other models, we don't support the in-between state
|
|
of successful tax calculation and failed EDI to simplify things."""
|
|
|
|
# Don't do EDI for refunds or already EDI'd orders, this should happen manually through account.move.
|
|
orders_to_edi = self.filtered(lambda order: order.l10n_br_is_avatax and order.l10n_br_last_avatax_status != "accepted" and not order.refunded_order_id)
|
|
|
|
orders_to_edi._get_and_set_external_taxes_on_eligible_records()
|
|
for order in orders_to_edi:
|
|
if order.l10n_br_last_avatax_status == "error":
|
|
continue # failed tax calculation above
|
|
|
|
if errors := order._l10n_br_edi_send(save_avalara_pdf=save_avalara_pdf):
|
|
order.l10n_br_avatax_error = "\n".join(errors)
|
|
order.l10n_br_edi_avatax_data = False
|
|
|
|
# Clear all taxes if EDI fails. For POS we consider tax calculation and EDI a single step. No savepoint
|
|
# is used because we need to save some data (l10n_br_avatax_error, ...), and this is simple enough.
|
|
for line in order.lines:
|
|
line.extra_tax_data = False
|
|
line.tax_ids = False
|
|
|
|
def _cron_l10n_br_send_nfce(self, batch_size=1):
|
|
def get_order_notification(orders, is_success: bool):
|
|
return [
|
|
'account_notification',
|
|
{
|
|
'type': 'success' if is_success else 'warning',
|
|
'title': self.env._('NFC-e sent') if is_success else self.env._('NFC-e declined'),
|
|
'sticky': not is_success,
|
|
'message': self.env._('POS Orders sent successfully.') if is_success else self.env._(
|
|
"One or more POS orders couldn't be processed."),
|
|
'action_button': {
|
|
'name': self.env._('Open'),
|
|
'action_name': self.env._('Sent orders') if is_success else self.env._('Orders in error'),
|
|
'model': 'pos.order',
|
|
'res_ids': orders.ids,
|
|
},
|
|
},
|
|
]
|
|
|
|
domain = [('l10n_br_edi_triggered_user_id', '!=', False), ('l10n_br_edi_processed_by_cron', '=', False)]
|
|
record = self.search(domain, order='id asc', limit=batch_size).try_lock_for_update()
|
|
if not record:
|
|
return
|
|
|
|
record.with_user(record.l10n_br_edi_triggered_user_id)._l10n_br_do_edi(save_avalara_pdf=True)
|
|
record.l10n_br_edi_processed_by_cron = True
|
|
remaining = self.search_count(domain)
|
|
|
|
if not remaining:
|
|
processed_records = self.search(
|
|
[('l10n_br_edi_triggered_user_id', '!=', False), ('l10n_br_edi_processed_by_cron', '=', True)]
|
|
).try_lock_for_update()
|
|
|
|
orders_by_user = processed_records.grouped('l10n_br_edi_triggered_user_id')
|
|
# Notify those that triggered the cron
|
|
for user, user_orders in orders_by_user.items():
|
|
|
|
user_orders_error = user_orders.filtered('l10n_br_avatax_error')
|
|
if user_orders_error:
|
|
user._bus_send(*get_order_notification(user_orders_error, False))
|
|
user_orders_success = user_orders - user_orders_error
|
|
if user_orders_success:
|
|
user._bus_send(*get_order_notification(user_orders_success, True))
|
|
user_orders.write({
|
|
'l10n_br_edi_triggered_user_id': False,
|
|
'l10n_br_edi_processed_by_cron': False,
|
|
})
|
|
|
|
self.env['ir.cron']._commit_progress(1, remaining=remaining)
|
|
|
|
def _prepare_invoice_vals(self):
|
|
"""Override. Refunds will be electronically invoiced through a normal account.move because it's not possible to
|
|
do a salesReturn for NFC-e."""
|
|
res = super()._prepare_invoice_vals()
|
|
if any([order.l10n_br_is_avatax for order in self]) and len(self.refunded_order_id.ids) > 0:
|
|
fp = self.env["account.fiscal.position"].search([("l10n_br_is_avatax", "=", True)], limit=1).id
|
|
res.update(
|
|
{
|
|
"fiscal_position_id": fp,
|
|
# We can only assign a single payment method to the invoice, take the first one.
|
|
"l10n_br_edi_payment_method": self.payment_ids[0].payment_method_id.l10n_br_payment_method,
|
|
}
|
|
)
|
|
|
|
return res
|
|
|
|
def _get_invoice_post_context(self):
|
|
"""Override. Taxes will change, if we skip_invoice_sync then the move will be unbalanced."""
|
|
res = super()._get_invoice_post_context()
|
|
if any([order.l10n_br_is_avatax for order in self]):
|
|
res.pop("skip_invoice_sync", False)
|
|
return res
|
|
|
|
def action_pos_order_invoice(self):
|
|
"""Override."""
|
|
for record in self:
|
|
if not record.l10n_br_is_avatax:
|
|
continue
|
|
|
|
if not record.refunded_order_id:
|
|
raise UserError(_("You cannot invoice NFC-e orders."))
|
|
|
|
if not record.partner_id:
|
|
raise ValidationError(_("NF-e refunds require a customer."))
|
|
|
|
return super().action_pos_order_invoice()
|
|
|
|
def button_l10n_br_edi(self):
|
|
"""We save the Avalara receipt PDF in cases where a customer invoices manually from the backend."""
|
|
self._l10n_br_do_edi(save_avalara_pdf=True)
|
|
|
|
def action_send_nfce_batch(self):
|
|
errored_orders = self.filtered(lambda order: order.l10n_br_last_avatax_status == 'error' and order.config_id.l10n_br_is_nfce)
|
|
others = self - errored_orders
|
|
if others:
|
|
raise UserError(_("Some orders are unable to be sent to Avalara for EDI. This could be due to them already being accepted or are not eligible to be sent.\n"
|
|
"Please unselect: %(other_orders)s and try again.", other_orders=others.mapped('name'))
|
|
)
|
|
|
|
already_triggered_orders = errored_orders.filtered('l10n_br_edi_triggered_user_id')
|
|
if already_triggered_orders:
|
|
raise UserError(_("Some orders are already queued to be sent to Avalara for EDI.\n"
|
|
"Please unselect: %(queued_orders)s and try again.", queued_orders=already_triggered_orders.mapped('name'))
|
|
)
|
|
|
|
new_errors = errored_orders - already_triggered_orders
|
|
new_errors.l10n_br_edi_triggered_user_id = self.env.user
|
|
if new_errors:
|
|
self.env.ref('l10n_br_edi_pos.ir_cron_l10n_br_edi_pos_check_status')._trigger()
|
|
title = _('Processing NFC-e')
|
|
message = _('Orders are being sent in the background.')
|
|
else:
|
|
title = _('No Orders to Process')
|
|
message = _('All orders already processed.')
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'type': 'info',
|
|
'title': title,
|
|
'message': message,
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def sync_from_ui(self, orders):
|
|
"""Entrypoint for EDI through the POS. Launch EDI for all paid orders when they are received in the backend. Amend
|
|
the response sent back to the POS after EDI to include fields needed for the receipt."""
|
|
result = super().sync_from_ui(orders)
|
|
|
|
# all orders in a single sync_from_ui call will belong to the same session
|
|
if (
|
|
len(orders) > 0
|
|
and orders[0].get("session_id")
|
|
and self.env["pos.session"].browse(orders[0]["session_id"]).config_id.l10n_br_is_nfce
|
|
):
|
|
paid_orders = [order["id"] for order in result["pos.order"] if order["state"] == "paid"]
|
|
self.env["pos.order"].browse(paid_orders)._l10n_br_do_edi()
|
|
|
|
for order in result["pos.order"]:
|
|
extra_fields_needed_in_pos = [
|
|
"l10n_br_last_avatax_status",
|
|
"l10n_br_access_key",
|
|
"l10n_br_edi_avatax_data",
|
|
"l10n_br_edi_protocol_authorization_number",
|
|
"l10n_br_edi_authorization_date",
|
|
"l10n_br_avatax_error",
|
|
]
|
|
order_db = self.env["pos.order"].browse(order["id"])
|
|
order.update(order_db.read(extra_fields_needed_in_pos)[0])
|
|
|
|
return result
|
|
|
|
def _l10n_br_call_avatax_taxes(self, company, document_data):
|
|
"""Override to store the retrieved Avatax data."""
|
|
api_response = super()._l10n_br_call_avatax_taxes(company, document_data)
|
|
self.l10n_br_edi_avatax_data = {
|
|
"header": api_response.get("header"),
|
|
"lines": api_response.get("lines"),
|
|
"summary": api_response.get("summary"),
|
|
}
|
|
return api_response
|
|
|
|
def _l10n_br_edi_get_tax_data(self):
|
|
"""Copy of account.move. Due to Avalara bugs they're unable to resolve we have to change their tax calculation response before
|
|
sending it back to them. This returns a tuple with what to include in the request ("lines" and "summary")
|
|
and the header (separate because it shouldn't be included)."""
|
|
# These return errors when null in /v3/invoices
|
|
keys_to_remove_when_null = ("ruleId", "ruleCode")
|
|
|
|
tax_calculation_response = self.l10n_br_edi_avatax_data
|
|
for line in tax_calculation_response.get("lines", []):
|
|
for detail in line.get("taxDetails", []):
|
|
for key in keys_to_remove_when_null:
|
|
if key in detail and detail[key] is None:
|
|
del detail[key]
|
|
|
|
return tax_calculation_response, tax_calculation_response.pop("header")
|
|
|
|
def _l10n_br_edi_get_goods_values(self):
|
|
"""Returns the appropriate (finNFe, goal) tuple for the goods section in the header."""
|
|
return 1, "Normal"
|
|
|
|
def _l10n_br_prepare_payment_info(self):
|
|
"""Based on _l10n_br_prepare_payment_mode of account.move. The sum of all payment "value" must equal the order total.
|
|
Because of this, we add payment methods until we reach the total. The remaining ones cannot be sent."""
|
|
card_methods = {"03", "04", "10", "11", "12", "13", "15", "17", "18"}
|
|
payment_modes = []
|
|
remaining_to_pay = self.amount_total
|
|
|
|
for payment in self.payment_ids.filtered(lambda payment: not payment.is_change):
|
|
if remaining_to_pay <= 0:
|
|
break
|
|
|
|
payment_method = payment.payment_method_id.l10n_br_payment_method
|
|
payment_mode = {
|
|
"mode": payment_method,
|
|
"value": min(remaining_to_pay, payment.amount),
|
|
}
|
|
remaining_to_pay = self.currency_id.round(remaining_to_pay - payment.amount)
|
|
|
|
if payment_method in card_methods:
|
|
payment_mode["cardTpIntegration"] = "2"
|
|
elif payment_method == "99":
|
|
payment_mode["modeDescription"] = _("Other")
|
|
|
|
payment_modes.append(payment_mode)
|
|
|
|
if not payment_modes:
|
|
# Always have a payment mode value if the order is free.
|
|
payment_modes.append({"mode": "99", "value": 0.0, "modeDescription": _("Other")})
|
|
|
|
return {
|
|
"change": self.currency_id.round(sum(self.payment_ids.filtered("is_change").mapped(lambda payment: -payment.amount))),
|
|
"paymentMode": payment_modes,
|
|
}
|
|
|
|
@api.model
|
|
def _l10n_br_get_location_dict(self, partner):
|
|
"""Copy of account.move."""
|
|
return {
|
|
"name": partner.name,
|
|
"businessName": partner.name,
|
|
"type": self._l10n_br_get_partner_type(partner),
|
|
"federalTaxId": partner.vat,
|
|
"cityTaxId": partner.l10n_br_im_code,
|
|
"stateTaxId": partner.l10n_br_ie_code,
|
|
"suframa": partner.l10n_br_isuf_code,
|
|
"address": {
|
|
"neighborhood": partner.street2,
|
|
"street": partner.street_name,
|
|
"zipcode": partner.zip,
|
|
"cityName": partner.city,
|
|
"state": partner.state_id.code,
|
|
"countryCode": partner.country_id.l10n_br_edi_code,
|
|
"number": partner.street_number,
|
|
"complement": partner.street_number2,
|
|
"phone": partner.phone,
|
|
"email": partner.email,
|
|
},
|
|
}
|
|
|
|
def _l10n_br_get_anonymous_location_dict(self, company_partner):
|
|
return {
|
|
"name": "CONSUMIDOR NÃO IDENTIFICADO",
|
|
"federalTaxId": "000.000.000-00",
|
|
"taxRegime": "individual",
|
|
"taxesSettings": {
|
|
"icmsTaxPayer": False,
|
|
},
|
|
"activitySector": {"code": "finalConsumer"},
|
|
"address": {
|
|
"state": company_partner.state_id.code,
|
|
},
|
|
}
|
|
|
|
def _l10n_br_calculate_access_key_check_digit(self, access_key):
|
|
"""Calculate the check digit for an access key using a slight variation of the MOD 11 algorithm (0 if remainder <2)."""
|
|
assert len(access_key) == 43
|
|
weighted_sum = 0
|
|
weight = 2
|
|
for char in reversed(access_key):
|
|
weighted_sum += int(char) * weight
|
|
weight += 1
|
|
if weight > 9:
|
|
weight = 2
|
|
|
|
remainder = weighted_sum % 11
|
|
return 0 if remainder < 2 else (11 - remainder)
|
|
|
|
def _l10n_br_get_cuf(self, state):
|
|
# From http://wiki.webcgi.com.br:49735/index.php?title=C%C3%B3digos_de_cada_UF_no_Brasil_-_IBGE
|
|
return {
|
|
"RO": "11",
|
|
"AC": "12",
|
|
"AM": "13",
|
|
"RR": "14",
|
|
"PA": "15",
|
|
"AP": "16",
|
|
"TO": "17",
|
|
"MA": "21",
|
|
"PI": "22",
|
|
"CE": "23",
|
|
"RN": "24",
|
|
"PB": "25",
|
|
"PE": "26",
|
|
"AL": "27",
|
|
"SE": "28",
|
|
"BA": "29",
|
|
"MG": "31",
|
|
"ES": "32",
|
|
"RJ": "33",
|
|
"SP": "35",
|
|
"PR": "41",
|
|
"SC": "42",
|
|
"RS": "43",
|
|
"MS": "50",
|
|
"MT": "51",
|
|
"GO": "52",
|
|
"DF": "53",
|
|
}[state.code]
|
|
|
|
def _l10n_br_get_id_for_cnf(self):
|
|
"""Allow this to be replaced by a fixed value in tests."""
|
|
return self.id
|
|
|
|
def _l10n_br_generate_access_key(self):
|
|
# If the database has been updated from pre 18.4, their vat may not be in the compacted mode.
|
|
compacted_vat = "".join(c for c in (self.company_id.partner_id.vat or '') if c.isdigit())
|
|
access_key = "".join(
|
|
[
|
|
f"{self._l10n_br_get_cuf(self.company_id.state_id):2.2}",
|
|
f"{self.date_order.strftime('%y%m'):4.4}", # dhEmi
|
|
f"{compacted_vat:14.14}", # Emitter CNPJ
|
|
f"{self.env.ref('l10n_br.dt_65').code:>02.2}", # mod (NFC-e)
|
|
f"{self.config_id.l10n_br_invoice_serial:>03.3}", # serie
|
|
self.l10n_br_edi_number, # nNF (9 characters)
|
|
"1", # tpEmis, hardcoded to the "normal" type
|
|
# cNF, an ID that uniquely identifies the order. Use the 8 least significant digits of the database ID.
|
|
f"{self._l10n_br_get_id_for_cnf():08}"[-8:],
|
|
]
|
|
)
|
|
|
|
return f"{access_key}{self._l10n_br_calculate_access_key_check_digit(access_key)}"
|
|
|
|
def _l10n_br_generate_nfce_qr_code(self, access_key):
|
|
assert len(access_key) == 44
|
|
|
|
# Already checked with RedirectWarning in _check_before_creating_new_session(), but check again to avoid
|
|
# potential ugly traceback.
|
|
if not self.company_id.l10n_br_edi_csc_identifier or not self.company_id.l10n_br_edi_csc_number:
|
|
raise ValidationError(
|
|
_("Please configure a CSC ID and CSC number in the Accounting settings.")
|
|
) # RedirectWarning not supported in POS.
|
|
|
|
qr_code_content = "|".join(
|
|
[
|
|
access_key,
|
|
"2", # QR code version
|
|
"1" if self.company_id.l10n_br_avalara_environment == "production" else "2",
|
|
self.company_id.l10n_br_edi_csc_identifier.lstrip("0"),
|
|
]
|
|
)
|
|
|
|
to_hash = qr_code_content + self.company_id.l10n_br_edi_csc_number
|
|
qr_code_content += "|" + sha1(to_hash.encode()).hexdigest()
|
|
return self._l10n_br_get_qr_url() + qr_code_content
|
|
|
|
def _l10n_br_get_url_key(self):
|
|
if self.company_id.l10n_br_edi_url_key_override:
|
|
return self.company_id.l10n_br_edi_url_key_override
|
|
|
|
state_code = self.company_id.state_id.code
|
|
|
|
# The exact format of these URLs is important, even though most redirect http:// to https:// changing it to https://
|
|
# here results in errors:
|
|
# "Endereco do site da UF da Consulta por chave de acesso diverge do previsto. [Nova url:http://www.fazenda.pr.gov.br/nfce/consulta]"
|
|
return {
|
|
"AC": ("http://www.sefaznet.ac.gov.br/nfce/consulta",),
|
|
"AL": ("www.sefaz.al.gov.br/nfce/consulta",),
|
|
"AM": ("www.sefaz.am.gov.br/nfce/consulta",),
|
|
"AP": ("https://www.sefaz.ap.gov.br/sate/seg/SEGf_AcessarFuncao.jsp?cdFuncao=FIS_1261",),
|
|
"BA": ("https://www.sefaz.ba.gov.br/nfce/consulta", "http://hinternet.sefaz.ba.gov.br/nfce/consulta"),
|
|
"CE": ("http://www.sefaz.ce.gov.br/nfce/consulta",),
|
|
"DF": ("www.fazenda.df.gov.br/nfce/consulta",),
|
|
"ES": ("http://www.sefaz.es.gov.br/nfce/consulta",),
|
|
"GO": ("http://www.sefaz.go.gov.br/nfce/consulta",),
|
|
"MA": ("http://www.nfce.sefaz.ma.gov.br/portal/consultarnfce.jsp",),
|
|
"MG": ("http://nfce.fazenda.mg.gov.br/portalnfce", "http://hinternet.sefaz.ba.gov.br/nfce/consulta"),
|
|
"MS": ("http://www.dfe.ms.gov.br/nfce/consulta",),
|
|
"MT": ("http://www.sefaz.mt.gov.br/nfce/consultanfce", "http://homologacao.sefaz.mt.gov.br/nfce/consultanfce"),
|
|
"PA": ("http://www.sefa.pa.gov.br/nfce/consulta",),
|
|
"PB": ("https://www7.sefaz.pb.gov.br/atf/seg/SEGf_AcessarFuncao.jsp?cdFuncao=FIS_1410&p=",),
|
|
"PE": ("http://nfce.sefaz.pe.gov.br/nfce/consulta",),
|
|
"PI": ("https://webas.sefaz.pi.gov.br/nfe/",),
|
|
"PR": ("http://www.fazenda.pr.gov.br/nfce/consulta",),
|
|
"RJ": ("http://www.fazenda.rj.gov.br/nfce/consulta",),
|
|
"RN": ("https://nfce.set.rn.gov.br/portalDFE/NFCe/ConsultaNFCe.aspx",),
|
|
"RO": ("http://www.sefin.ro.gov.br/nfce/consulta",),
|
|
"RR": ("https://portalweb.sefaz.rr.gov.br/nfce/servlet/wp_consulta_nfce",),
|
|
"RS": ("http://www.sefaz.rs.gov.br/nfce/consulta",),
|
|
"SC": ("https://sat.sef.sc.gov.br/tax.net/Sat.Dfe.NFCe.Web/Consultas/ConsultaPublicaNFe.aspx",),
|
|
"SE": ("http://www.nfce.se.gov.br/nfce/consulta", "http://www.hom.nfe.se.gov.br/nfce/consulta"),
|
|
"SP": ("https://www.nfce.fazenda.sp.gov.br/consulta", "https://www.homologacao.nfce.fazenda.sp.gov.br/consulta"),
|
|
"TO": ("http://www.sefaz.to.gov.br/nfce/consulta",),
|
|
}[state_code][0 if self.company_id.l10n_br_avalara_environment == "production" else -1]
|
|
|
|
def _l10n_br_get_qr_url(self):
|
|
if self.company_id.l10n_br_edi_qr_url_override:
|
|
return self.company_id.l10n_br_edi_qr_url_override
|
|
|
|
state_code = self.company_id.state_id.code
|
|
return {
|
|
"AC": ("http://www.sefaznet.ac.gov.br/nfce/qrcode?p=", "http://www.hml.sefaznet.ac.gov.br/nfce/qrcode?p="),
|
|
"AL": ("http://nfce.sefaz.al.gov.br/QRCode/consultarNFCe.jsp?p=",),
|
|
"AM": ("https://sistemas.sefaz.am.gov.br/nfceweb/consultarNFCe.jsp?p=",),
|
|
"AP": ("https://www.sefaz.ap.gov.br/nfce/nfcep.php?p=", "https://www.sefaz.ap.gov.br/nfcehml/nfce.php?p="),
|
|
"BA": ("http://nfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?p=", "http://hnfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?p="),
|
|
"CE": ("http://nfce.sefaz.ce.gov.br/pages/ShowNFCe.html?p=", "http://nfceh.sefaz.ce.gov.br/pages/ShowNFCe.html?p="),
|
|
"DF": ("http://www.fazenda.df.gov.br/nfce/qrcode?p=",),
|
|
"ES": ("http://app.sefaz.es.gov.br/ConsultaNFCe?p=", "http://homologacao.sefaz.es.gov.br/ConsultaNFCe?p="),
|
|
"GO": ("https://nfeweb.sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?p=", "https://nfewebhomolog.sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?p="),
|
|
"MA": ("nfce.sefaz.ma.gov.br/portal/consultarNFCe.jsp?p=", "homolog acao.sefaz.ma.gov.br/portal/consultarNFCe.jsp?p="),
|
|
"MG": ("https://portalsped.fazenda.mg.gov.br/portalnfce/sistema/qrcode.xhtml?p=",),
|
|
"MS": ("http://www.dfe.ms.gov.br/nfce/qrcode?p=",),
|
|
"MT": ("http://www.sefaz.mt.gov.br/nfce/consultanfce?p=", "http://homologacao.sefaz.mt.gov.br/nfce/consultanfce?p="),
|
|
"PA": ("https://appnfc.sefa.pa.gov.br/portal/view/consultas/nfce/nfceForm.seam?p=", "https://appnfc.sefa.pa.gov.br/portal-homologacao/view/consultas/nfce/nfceForm.seam?p="),
|
|
"PB": ("http://www.sefaz.pb.gov.br/nfce?p=", "http://www.sefaz.pb.gov.br/nfcehom?p="),
|
|
"PE": ("http://nfce.sefaz.pe.gov.br/nfce/consulta?p=", "http://nfcehomolog.sefaz.pe.gov.br/nfce/consulta?p="),
|
|
"PI": ("http://www.sefaz.pi.gov.br/nfce/qrcode?p=",),
|
|
"PR": ("http://www.fazenda.pr.gov.br/nfce/qrcode?p=",),
|
|
"RJ": ("https://consultadfe.fazenda.rj.gov.br/consultaNFCe/QRCode?p=",),
|
|
"RN": ("http://nfce.set.rn.gov.br/consultarNFCe.aspx?p=", "http://hom.nfce.set.rn.gov.br/consultarNFCe.aspx?p="),
|
|
"RO": ("http://www.nfce.sefin.ro.gov.br/consultanfce/consulta.jsp?p=",),
|
|
"RR": ("https://www.sefaz.rr.gov.br/nfce/servlet/qrcode?p=", "http://200.174.88.103:8080/nfce/servlet/qrcode?p="),
|
|
"RS": ("https://www.sefaz.rs.gov.br/NFCE/NFCE-COM.aspx?p=",),
|
|
"SC": ("https://sat.sef.sc.gov.br/nfce/consulta?p=", "https://hom.sat.sef.sc.gov.br/nfce/consulta?p="),
|
|
"SE": ("http://www.nfce.se.gov.br/nfce/qrcode?p=", "http://www.hom.nfe.se.gov.br/nfce/qrcode?p="),
|
|
"SP": ("https://www.nfce.fazenda.sp.gov.br/qrcode?p=", "https://www.homologacao.nfce.fazenda.sp.gov.br/qrcode?p="),
|
|
"TO": ("http://www.sefaz.to.gov.br/nfce/qrcode?p=",),
|
|
}[state_code][0 if self.company_id.l10n_br_avalara_environment == "production" else -1]
|
|
|
|
@api.model
|
|
def _prepare_l10n_br_avatax_document_service_call(self, params):
|
|
# EXTENDS 'account.external.tax.mixin'
|
|
payload = super()._prepare_l10n_br_avatax_document_service_call(params)
|
|
customer = params['partner']
|
|
company_partner = params['company_partner']
|
|
extra_payload = {
|
|
"header": {
|
|
"companyLocation": company_partner.vat,
|
|
"invoiceNumber": params['edi_number'],
|
|
"invoiceSerial": params['invoice_serial'],
|
|
"locations": {
|
|
"entity": (
|
|
self._l10n_br_get_location_dict(customer)
|
|
if customer
|
|
else self._l10n_br_get_anonymous_location_dict(company_partner)
|
|
),
|
|
"establishment": self._l10n_br_get_location_dict(company_partner),
|
|
},
|
|
"goods": {
|
|
"model": self.env.ref("l10n_br.dt_65").code,
|
|
"tplmp": "4", # DANFe NFC-e
|
|
"goal": "Normal",
|
|
"finNFe": 1,
|
|
"urlKey": params['url_key'],
|
|
"nfceQrCode": params['nfce_qr_code'],
|
|
"tpImp": "4",
|
|
"indPres": "1", # An in-person transaction.
|
|
"transport": {
|
|
"modFreight": "FreeShipping",
|
|
},
|
|
},
|
|
"payment": {
|
|
"paymentInfo": self._l10n_br_prepare_payment_info(),
|
|
},
|
|
"invoiceAccessKey": params['access_key'],
|
|
},
|
|
}
|
|
|
|
for line in payload["lines"]:
|
|
line.pop("freightAmount")
|
|
|
|
if not customer:
|
|
payload["header"]["locations"].pop("entity")
|
|
|
|
# extra_payload is cleaned before it's used to avoid e.g. "cityName": False or "number": "". These make
|
|
# Avatax return various errors: e.g. "Falha na estrutura enviada". This is to avoid having lots of if
|
|
# statements.
|
|
deep_update(payload, deep_clean(extra_payload))
|
|
return payload
|
|
|
|
def _l10n_br_prepare_invoice_payload(self):
|
|
errors = []
|
|
|
|
# Don't raise because we don't want to block the POS
|
|
try:
|
|
# The /transaction payload requires a superset of the /calculate payload we use for tax calculation.
|
|
service_params = self._get_l10n_br_avatax_service_params()
|
|
payload = self._prepare_l10n_br_avatax_document_service_call(service_params)
|
|
except (UserError, ValidationError) as e:
|
|
payload = {}
|
|
errors.append(str(e).replace("- ", ""))
|
|
|
|
tax_data_to_include, tax_data_header = self._l10n_br_edi_get_tax_data()
|
|
payload["header"]["goods"]["class"] = tax_data_header.get("goods", {}).get("class")
|
|
|
|
# This adds the "lines" and "summary" dicts received during tax calculation.
|
|
payload.update(tax_data_to_include)
|
|
return payload, errors
|
|
|
|
def _l10n_br_get_error_from_response(self, response):
|
|
"""Copy of account.move."""
|
|
if error := response.get("error"):
|
|
return f"Code {error['code']}: {error['message']}"
|
|
|
|
def _l10n_br_submit_invoice(self, payload):
|
|
try:
|
|
response = self._l10n_br_iap_request("submit_invoice_goods", self.company_id, payload)
|
|
return response, self._l10n_br_get_error_from_response(response)
|
|
except (UserError, InsufficientCreditError) as e:
|
|
# These exceptions can be thrown by iap_jsonrpc()
|
|
return None, str(e)
|
|
|
|
def _l10n_br_edi_attachments_from_response(self, response, save_avalara_pdf=False):
|
|
"""Copy of account.move. Link these attachments to fields to restrict them to who can access pos.order (enforced
|
|
by ir.attachment's check())."""
|
|
attachments = self.env["ir.attachment"].create(
|
|
{
|
|
"res_model": "pos.order",
|
|
"res_id": self.id,
|
|
"res_field": "l10n_br_edi_xml_attachment_file",
|
|
"name": f"{self.name}_edi.xml",
|
|
"datas": response["xml"]["base64"],
|
|
}
|
|
)
|
|
|
|
if save_avalara_pdf:
|
|
attachments |= self.env["ir.attachment"].create(
|
|
{
|
|
"res_model": "pos.order",
|
|
"res_id": self.id,
|
|
"res_field": "l10n_br_edi_pdf_attachment_file",
|
|
"name": f"{self.name}_edi.pdf",
|
|
"datas": response["pdf"]["base64"],
|
|
}
|
|
)
|
|
|
|
return attachments
|
|
|
|
def _l10n_br_edi_get_successful_message(self, adjustment_move=None):
|
|
"""Return tax summary for EDI that happens after the session is closed. This way the user can manually
|
|
correct the accounting."""
|
|
msg = _("E-invoice submitted successfully.")
|
|
if not adjustment_move:
|
|
return msg
|
|
|
|
return msg + Markup(
|
|
' Adjusted taxes in <a href="#" data-oe-model="account.move" data-oe-id="%(move_id)s">%(move_name)s</a>.'
|
|
) % {
|
|
"move_id": adjustment_move.id,
|
|
"move_name": adjustment_move.name,
|
|
}
|
|
|
|
def _l10n_br_find_tax_for_l10n_br_avatax_code(self, l10n_br_avatax_code):
|
|
return (
|
|
self.env["account.tax"]
|
|
.with_context(active_test=False)
|
|
.search(
|
|
[
|
|
("l10n_br_avatax_code", "=", l10n_br_avatax_code),
|
|
("price_include_override", "=", "tax_included"),
|
|
("company_id", "=", self.company_id.id),
|
|
("type_tax_use", "=", "sale"),
|
|
],
|
|
limit=1,
|
|
order="create_date desc, id desc",
|
|
)
|
|
)
|
|
|
|
def _l10n_br_edi_adjustment_entry(self):
|
|
"""Failed EDI orders will have no taxes. If the session is already closed, this will be reflected in the closing
|
|
journal entry. This method will be called in cases where EDI is successfully retried on orders belonging to closed
|
|
sessions.
|
|
|
|
This method will create a journal entry that will subtract the newly calculated taxes from the income
|
|
account(s) (because previously the entire order amount was an Untaxed Sale) and adds the tax amounts to the
|
|
appropriate tax accounts."""
|
|
if self.session_id.state != "closed":
|
|
return
|
|
|
|
# Only depends on order_id and order_id.fiscal_position_id, so it's not accurate if tax calculation changed taxes.
|
|
self.lines.invalidate_recordset(["tax_ids_after_fiscal_position"])
|
|
product_id_to_taxes = defaultdict(float)
|
|
adjustment_lines_vals = []
|
|
aml_values_list = self._prepare_aml_values_list_per_nature()
|
|
|
|
# Figure out how much total tax to remove per product.
|
|
for avatax_line in self.l10n_br_edi_avatax_data["lines"]:
|
|
tax_amount = avatax_line["lineAmount"] - avatax_line["lineNetFigure"]
|
|
line = self.env["pos.order.line"].browse(avatax_line["lineCode"])
|
|
product_id_to_taxes[line.product_id.id] += tax_amount
|
|
|
|
# Set the removed amounts calculated above on the right product AML.
|
|
for aml_values in aml_values_list["product"]:
|
|
amount = product_id_to_taxes.get(aml_values["product_id"])
|
|
if amount is not None:
|
|
aml_values["amount_currency"] = aml_values["balance"] = amount
|
|
|
|
adjustment_lines_vals.extend(aml_values_list["product"])
|
|
|
|
# Adjust the tax amounts to what Avatax returned, don't rely on what Odoo calculated.
|
|
for l10n_br_avatax_code, details in self.l10n_br_edi_avatax_data["summary"]["taxByType"].items():
|
|
for aml_values in aml_values_list["tax"]:
|
|
tax = self._l10n_br_find_tax_for_l10n_br_avatax_code(l10n_br_avatax_code)
|
|
if self.env["account.tax.repartition.line"].browse(aml_values["tax_repartition_line_id"]).tax_id == tax:
|
|
aml_values["amount_currency"] = aml_values["balance"] = -details["tax"]
|
|
aml_values["tax_base_amount"] = -details["subtotalTaxable"] + details["tax"]
|
|
|
|
adjustment_lines_vals.extend(aml_values_list["tax"])
|
|
|
|
adjustment_move = self.env["account.move"].create(
|
|
{
|
|
"ref": _("Tax adjustment for %(order_name)s", order_name=self.name),
|
|
"journal_id": self.config_id.journal_id.id,
|
|
"line_ids": [Command.create(line_vals) for line_vals in adjustment_lines_vals],
|
|
}
|
|
)
|
|
adjustment_move._post()
|
|
|
|
return adjustment_move
|
|
|
|
def _l10n_br_edi_log_taxes(self):
|
|
"""There's no tax breakdown per line or order. So, we log the taxes here manually instead."""
|
|
message = []
|
|
has_informative_tax = False
|
|
for line in self.l10n_br_edi_avatax_data.get('lines', []):
|
|
tax_descriptions = []
|
|
for tax_detail in line.get('taxDetails', []):
|
|
tax = self.env['account.tax'].with_context(active_test=False).search([('l10n_br_avatax_code', '=', tax_detail['taxType'])], limit=1)
|
|
is_informative = tax_detail['taxImpact']['impactOnNetAmount'] == 'Informative'
|
|
has_informative_tax = has_informative_tax or is_informative
|
|
tax_descriptions.append(Markup("{tax_name}{informative} - {tax_amount}").format(
|
|
tax_name=tax.display_name or tax_detail['taxType'],
|
|
tax_amount=self.currency_id.format(tax_detail['tax']),
|
|
informative=' (*)' if is_informative else ''
|
|
))
|
|
|
|
message.append(Markup("<b>{line_name}</b><br/>{tax_descriptions}").format(
|
|
line_name=line['itemDescriptor'].get('description'),
|
|
tax_descriptions=Markup("<br/>").join(tax_descriptions))
|
|
)
|
|
|
|
if has_informative_tax:
|
|
message.append(Markup("<i> *: ") + _("informative tax") + Markup("</i>"))
|
|
|
|
self.message_post(body=Markup("<hr/>").join(message))
|
|
|
|
def _l10n_br_edi_send(self, save_avalara_pdf=False):
|
|
"""Sends the e-invoice and returns an array of error strings."""
|
|
for order in self:
|
|
payload, validation_errors = order._l10n_br_prepare_invoice_payload()
|
|
|
|
if validation_errors:
|
|
return validation_errors
|
|
else:
|
|
response, api_error = order._l10n_br_submit_invoice(payload)
|
|
if api_error:
|
|
order.l10n_br_last_avatax_status = "error"
|
|
return [api_error]
|
|
else:
|
|
adjustment_move = order._l10n_br_edi_adjustment_entry()
|
|
message = order._l10n_br_edi_get_successful_message(adjustment_move)
|
|
|
|
order.l10n_br_avatax_error = False
|
|
order.l10n_br_last_avatax_status = "accepted"
|
|
order.l10n_br_access_key = response.get("key")
|
|
|
|
status = response.get("status", {})
|
|
order.l10n_br_edi_protocol_authorization_number = status.get("protocol")
|
|
order.l10n_br_edi_authorization_date = status.get("authorizationDateTime")
|
|
order.l10n_br_edi_series = status.get("serial")
|
|
|
|
order._l10n_br_edi_log_taxes()
|
|
order.message_post(
|
|
body=message,
|
|
attachment_ids=order._l10n_br_edi_attachments_from_response(
|
|
response, save_avalara_pdf=save_avalara_pdf
|
|
).ids,
|
|
)
|
|
|
|
def _l10n_br_edi_action_download(self):
|
|
valid_orders = self.filtered(lambda o: o.l10n_br_edi_xml_attachment_file and o.l10n_br_last_avatax_status == "accepted")
|
|
if len(valid_orders) != len(self):
|
|
invalid_orders = (self - valid_orders).mapped('name')
|
|
raise UserError(self.env._(
|
|
"Oops! Some orders are not ready for XML download: %(orders)s\n"
|
|
"Please make sure all selected orders have been accepted by SEFAZ before proceeding."
|
|
, orders=format_list(self.env, invalid_orders)
|
|
))
|
|
attachments = self.env['ir.attachment'].search([
|
|
('res_model', '=', 'pos.order'),
|
|
('res_id', 'in', valid_orders.ids),
|
|
('res_field', '=', 'l10n_br_edi_xml_attachment_file'),
|
|
])
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'url': f'/l10n_br_edi_pos/download_nfce_attachments/{",".join(map(str, attachments.ids))}',
|
|
'target': 'self',
|
|
}
|
|
|
|
def get_reference_last_part(self):
|
|
if self.config_id.company_id.account_fiscal_country_id == self.env.ref('base.br') and self.config_id.l10n_br_is_nfce:
|
|
return self.sequence_number
|
|
return super().get_reference_last_part()
|