964 lines
42 KiB
Python
964 lines
42 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
|
|
from markupsafe import Markup
|
|
from stdnum.br.cnpj import format as format_cnpj
|
|
from stdnum.br.cpf import format as format_cpf
|
|
|
|
from odoo import models, fields, api, _, Command
|
|
from odoo.addons.iap import InsufficientCreditError
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import html2plaintext
|
|
from odoo.tools.xml_utils import find_xml_value
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
FREIGHT_MODEL_SELECTION = [
|
|
("CIF", "Freight contracting on behalf of the Sender (CIF)"),
|
|
("FOB", "Contracting of freight on behalf of the recipient/sender (FOB)"),
|
|
("Thirdparty", "Contracting Freight on behalf of third parties"),
|
|
("SenderVehicle", "Own transport on behalf of the sender"),
|
|
("ReceiverVehicle", "Own transport on behalf of the recipient"),
|
|
("FreeShipping", "Free shipping"),
|
|
]
|
|
|
|
PAYMENT_METHOD_SELECTION = [
|
|
("01", "Money"),
|
|
("02", "Check"),
|
|
("03", "Credit Card"),
|
|
("04", "Debit Card"),
|
|
("05", "Store Credit"),
|
|
("10", "Food voucher"),
|
|
("11", "Meal Voucher"),
|
|
("12", "Gift certificate"),
|
|
("13", "Fuel Voucher"),
|
|
("14", "Duplicate Mercantil"),
|
|
("15", "Boleto Bancario"),
|
|
("16", "Bank Deposit"),
|
|
("17", "Instant Payment (PIX)"),
|
|
("18", "Bank transfer, Digital Wallet"),
|
|
("19", "Loyalty program, Cashback, Virtual Credit"),
|
|
("90", "No Payment"),
|
|
("99", "Others"),
|
|
]
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = "account.move"
|
|
|
|
l10n_br_edi_avatax_data = fields.Text(
|
|
help="Brazil: technical field that remembers the last tax summary returned by Avatax.", copy=False
|
|
)
|
|
l10n_br_edi_is_needed = fields.Boolean(
|
|
compute="_compute_l10n_br_edi_is_needed",
|
|
help="Brazil: technical field to determine if this invoice is eligible to be e-invoiced.",
|
|
)
|
|
l10n_br_edi_transporter_id = fields.Many2one(
|
|
"res.partner",
|
|
"Transporter Brazil",
|
|
help="Brazil: if you use a transport company, add its company contact here.",
|
|
)
|
|
l10n_br_edi_freight_model = fields.Selection(
|
|
FREIGHT_MODEL_SELECTION,
|
|
string="Freight Model",
|
|
help="Brazil: used to determine the freight model used on this transaction.",
|
|
)
|
|
l10n_br_edi_payment_method = fields.Selection(
|
|
PAYMENT_METHOD_SELECTION,
|
|
string="Payment Method Brazil",
|
|
help="Brazil: expected payment method to be used.",
|
|
)
|
|
l10n_br_access_key = fields.Char(
|
|
"Access Key",
|
|
copy=False,
|
|
help="Brazil: access key associated with the electronic document. Can be used to get invoice information directly from the government.",
|
|
)
|
|
l10n_br_edi_error = fields.Text(
|
|
"Brazil E-Invoice Error",
|
|
copy=False,
|
|
readonly=True,
|
|
help="Brazil: error details for invoices in the 'error' state.",
|
|
)
|
|
l10n_br_last_edi_status = fields.Selection(
|
|
[
|
|
("pending", "Pending"),
|
|
("accepted", "Accepted"),
|
|
("error", "Error"),
|
|
("cancelled", "Cancelled"),
|
|
],
|
|
string="Brazil E-Invoice Status",
|
|
copy=False,
|
|
tracking=True,
|
|
readonly=True,
|
|
help="Brazil: the state of the most recent e-invoicing attempt.",
|
|
)
|
|
l10n_br_edi_xml_attachment_file = fields.Binary(
|
|
string="Brazil E-Invoice XML File",
|
|
copy=False,
|
|
attachment=True,
|
|
help="Brazil: technical field holding the e-invoice XML data for security reasons.",
|
|
)
|
|
l10n_br_edi_xml_attachment_id = fields.Many2one(
|
|
"ir.attachment",
|
|
string="Brazil E-Invoice XML",
|
|
compute=lambda self: self._compute_linked_attachment_id(
|
|
"l10n_br_edi_xml_attachment_id", "l10n_br_edi_xml_attachment_file"
|
|
),
|
|
depends=["l10n_br_edi_xml_attachment_file"],
|
|
help="Brazil: the most recent e-invoice XML returned by Avalara.",
|
|
)
|
|
l10n_br_edi_last_correction_number = fields.Integer(
|
|
"Brazil Correction Number",
|
|
readonly=True,
|
|
copy=False,
|
|
help="Brazil: technical field that holds the latest correction that happened to this invoice",
|
|
)
|
|
l10n_br_nfse_number = fields.Char(
|
|
"NFS-e Number",
|
|
help="Brazil: After an NFS-e invoice is issued and confirmed by the municipality, an NFS-e number is provided.",
|
|
)
|
|
l10n_br_nfse_verification = fields.Char(
|
|
"NFS-e Verification Code",
|
|
help="Brazil: After an NFS-e invoice is issued and confirmed by the municipality, a unique code is provided for online verification of its authenticity.",
|
|
)
|
|
|
|
def _l10n_br_call_avatax_taxes(self, company, document_data):
|
|
# EXTENDS 'account.external.tax.mixin' to store the retrieved Avatax data.
|
|
api_response = super()._l10n_br_call_avatax_taxes(company, document_data)
|
|
|
|
# Avalara doesn't respect the CFOP we send in tax calculation, they always return their own. So we choose not to send
|
|
# it in tax calculation at all (it shouldn't affect taxes). We use this tax calculation response to build the EDI
|
|
# request, and so let's fix it here before storing it. That way it won't need hacks to make l10n_br_cfop work on
|
|
# account.move.line.
|
|
lines = api_response.get("lines", [])
|
|
aml_ids = [line['lineCode'] for line in lines]
|
|
amls = self.env["account.move.line"].browse(aml_ids)
|
|
for line, aml in zip(lines, amls):
|
|
goods_operation_type = aml.l10n_br_goods_operation_type_id or self.l10n_br_goods_operation_type_id
|
|
if cfop := goods_operation_type.l10n_br_cfop_code:
|
|
with contextlib.suppress(ValueError): # To be defensive, already checked with constrains.
|
|
line["cfop"] = int(cfop)
|
|
|
|
self.l10n_br_edi_avatax_data = json.dumps(
|
|
{
|
|
"header": api_response.get("header"),
|
|
"lines": lines,
|
|
"summary": api_response.get("summary"),
|
|
}
|
|
)
|
|
|
|
return api_response
|
|
|
|
@api.depends("l10n_br_last_edi_status")
|
|
def _compute_show_reset_to_draft_button(self):
|
|
"""Override. Don't show resetting to draft when the invoice is pending. It's already been sent and the user
|
|
should wait for the final result of that."""
|
|
super()._compute_show_reset_to_draft_button()
|
|
self.filtered(lambda move: move.l10n_br_last_edi_status == "pending").show_reset_to_draft_button = False
|
|
|
|
@api.depends("l10n_br_last_edi_status", "country_code", "company_currency_id", "move_type", "fiscal_position_id", "journal_id.l10n_br_invoice_serial")
|
|
def _compute_l10n_br_edi_is_needed(self):
|
|
for move in self:
|
|
move.l10n_br_edi_is_needed = (
|
|
not move.l10n_br_last_edi_status
|
|
and move.country_code == "BR"
|
|
and (
|
|
move.move_type in ("out_invoice", "out_refund")
|
|
or (
|
|
move.move_type in ("in_invoice", "in_refund")
|
|
and move.journal_id.l10n_br_invoice_serial
|
|
)
|
|
)
|
|
and move.fiscal_position_id.l10n_br_is_avatax
|
|
)
|
|
|
|
@api.depends("l10n_br_last_edi_status")
|
|
def _compute_need_cancel_request(self):
|
|
# EXTENDS 'account' to add dependencies
|
|
super()._compute_need_cancel_request()
|
|
|
|
@api.depends('l10n_br_edi_is_needed')
|
|
def _compute_display_send_button(self):
|
|
# EXTENDS 'account' to display the "Send" button on unsent vendor bills
|
|
super()._compute_display_send_button()
|
|
for move in self:
|
|
# l10n_br_edi_is_needed filters the right move_types
|
|
if move.l10n_br_edi_is_needed:
|
|
move.display_send_button |= move.state == 'posted'
|
|
|
|
@api.depends('l10n_br_edi_is_needed')
|
|
def _compute_highlight_send_button(self):
|
|
# EXTENDS 'account' to highlight the "Send" button on unsent vendor bills
|
|
super()._compute_highlight_send_button()
|
|
for move in self:
|
|
# l10n_br_edi_is_needed filters the right move_types
|
|
if move.l10n_br_edi_is_needed:
|
|
move.highlight_send_button |= move.state == 'posted'
|
|
|
|
def _is_manual_document_number(self):
|
|
# EXTENDS 'l10n_latam_invoice_document' to automatically number purchase EDI journals
|
|
journal = self.journal_id
|
|
if journal.company_id.account_fiscal_country_id.code == 'BR' and journal.type == 'purchase' and journal.l10n_br_invoice_serial:
|
|
return False
|
|
return super()._is_manual_document_number()
|
|
|
|
def _l10n_br_edi_check_calculated_tax(self):
|
|
if self.state != "posted" or self.l10n_br_last_edi_status:
|
|
return {}
|
|
|
|
if not self.l10n_br_edi_avatax_data:
|
|
return {
|
|
"tax_not_calculated": {
|
|
"message": _('Tax has never been calculated on this invoice, please "Reset to Draft" and re-post.'),
|
|
"level": "danger",
|
|
}
|
|
}
|
|
|
|
return {}
|
|
|
|
def _l10n_br_edi_check_partners(self, partners):
|
|
if self.state != "posted": # only required for EDI
|
|
return {}
|
|
|
|
if not partners:
|
|
return {}
|
|
|
|
partners_missing_fields = self.env["res.partner"]
|
|
for partner in partners:
|
|
requires_minimal_info = (
|
|
self.l10n_br_is_service_transaction and
|
|
partner.l10n_br_tax_regime == "individual" and
|
|
partner.l10n_br_activity_sector == "finalConsumer" and
|
|
partner.l10n_latam_identification_type_id == self.env.ref("l10n_br.cpf")
|
|
)
|
|
required_fields = ("zip",) if requires_minimal_info else ("street", "street2", "zip", "vat", "l10n_latam_identification_type_id")
|
|
|
|
for field in required_fields:
|
|
if not partner[field]:
|
|
partners_missing_fields |= partner
|
|
|
|
if partners_missing_fields:
|
|
return {
|
|
"partners_missing_fields": {
|
|
"message": _(
|
|
"For Brazilian electronic invoicing, contacts must have a complete address, VAT number and identification type:\n%s",
|
|
partners_missing_fields.mapped('display_name'),
|
|
),
|
|
"action_text": _("View contacts"),
|
|
"action": partners_missing_fields._l10n_br_avatax_action_missing_fields(),
|
|
"level": "danger",
|
|
}
|
|
}
|
|
|
|
return {}
|
|
|
|
def _l10n_br_check_origin_access_key(self, service_params):
|
|
if origin := service_params['origin_record']:
|
|
if not origin.l10n_br_access_key:
|
|
return {
|
|
"origin_missing_access_key": {
|
|
"message": _(
|
|
"The originating invoice (%(origin_invoice)s) must have an access key before electronically invoicing %(current_invoice)s. The access key can be set manually or by electronically invoicing %(origin_invoice)s.",
|
|
origin_invoice=origin.display_name,
|
|
current_invoice=self.display_name,
|
|
),
|
|
"action_text": _("View invoice"),
|
|
"action": origin._get_records_action(),
|
|
"level": "danger",
|
|
}
|
|
}
|
|
|
|
return {}
|
|
|
|
@api.depends(lambda self: self._depends_l10n_br_avatax_warnings())
|
|
def _compute_l10n_br_avatax_warnings(self):
|
|
"""Override."""
|
|
super()._compute_l10n_br_avatax_warnings()
|
|
|
|
for move in self:
|
|
if not move.l10n_br_is_avatax:
|
|
move.l10n_br_avatax_warnings = False
|
|
continue
|
|
|
|
move.l10n_br_avatax_warnings = {
|
|
**(move.l10n_br_avatax_warnings or {}),
|
|
**move._l10n_br_edi_check_calculated_tax(),
|
|
**move._l10n_br_edi_check_partners(move.partner_id | move.company_id.partner_id | move._l10n_br_get_transporter()),
|
|
**move._l10n_br_check_origin_access_key(move._get_l10n_br_avatax_service_params()),
|
|
}
|
|
|
|
def _depends_l10n_br_avatax_warnings(self):
|
|
"""Override."""
|
|
return super()._depends_l10n_br_avatax_warnings() + ["partner_id", "l10n_br_edi_avatax_data", "l10n_br_edi_transporter_id"]
|
|
|
|
def _need_cancel_request(self):
|
|
# EXTENDS 'account'
|
|
return super()._need_cancel_request() or self.l10n_br_last_edi_status == "accepted"
|
|
|
|
def button_request_cancel(self):
|
|
# EXTENDS 'account'
|
|
if self._need_cancel_request() and self.l10n_br_last_edi_status == "accepted":
|
|
return {
|
|
"name": _("Fiscal Document Cancellation"),
|
|
"type": "ir.actions.act_window",
|
|
"view_type": "form",
|
|
"view_mode": "form",
|
|
"res_model": "l10n_br_edi.invoice.update",
|
|
"target": "new",
|
|
"context": {"default_move_id": self.id, "default_mode": "cancel"},
|
|
}
|
|
|
|
return super().button_request_cancel()
|
|
|
|
def button_draft(self):
|
|
# EXTENDS 'account'
|
|
self.write(
|
|
{
|
|
"l10n_br_last_edi_status": False,
|
|
"l10n_br_edi_error": False,
|
|
"l10n_br_edi_avatax_data": False,
|
|
}
|
|
)
|
|
return super().button_draft()
|
|
|
|
def button_cancel(self):
|
|
# EXTENDS 'account'
|
|
res = super().button_cancel()
|
|
self.l10n_br_last_edi_status = "cancelled"
|
|
return res
|
|
|
|
def _get_fields_to_detach(self):
|
|
# EXTENDS account
|
|
fields_list = super()._get_fields_to_detach()
|
|
fields_list.append('l10n_br_edi_xml_attachment_file')
|
|
return fields_list
|
|
|
|
def button_request_correction(self):
|
|
return {
|
|
"name": _("Fiscal Document Correction"),
|
|
"type": "ir.actions.act_window",
|
|
"view_type": "form",
|
|
"view_mode": "form",
|
|
"res_model": "l10n_br_edi.invoice.update",
|
|
"target": "new",
|
|
"context": {
|
|
"default_move_id": self.id,
|
|
"default_mode": "correct",
|
|
},
|
|
}
|
|
|
|
def button_l10n_br_edi_get_service_invoice(self):
|
|
"""Checks if the invoice received final approval from the government."""
|
|
if self.l10n_br_last_edi_status != "pending":
|
|
return
|
|
|
|
response = self._l10n_br_iap_request(
|
|
"get_invoice_services",
|
|
self.company_id,
|
|
{
|
|
"serie": self.journal_id.l10n_br_invoice_serial,
|
|
"number": self.l10n_latam_document_number,
|
|
},
|
|
)
|
|
if error := self._l10n_br_get_error_from_response(response):
|
|
self.l10n_br_last_edi_status = "error"
|
|
self.l10n_br_edi_error = error
|
|
self.message_post(body=_("E-invoice was not accepted:\n%s", error))
|
|
return
|
|
|
|
status = response.get("status", {})
|
|
response_code = status.get("code")
|
|
attachments = self.env["ir.attachment"]
|
|
subtype_xmlid = None
|
|
if response_code == "105":
|
|
message = _("E-invoice is pending: %s", status.get("desc"))
|
|
elif response_code in ("100", "200"):
|
|
self.l10n_br_last_edi_status = "accepted"
|
|
self.l10n_br_nfse_number = status.get("nfseNumber")
|
|
self.l10n_br_nfse_verification = status.get("nfseVerifyCode")
|
|
|
|
message = (
|
|
Markup(
|
|
"%s"
|
|
"<ul>"
|
|
" <li>%s</li>"
|
|
" <li>%s</li>"
|
|
" <li>%s</li>"
|
|
"</ul>"
|
|
)
|
|
% (
|
|
_("E-invoice accepted:"),
|
|
_("Status: %s", status.get("desc")),
|
|
_("NFS-e number: %s", self.l10n_br_nfse_number),
|
|
_("NFS-e verify code: %s", self.l10n_br_nfse_verification),
|
|
)
|
|
)
|
|
attachments = self._l10n_br_edi_attachments_from_response(response)
|
|
subtype_xmlid = "mail.mt_comment" # send to all followers
|
|
else:
|
|
message = _("Unknown E-invoice status code %(code)s: %(description)s", code=response_code, description=status.get("desc"))
|
|
|
|
self.message_post(
|
|
body=message, attachment_ids=attachments.ids, subtype_xmlid=subtype_xmlid
|
|
)
|
|
|
|
def _l10n_br_iap_cancel_invoice_goods(self, transaction):
|
|
return self._l10n_br_iap_request("cancel_invoice_goods", self.company_id, transaction)
|
|
|
|
def _l10n_br_iap_correct_invoice_goods(self, transaction):
|
|
return self._l10n_br_iap_request("correct_invoice_goods", self.company_id, transaction)
|
|
|
|
def _l10n_br_iap_cancel_range_goods(self, transaction, company):
|
|
return self._l10n_br_iap_request("cancel_range_goods", company, transaction)
|
|
|
|
def _l10n_br_edi_get_xml_attachment_name(self):
|
|
return f"{self.name}_edi.xml"
|
|
|
|
def _l10n_br_edi_attachments_from_response(self, response):
|
|
# Unset old ones because otherwise `_compute_linked_attachment_id()` will set the oldest
|
|
# attachment, not this new one.
|
|
self.invoice_pdf_report_id.res_field = False
|
|
self.l10n_br_edi_xml_attachment_id.res_field = False
|
|
|
|
# Creating the e-invoice PDF like this prevents the standard invoice PDF from being generated.
|
|
invoice_pdf = self.env["ir.attachment"].create(
|
|
{
|
|
"res_model": "account.move",
|
|
"res_id": self.id,
|
|
"res_field": "invoice_pdf_report_file",
|
|
"name": self._get_invoice_report_filename(),
|
|
"datas": response["pdf"]["base64"],
|
|
}
|
|
)
|
|
# make sure latest PDF shows to the right of the chatter
|
|
self._message_set_main_attachment_id(invoice_pdf, force=True, filter_xml=False)
|
|
|
|
invoice_xml = self.env["ir.attachment"].create(
|
|
{
|
|
"res_model": "account.move",
|
|
"res_id": self.id,
|
|
"res_field": "l10n_br_edi_xml_attachment_file",
|
|
"name": self._l10n_br_edi_get_xml_attachment_name(),
|
|
"datas": response["xml"]["base64"],
|
|
}
|
|
)
|
|
self.invalidate_recordset(
|
|
fnames=[
|
|
"invoice_pdf_report_id",
|
|
"invoice_pdf_report_file",
|
|
"l10n_br_edi_xml_attachment_id",
|
|
"l10n_br_edi_xml_attachment_file",
|
|
]
|
|
)
|
|
return invoice_pdf | invoice_xml
|
|
|
|
def _l10n_br_edi_send(self):
|
|
"""Sends the e-invoice and returns an array of error strings."""
|
|
for invoice in self:
|
|
payload = invoice._l10n_br_prepare_invoice_payload()
|
|
response, api_error = self._l10n_br_submit_invoice(invoice, payload)
|
|
if api_error:
|
|
invoice.l10n_br_last_edi_status = "error"
|
|
return api_error
|
|
else:
|
|
invoice.l10n_br_last_edi_status = "pending" if invoice.l10n_br_is_service_transaction else "accepted"
|
|
invoice.l10n_br_access_key = response["key"]
|
|
invoice.message_post(
|
|
body=_("E-invoice submitted successfully."),
|
|
attachment_ids=invoice._l10n_br_edi_attachments_from_response(response).ids,
|
|
)
|
|
|
|
# Now that the invoice is submitted and accepted we no longer need the saved tax computation data.
|
|
invoice.l10n_br_edi_avatax_data = False
|
|
|
|
return None
|
|
|
|
def _l10n_br_edi_get_goods_values(self):
|
|
"""Returns the appropriate (finNFe, goal) tuple for the goods section in the header."""
|
|
goal_to_operation_types = {
|
|
"Shipping": {
|
|
"itemsForManufacturingShippingInbound",
|
|
"salesOutsideTheEstablishmentReturnOfUnsoldGoods",
|
|
},
|
|
"TransferBack": {
|
|
"fairShippingInbound",
|
|
"fixedAssetInboundOfReturnForUseOutsideEstab",
|
|
"generalStorageShippingReturnInBound",
|
|
"generalStorageShippingReturnOutBound",
|
|
"itemsForManufacturingReturnsShippingIncoming",
|
|
"itemsForRepairReturnShippingInbound",
|
|
"itemsForRepairReturnShippingOutbound",
|
|
"itemsNotUsedForManufacReturnsShippingIncoming",
|
|
"itemsNotUsedForManufacReturnsShippingOutbound",
|
|
"returnOfDemonstrationShippingInbound",
|
|
"returnOfDemonstrationShippingOutbound",
|
|
"salesOutsideTheEstablishmentReturnOfUnsoldGoods",
|
|
"shippingLendingReturnIn",
|
|
"shippingLendingReturnOut",
|
|
"shippingReturnReturnablePackaging",
|
|
"showcaseItemsReturnsShippingInbound",
|
|
"symbolicTransferBackFormGeneralStorage",
|
|
"x925ManufactoringInBoundReturn",
|
|
"x925ManufactoringReturn",
|
|
},
|
|
}
|
|
for goal, operation_types in goal_to_operation_types.items():
|
|
if self.l10n_br_goods_operation_type_id.technical_name in operation_types:
|
|
return 1, goal
|
|
|
|
if self.debit_origin_id:
|
|
return 2, "Complementary"
|
|
elif self.move_type in ("out_refund", "in_refund"):
|
|
return 4, "Return"
|
|
else:
|
|
return 1, "Normal"
|
|
|
|
def _get_l10n_br_avatax_service_params(self):
|
|
""" EXTENDS 'account.move'
|
|
For credit and debit notes this returns the appropriate reference to the original invoice. For tax
|
|
calculation we send these references as documentCode, which are Odoo references (e.g. account.move_31).
|
|
For EDI the government requires these references as refNFe instead. They should contain the access key
|
|
assigned when the original invoice was e-invoiced. """
|
|
params = super()._get_l10n_br_avatax_service_params()
|
|
|
|
params['invoice_refs_edi'] = {}
|
|
if origin := params['origin_record']:
|
|
# origin.l10n_br_access_key's existence is checked by l10n_br_avatax_warnings
|
|
params['invoice_refs_edi'] = {
|
|
'invoicesRefs': [
|
|
{
|
|
'type': 'refNFe',
|
|
'refNFe': origin.l10n_br_access_key,
|
|
}
|
|
]
|
|
}
|
|
|
|
return params
|
|
|
|
def _l10n_br_edi_get_tax_data(self):
|
|
"""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 = json.loads(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_prepare_payment_mode(self):
|
|
payment_value = False
|
|
if self.l10n_br_edi_payment_method != "90": # if different from no payment
|
|
payment_value = self.amount_total
|
|
|
|
payment_mode = {
|
|
"mode": self.l10n_br_edi_payment_method,
|
|
"value": payment_value,
|
|
}
|
|
if self.l10n_br_edi_payment_method == "99":
|
|
payment_mode["modeDescription"] = _("Other")
|
|
|
|
card_methods = {"03", "04", "10", "11", "12", "13", "15", "17", "18"}
|
|
if self.l10n_br_edi_payment_method in card_methods:
|
|
payment_mode["cardTpIntegration"] = "2"
|
|
|
|
return payment_mode
|
|
|
|
def _l10n_br_log_informative_taxes(self, payload):
|
|
non_accounting_taxes = set()
|
|
for line in payload.get("lines", []):
|
|
if line.get("taxDetails"):
|
|
non_accounting_taxes.update({tax_detail['taxType'] for tax_detail in line["taxDetails"] if tax_detail['taxImpact']['accounting'] == 'none'})
|
|
|
|
informative_taxes = []
|
|
taxes_summary = payload.get("summary", {}).get("taxImpactHighlights", {})
|
|
for tax_type, taxes in taxes_summary.items():
|
|
if tax_type == 'informative':
|
|
informative_taxes += taxes
|
|
else:
|
|
informative_taxes += [tax for tax in taxes if tax['taxType'] in non_accounting_taxes]
|
|
# Informative taxes look like: [{"taxType": "aproxtribCity", "tax": 7.8, "subtotalTaxable": 200}, ...]
|
|
# Transform to:
|
|
# - taxType: aproxtribCity, tax: 7.8, subtotalTaxable: 200
|
|
# - ...
|
|
pretty_informative_taxes = Markup()
|
|
for tax in informative_taxes:
|
|
line = ", ".join(f"{key}: {value}" for key, value in tax.items())
|
|
pretty_informative_taxes += Markup("<li>%s</li>") % line
|
|
|
|
self.message_post(
|
|
body=Markup("%s<ul>%s</ul>")
|
|
% (_("Informative taxes:"), pretty_informative_taxes or Markup("<li>%s</li>") % _("N/A"))
|
|
)
|
|
|
|
def _l10n_br_remove_informative_taxes(self, payload):
|
|
# Remove informative taxes when submitting the invoice. These informative taxes change after invoice posting,
|
|
# based on when a customer pays. These need to be handled manually in a separate misc journal entry when needed,
|
|
# and should not be included in the legal XML and PDF.
|
|
for line in payload.get("lines", []):
|
|
line["taxDetails"] = [
|
|
detail for detail in line["taxDetails"] if detail["taxImpact"]["impactOnFinalPrice"] != "Informative"
|
|
]
|
|
|
|
tax_highlights = payload.get("summary", {}).get("taxImpactHighlights", {})
|
|
if "informative" in tax_highlights:
|
|
for informative_tax in tax_highlights.get("informative", []):
|
|
del payload["summary"]["taxByType"][informative_tax["taxType"]]
|
|
|
|
del tax_highlights["informative"]
|
|
|
|
def _l10n_br_type_specific_header(self, tax_data_header):
|
|
"""Pieces of the header that change depending on whether the transaction is a service or goods one."""
|
|
if self.l10n_br_is_service_transaction:
|
|
return {
|
|
"rpsNumber": self.l10n_latam_document_number,
|
|
"rpsSerie": self.journal_id.l10n_br_invoice_serial,
|
|
}
|
|
else:
|
|
goods_nfe, goods_goal = self._l10n_br_edi_get_goods_values()
|
|
return {
|
|
"invoiceNumber": self.l10n_latam_document_number,
|
|
"invoiceSerial": self.journal_id.l10n_br_invoice_serial,
|
|
"goods": {
|
|
"model": self.l10n_latam_document_type_id.code,
|
|
"class": tax_data_header.get("goods", {}).get("class"),
|
|
"tplmp": "4", # DANFe NFC-e
|
|
"goal": goods_goal,
|
|
"finNFe": goods_nfe,
|
|
"transport": {
|
|
"modFreight": self.l10n_br_edi_freight_model,
|
|
},
|
|
},
|
|
}
|
|
|
|
def _l10n_br_get_location_dict(self, partner):
|
|
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_locations(self, customer, company_partner, transporter):
|
|
locations = {
|
|
"entity": self._l10n_br_get_location_dict(customer),
|
|
"establishment": self._l10n_br_get_location_dict(company_partner),
|
|
}
|
|
|
|
if not self.l10n_br_is_service_transaction:
|
|
locations["transporter"] = self._l10n_br_get_location_dict(transporter)
|
|
|
|
return locations
|
|
|
|
def _l10n_br_get_additional_info(self):
|
|
info = self.narration and html2plaintext(self.narration) # html2plaintext turns False into "False"
|
|
tax_data, _ = self._l10n_br_edi_get_tax_data() # TODO: Pass as a parameter in master
|
|
informative_taxes = tax_data.get('summary', {}).get('taxImpactHighlights', {}).get('informative', [])
|
|
approximate_taxes = {tax['taxType']: tax['tax'] for tax in informative_taxes if tax['taxType'] in ('aproxtribState', 'aproxtribFed', 'aproxtribCity')}
|
|
aprox_line = f"Trib. Aprox. R$ {approximate_taxes.get('aproxtribFed', 0.0)} Federal, R$ {approximate_taxes.get('aproxtribState', 0.0)} Estadual e R$ {approximate_taxes.get('aproxtribCity', 0.0)} Municipal. Fonte: IBPT"
|
|
info = "\n".join(line for line in (info, aprox_line) if line) # skip the None line if there's no narration since we always want the approx line
|
|
return {
|
|
"additionalInfo": {"otherInfo" if self.l10n_br_is_service_transaction else "complementaryInfo": info}
|
|
}
|
|
|
|
def _l10n_br_get_transporter(self):
|
|
customer = self.partner_id
|
|
transporter = self.l10n_br_edi_transporter_id
|
|
is_invoice = self.move_type == "out_invoice"
|
|
if self.l10n_br_edi_freight_model == "SenderVehicle":
|
|
transporter = self.company_id.partner_id if is_invoice else customer
|
|
elif self.l10n_br_edi_freight_model == "ReceiverVehicle":
|
|
transporter = customer if is_invoice else self.company_id.partner_id
|
|
|
|
return transporter
|
|
|
|
def _l10n_br_prepare_invoice_payload(self):
|
|
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
|
|
|
|
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
|
|
|
|
# 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)
|
|
|
|
customer = self.partner_id
|
|
company_partner = self.company_id.partner_id
|
|
transporter = self._l10n_br_get_transporter()
|
|
partner_shipping_id = service_params['partner_shipping']
|
|
if partner_shipping_location := (payload['header']['locations'].get('rendered', {}) or payload['header']['locations'].get('delivery', {})):
|
|
partner_shipping_location['address']['countryCode'] = partner_shipping_id.country_id.l10n_br_edi_code
|
|
|
|
tax_data_to_include, tax_data_header = self._l10n_br_edi_get_tax_data()
|
|
extra_payload = {
|
|
"header": {
|
|
"companyLocation": company_partner.vat,
|
|
**service_params['invoice_refs_edi'],
|
|
**self._l10n_br_type_specific_header(tax_data_header),
|
|
"locations": {
|
|
**self._l10n_br_get_locations(
|
|
customer,
|
|
company_partner,
|
|
transporter,
|
|
),
|
|
},
|
|
"payment": {
|
|
"paymentInfo": {
|
|
"paymentMode": [
|
|
self._l10n_br_prepare_payment_mode(),
|
|
],
|
|
},
|
|
},
|
|
**self._l10n_br_get_additional_info(),
|
|
"shippingDate": fields.Date.to_string(self.delivery_date),
|
|
},
|
|
}
|
|
|
|
# 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))
|
|
|
|
# This adds the "lines" and "summary" dicts received during tax calculation.
|
|
payload.update(tax_data_to_include)
|
|
|
|
self._l10n_br_log_informative_taxes(payload)
|
|
|
|
if self.l10n_br_is_service_transaction:
|
|
self._l10n_br_remove_informative_taxes(payload)
|
|
|
|
return payload
|
|
|
|
def _l10n_br_get_error_from_response(self, response):
|
|
if error := response.get("error"):
|
|
return f"Code {error['code']}: {error['message']}"
|
|
|
|
def _l10n_br_submit_invoice(self, invoice, payload):
|
|
try:
|
|
route = "submit_invoice_services" if self.l10n_br_is_service_transaction else "submit_invoice_goods"
|
|
response = invoice._l10n_br_iap_request(route, 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 _cron_l10n_br_get_invoice_statuses(self, batch_size=10):
|
|
pending_invoices = self.search([("l10n_br_last_edi_status", "=", "pending")], limit=batch_size + 1)
|
|
for invoice in pending_invoices[:batch_size]:
|
|
invoice.button_l10n_br_edi_get_service_invoice()
|
|
if self._can_commit():
|
|
self.env.cr.commit()
|
|
|
|
if len(pending_invoices) > batch_size:
|
|
self.env.ref("l10n_br_edi.ir_cron_l10n_br_edi_check_status")._trigger()
|
|
|
|
def _l10n_br_edi_import_invoice(self, invoice, data, is_new):
|
|
if invoice.invoice_line_ids:
|
|
return invoice._reason_cannot_decode_has_invoice_lines()
|
|
|
|
namespaces = {'nfe': 'http://www.portalfiscal.inf.br/nfe'}
|
|
|
|
def get_xml_text(tree, xpath):
|
|
return find_xml_value(xpath, tree, namespaces=namespaces)
|
|
|
|
def get_xml_attribute(xpath, attribute):
|
|
return tree.xpath(xpath, namespaces=namespaces)[0].get(attribute)
|
|
|
|
def get_latam_document_type():
|
|
return self.env['l10n_latam.document.type'].search([
|
|
('country_id.code', '=', 'BR'),
|
|
('code', '=', get_xml_text(tree, './/nfe:mod'))
|
|
], limit=1).id
|
|
|
|
def get_partner_id(xpath, create_if_not_found=False):
|
|
partner_tree = tree.xpath(xpath, namespaces=namespaces)
|
|
if not partner_tree:
|
|
return False
|
|
|
|
Partner = self.env['res.partner']
|
|
partner_tree = partner_tree[0]
|
|
cnpj_id = self.env.ref('l10n_br.cnpj').id
|
|
if cnpj := get_xml_text(partner_tree, './/nfe:CNPJ'):
|
|
if partner := Partner.search([('l10n_latam_identification_type_id', '=', cnpj_id), ('vat', 'in', (cnpj, format_cnpj(cnpj)))], limit=1):
|
|
return partner.id
|
|
|
|
cpf_id = self.env.ref('l10n_br.cpf').id
|
|
if cpf := get_xml_text(partner_tree, './/nfe:CPF'):
|
|
if partner := Partner.search([('l10n_latam_identification_type_id', '=', cpf_id), ('vat', 'in', (cpf, format_cpf(cpf)))], limit=1):
|
|
return partner.id
|
|
|
|
if create_if_not_found:
|
|
country_id = False
|
|
if c_pays := get_xml_text(partner_tree, './/nfe:cPais'):
|
|
country_id = self.env['res.country'].search([('l10n_br_edi_code', '=', c_pays)], limit=1).id
|
|
|
|
state_id = False
|
|
if uf := get_xml_text(partner_tree, './/nfe:UF'):
|
|
state_id = self.env['res.country.state'].search([('country_id', '=', country_id), ('code', '=', uf)], limit=1).id
|
|
|
|
city_id = False
|
|
if x_mun := get_xml_text(partner_tree, './/nfe:xMun'):
|
|
city_domain = [('country_id', '=', country_id), ('name', '=ilike', x_mun)]
|
|
if state_id:
|
|
city_domain += [('state_id', '=', state_id)]
|
|
|
|
city_id = self.env['res.city'].search(city_domain, limit=1).id
|
|
|
|
return Partner.create({
|
|
'is_company': True,
|
|
'vat': cnpj or cpf,
|
|
'l10n_latam_identification_type_id': cnpj_id if cnpj else cpf_id,
|
|
'name': get_xml_text(partner_tree, './/nfe:xNome'),
|
|
'street_name': get_xml_text(partner_tree, './/nfe:xLgr'),
|
|
'street_number': get_xml_text(partner_tree, './/nfe:nro'),
|
|
'street2': get_xml_text(partner_tree, './/nfe:xBairro'),
|
|
'street_number2': get_xml_text(partner_tree, './/nfe:xCpl'),
|
|
'city_id': city_id,
|
|
'state_id': state_id,
|
|
'zip': get_xml_text(partner_tree, './/nfe:CEP'),
|
|
'country_id': country_id,
|
|
'phone': get_xml_text(partner_tree, './/nfe:fone'),
|
|
'l10n_br_ie_code': get_xml_text(partner_tree, './/nfe:IE'),
|
|
'l10n_br_im_code': get_xml_text(partner_tree, './/nfe:IM'),
|
|
}).id
|
|
|
|
def get_payment_method():
|
|
possible_values = self._fields.get('l10n_br_edi_payment_method').get_values(self.env)
|
|
t_pag = get_xml_text(tree, './/nfe:tPag')
|
|
return t_pag if t_pag in possible_values else '99'
|
|
|
|
def get_freight_model():
|
|
mod_frete = get_xml_text(tree, './/nfe:modFrete')
|
|
return {
|
|
'0': 'CIF',
|
|
'1': 'FOB',
|
|
'2': 'Thirdparty',
|
|
'3': 'SenderVehicle',
|
|
'4': 'ReceiverVehicle',
|
|
'9': 'FreeShipping',
|
|
}.get(mod_frete)
|
|
|
|
def get_lines_vals(vendor_id):
|
|
def get_uom_id(u_com):
|
|
return {
|
|
'KG': self.env.ref('uom.product_uom_kgm'),
|
|
'G': self.env.ref('uom.product_uom_gram'),
|
|
'L': self.env.ref('uom.product_uom_litre'),
|
|
'M': self.env.ref('uom.product_uom_meter'),
|
|
'MM': self.env.ref('uom.product_uom_millimeter'),
|
|
'M2': self.env.ref('uom.product_uom_square_meter'),
|
|
'M3': self.env.ref('uom.product_uom_cubic_meter'),
|
|
'DZ': self.env.ref('uom.product_uom_dozen'),
|
|
'GL': self.env.ref('uom.product_uom_gal'),
|
|
'TON': self.env.ref('uom.product_uom_ton'),
|
|
}.get(u_com, self.env.ref('uom.product_uom_unit')).id
|
|
|
|
def get_product_id():
|
|
def get_product_id_supplierinfo(supplierinfo):
|
|
""" Return a product.product id only if the supplierinfo refers to exactly one variant. """
|
|
if supplierinfo.product_id:
|
|
return supplierinfo.product_id.id
|
|
if supplierinfo.product_variant_count == 1:
|
|
return supplierinfo.product_tmpl_id.product_variant_id.id
|
|
|
|
c_prod = get_xml_text(line, './/nfe:cProd')
|
|
if vendor_id:
|
|
domain = [('partner_id', '=', vendor_id)]
|
|
if c_prod:
|
|
if product_id := get_product_id_supplierinfo(self.env['product.supplierinfo'].search(domain + [('product_code', '=', c_prod)], limit=1)):
|
|
return product_id
|
|
|
|
if x_prod := get_xml_text(line, './/nfe:xProd'):
|
|
if product_id := get_product_id_supplierinfo(self.env['product.supplierinfo'].search(domain + [('product_name', '=', x_prod)], limit=1)):
|
|
return product_id
|
|
|
|
if c_prod:
|
|
return self.env['product.product'].search([('default_code', '=', c_prod)], limit=1).id
|
|
|
|
vals = []
|
|
for line in tree.xpath('.//nfe:det/nfe:prod', namespaces=namespaces):
|
|
product_id = get_product_id()
|
|
description = ' '.join([get_xml_text(line, './/nfe:xProd'), get_xml_text(line, './/nfe:cProd')])
|
|
vals.append({
|
|
'product_id': product_id,
|
|
'name': False if product_id else description,
|
|
'quantity': get_xml_text(line, './/nfe:qCom'),
|
|
'product_uom_id': get_uom_id(get_xml_text(line, './/nfe:uTrib')),
|
|
'price_unit': get_xml_text(line, './/nfe:vUnCom'),
|
|
'price_total': get_xml_text(line, './/nfe:vProd'),
|
|
'is_imported': True, # don't change imported price when modifying this line
|
|
})
|
|
|
|
return vals
|
|
|
|
tree = data['xml_tree']
|
|
|
|
# emit is required in leiauteNFe_v4.00.xsd
|
|
vendor = get_partner_id('.//nfe:emit', create_if_not_found=True)
|
|
invoice.write({
|
|
'partner_id': vendor,
|
|
'l10n_latam_document_type_id': get_latam_document_type(),
|
|
'name': get_xml_text(tree, './/nfe:nNF'),
|
|
'invoice_date': get_xml_text(tree, './/nfe:dhEmi'), # ignore timezone offset, because this is a date field
|
|
'delivery_date': get_xml_text(tree, './/nfe:dhSaiEnt'), # ignore timezone offset, because this is a date field
|
|
'l10n_br_access_key': get_xml_attribute('.//nfe:infNFe', 'Id'),
|
|
'invoice_line_ids': [Command.create(line) for line in get_lines_vals(vendor)],
|
|
'l10n_br_edi_payment_method': get_payment_method(),
|
|
'l10n_br_edi_transporter_id': get_partner_id('.//nfe:transporta'),
|
|
'l10n_br_edi_freight_model': get_freight_model(),
|
|
})
|
|
|
|
def _get_import_file_type(self, file_data):
|
|
""" Identify NFe files. """
|
|
# EXTENDS 'account'
|
|
|
|
if b"<nfeProc " in file_data['raw'] and b"<NFe" in file_data['raw']:
|
|
return 'l10n_br.nfe'
|
|
|
|
return super()._get_import_file_type(file_data)
|
|
|
|
def _get_edi_decoder(self, file_data, new=False):
|
|
# EXTENDS 'account'
|
|
if file_data['import_file_type'] == 'l10n_br.nfe':
|
|
return {
|
|
'priority': 20,
|
|
'decoder': self._l10n_br_edi_import_invoice,
|
|
}
|
|
return super()._get_edi_decoder(file_data, new)
|