Files
enterprise-addons/l10n_mx_edi/models/l10n_mx_edi_document.py
T

2536 lines
109 KiB
Python

import base64
from cryptography.hazmat.primitives import serialization
import json
import random
import re
import requests
import string
from collections import defaultdict
from datetime import datetime, time, timedelta
from json.decoder import JSONDecodeError
from lxml import etree
from odoo.tools.zeep import Client, Transport
from pytz import timezone
from werkzeug.urls import url_quote_plus
from odoo import _, api, models, modules, fields, tools, SUPERUSER_ID
from odoo.exceptions import UserError
from odoo.fields import Domain
from odoo.tools.float_utils import float_is_zero, float_round
from odoo.addons.base.models.ir_qweb import keep_query
from odoo.tools.misc import clean_context
CFDI_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S'
CANCELLATION_REASON_SELECTION = [
('01', "01 - Document issued with errors (with related document)"),
('02', "02 - Document issued with errors (no replacement)"),
('03', "03 - The operation was not carried out"),
('04', "04 - Nominative operation related to the global invoice"),
]
CANCELLATION_REASON_DESCRIPTION = (
f"{CANCELLATION_REASON_SELECTION[0][1]}.\n"
"This option applies when there is an error in the document data, so it must be reissued. In this case, the replacement document is"
" referenced in the cancellation request.\n"
f"{CANCELLATION_REASON_SELECTION[1][1]}.\n"
"This option applies when there is an error in the document data and no replacement document will be generated.\n"
f"{CANCELLATION_REASON_SELECTION[2][1]}.\n"
"This option applies when a transaction was invoiced that does not materialize.\n"
f"{CANCELLATION_REASON_SELECTION[3][1]}.\n"
"This option applies when a sale was included in the global invoice of operations with the general public, but should actually be"
" excluded since the partner has requested a CFDI to be issued in their name.\n"
)
GLOBAL_INVOICE_PERIODICITY_DEFAULT_VALUES = {
'selection': [
('01', "Daily"),
('02', "Weekly"),
('03', "Fortnightly"),
('04', "Monthly"),
('05', "Bimonthly"),
],
'default': '04',
'string': "Periodicity",
'help': "The periodicity at which you want to send the CFDI global invoices.",
}
TAX_TYPE_TO_CFDI_CODE = {'isr': '001', 'iva': '002', 'ieps': '003'}
CFDI_CODE_TO_TAX_TYPE = {v: k for k, v in TAX_TYPE_TO_CFDI_CODE.items()}
USAGE_SELECTION = [
('G01', 'Acquisition of merchandise'),
('G02', 'Returns, discounts or bonuses'),
('G03', 'General expenses'),
('I01', 'Constructions'),
('I02', 'Office furniture and equipment investment'),
('I03', 'Transportation equipment'),
('I04', 'Computer equipment and accessories'),
('I05', 'Dices, dies, molds, matrices and tooling'),
('I06', 'Telephone communications'),
('I07', 'Satellite communications'),
('I08', 'Other machinery and equipment'),
('D01', 'Medical, dental and hospital expenses.'),
('D02', 'Medical expenses for disability'),
('D03', 'Funeral expenses'),
('D04', 'Donations'),
('D05', 'Real interest effectively paid for mortgage loans (room house)'),
('D06', 'Voluntary contributions to SAR'),
('D07', 'Medical insurance premiums'),
('D08', 'Mandatory School Transportation Expenses'),
('D09', 'Deposits in savings accounts, premiums based on pension plans.'),
('D10', 'Payments for educational services (Colegiatura)'),
('S01', "Without fiscal effects"),
]
class L10n_Mx_EdiDocument(models.Model):
_name = 'l10n_mx_edi.document'
_description = "Mexican documents that needs to transit outside of Odoo"
_order = 'datetime DESC, id DESC'
invoice_ids = fields.Many2many(
comodel_name='account.move',
relation='l10n_mx_edi_invoice_document_ids_rel',
column1='document_id',
column2='invoice_id',
copy=False,
readonly=True,
)
datetime = fields.Datetime(required=True)
move_id = fields.Many2one(comodel_name='account.move', bypass_search_access=True, index='btree_not_null')
attachment_id = fields.Many2one(comodel_name='ir.attachment')
attachment_uuid = fields.Char(
string="Fiscal Folio",
compute='_compute_from_attachment',
store=True,
)
attachment_origin = fields.Char(
string="Origin",
compute='_compute_from_attachment',
store=True,
)
cancellation_reason = fields.Selection(
selection=CANCELLATION_REASON_SELECTION,
string="Cancellation Reason",
copy=False,
help=CANCELLATION_REASON_DESCRIPTION,
)
message = fields.Char(string="Info")
state = fields.Selection(
selection=[
('invoice_sent', "Sent"),
('invoice_sent_failed', "Send In Error"),
('invoice_cancel_requested', "Cancel Requested"),
('invoice_cancel_requested_failed', "Cancel Requested In Error"),
('invoice_cancel', "Cancel"),
('invoice_cancel_failed', "Cancel In Error"),
('invoice_received', "Received"),
('ginvoice_sent', "Sent Global"),
('ginvoice_sent_failed', "Send Global In Error"),
('ginvoice_cancel', "Cancel Global"),
('ginvoice_cancel_failed', "Cancel Global In Error"),
('payment_sent_pue', "PUE Payment"),
('payment_sent', "Payment Sent"),
('payment_sent_failed', "Payment Send In Error"),
('payment_cancel', "Payment Cancel"),
('payment_cancel_failed', "Payment Cancel In Error"),
],
required=True,
)
sat_state = fields.Selection(
selection=[
('skip', "Skip"),
('valid', "Validated"),
('cancelled', "Cancelled"),
('not_found', "Not Found"),
('not_defined', "Not Defined"),
('error', "Error"),
],
)
cancel_button_needed = fields.Boolean(compute='_compute_cancel_button_needed')
retry_button_needed = fields.Boolean(compute='_compute_retry_button_needed')
show_button_needed = fields.Boolean(compute='_compute_show_button_needed')
print_button_needed = fields.Boolean(compute='_compute_print_button_needed')
# -------------------------------------------------------------------------
# COMPUTE
# -------------------------------------------------------------------------
@api.depends('attachment_id.raw')
def _compute_from_attachment(self):
""" Decode the CFDI document and extract some valuable information such as the UUID or the origin. """
for doc in self.with_context(bin_size=False):
doc.attachment_uuid = None
doc.attachment_origin = None
if doc.attachment_id:
cfdi_infos = self._decode_cfdi_attachment(doc.attachment_id.raw)
if cfdi_infos:
doc.attachment_uuid = cfdi_infos['uuid']
doc.attachment_origin = cfdi_infos['origin']
@api.model
def _get_cancel_button_map(self):
""" Mapping to manage the 'cancel' flow on documents.
:return: A mapping:
<source_state>: (<cancel_state>, <extra_condition_function>, <cancel_function>)
where:
<source_state> is the original state of the document allowing a cancel flow (e.g. 'invoice_sent').
<cancel_state> is the state cancelling <source_state> (e.g. 'invoice_cancel').
<extra_condition_function> is an optional function allowing extra checking on the document (mainly specific stuff
depending on the related business record owning the document).
<cancel_function> is the function to be called when clicking on the 'cancel' button.
"""
def invoice_sent_cancel(doc):
# For invoices, we support the cancellation reason 01. Then, let's delegate the cancellation flow to the wizard.
if doc.move_id:
return doc.action_request_cancel()
# For others documents like pos orders, we only support the cancellation reason 02 atm.
records = self._get_source_records()
records._l10n_mx_edi_cfdi_invoice_try_cancel(doc, '02')
return {
'invoice_sent': (
'invoice_cancel',
lambda x: not x.move_id or x.move_id._l10n_mx_edi_need_cancel_request(),
invoice_sent_cancel,
),
'ginvoice_sent': (
'ginvoice_cancel',
None,
lambda x: x.action_request_cancel(),
),
'payment_sent': (
'payment_cancel',
None,
lambda x: x.action_request_cancel_payment(),
),
}
@api.depends('state')
def _compute_cancel_button_needed(self):
""" Compute whatever or not the 'cancel' button should be displayed. """
doc_state_mapping = self._get_cancel_button_map()
for doc in self:
doc.cancel_button_needed = False
results = doc_state_mapping.get(doc.state)
if (
results
and doc.sat_state not in ('cancelled', 'skip')
and (not results[1] or results[1](doc))
):
doc.cancel_button_needed = not doc._get_cancel_document_from_source()
@api.model
def _get_retry_button_map(self):
""" Mapping to manage the 'retry' flow on documents.
:return: A mapping:
<source_state>: (<extra_condition_function>, <retry_function>)
where:
<source_state> is the original state of the document allowing a retry flow
(a.k.a any failing document such as 'invoice_sent_failed').
<extra_condition_function> is an optional function allowing extra checking on the document (mainly specific stuff
depending on the related business record owning the document).
<retry_function> is the function to be called when clicking on the 'retry' button.
"""
return {
'invoice_sent_failed': (
None,
lambda x: x._action_retry_invoice_try_send(),
),
'invoice_cancel_failed': (
None,
lambda x: x._action_retry_invoice_try_cancel(),
),
'invoice_cancel_requested_failed': (
None,
lambda x: x._action_retry_invoice_try_cancel(),
),
'payment_sent_failed': (
lambda x: x.move_id,
lambda x: x.move_id._l10n_mx_edi_cfdi_payment_try_send(),
),
'payment_cancel_failed': (
None,
lambda x: x._action_retry_payment_try_cancel(),
),
'ginvoice_sent_failed': (
lambda x: x.attachment_id,
lambda x: x._action_retry_global_invoice_try_send(),
),
'ginvoice_cancel_failed': (
None,
lambda x: x._action_retry_global_invoice_try_cancel(),
),
}
@api.depends('state', 'attachment_id')
def _compute_retry_button_needed(self):
""" Compute whatever or not the 'retry' button should be displayed. """
doc_state_mapping = self._get_retry_button_map()
for doc in self:
results = doc_state_mapping.get(doc.state)
doc.retry_button_needed = bool(results) and (not results[0] or results[0](doc))
@api.depends('state')
def _compute_print_button_needed(self):
""" Compute whatever or not the 'print' button should be displayed. """
for doc in self:
doc.print_button_needed = doc.state == 'payment_sent'
@api.depends('state')
def _compute_show_button_needed(self):
""" Compute whatever or not the 'show' button should be displayed. """
for doc in self:
if doc.state.startswith('payment_') and not doc.move_id:
doc.show_button_needed = False
continue
doc.show_button_needed = doc.state.startswith('payment_') or doc.state.startswith('ginvoice_')
# -------------------------------------------------------------------------
# BUTTON ACTIONS
# -------------------------------------------------------------------------
@api.model
def _can_commit(self):
return not tools.config['test_enable'] and not modules.module.current_test
def _get_source_records(self):
""" Get the originator records for the current document.
This is useful when some flows are the same across multiple input documents.
:return: A recordset.
"""
self.ensure_one()
return self.invoice_ids
def _get_source_document_from_cancel(self, target_state):
""" Get the source document for the current cancel document.
For example, if the current document is 'invoice_cancel' and the target_state is 'invoice_sent', this method will give you
the source document having the 'invoice_sent' originator of this 'invoice_cancel' document.
:param target_state: The state of the targeted document.
:return: Another document if any.
"""
self.ensure_one()
if not self.attachment_id:
return
return self.search(
[('state', '=', target_state), ('attachment_id', '=', self.attachment_id.id)],
limit=1,
)
def _get_cancel_document_from_source(self):
""" Get the cancel document for the current signed document.
For example, if the current document is 'invoice_cancel' and the target_state is 'invoice_sent', this method will give you
the source document having the 'invoice_sent' originator of this 'invoice_cancel' document.
:return: Another document if any.
"""
self.ensure_one()
if not self.attachment_id:
return
doc_state_mapping = self._get_cancel_button_map()
return self.search(
[('state', '=', doc_state_mapping[self.state][0]), ('attachment_id', '=', self.attachment_id.id)],
limit=1,
)
def _get_substitution_document(self):
""" Get the document substituting the current signed document.
This happens when using the cancellation reason 01 in which you need to replace first the CFDI document by another one
before cancelling it. In that case, the substitution document is linked to the current one through the origin field.
:return: Another document if any.
"""
self.ensure_one()
uuid = self.attachment_uuid
if not uuid:
return self.env['l10n_mx_edi.document']
return self.env['l10n_mx_edi.document'].search(
[('id', '!=', self.id), ('state', '=', self.state), ('attachment_origin', '=like', f'04|{uuid}%')],
limit=1,
)
def _get_original_document(self):
"""
Get the document for which the current document is the substitution.
"""
self.ensure_one()
if not self.attachment_origin:
return self.env['l10n_mx_edi.document']
origin_uuid = self.attachment_origin
doc_id = self.id
return self.env['l10n_mx_edi.document'].search(
[('id', '!=', doc_id), ('attachment_uuid', '=like', origin_uuid[3:])],
limit=1,
)
def action_show_document(self):
""" View the record(s) owning this document. """
self.ensure_one()
if self.state.startswith('payment_'):
return self.move_id.action_open_business_doc()
elif self.state.startswith('ginvoice_'):
return {
'name': _("Global Invoice"),
'type': 'ir.actions.act_window',
'res_model': self.invoice_ids._name,
'view_mode': 'list,form',
'domain': [('id', 'in', self.invoice_ids.ids)],
'context': {'create': False},
}
def action_download_file(self):
""" Download the XML file linked to the document.
:return: An action to download the attachment.
"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{self.attachment_id.id}?download=true',
}
def action_download_payment_receipt(self):
""" Download the payment receipt linked to the document."""
self.ensure_one()
if self.move_id.origin_payment_id:
return self.env.ref('account.action_report_payment_receipt').report_action(self.move_id.origin_payment_id)
else:
return self.env.ref('l10n_mx_edi.action_report_bank_transaction_receipt').report_action(self.move_id)
def action_force_payment_cfdi(self):
""" Force the CFDI for the PUE payment document."""
raise UserError(_("You can no longer send a CFDI for a PUE payment."))
def action_cancel(self):
""" Cancel the document. """
self.ensure_one()
return self._get_cancel_button_map()[self.state][2](self)
def _action_retry_invoice_try_send(self):
""" Retry the sending of an invoice CFDI document that failed to be sent. """
self.ensure_one()
records = self._get_source_records()
if self.move_id:
records._l10n_mx_edi_cfdi_invoice_retry_send()
else:
records._l10n_mx_edi_cfdi_invoice_try_send()
def _action_retry_invoice_try_cancel(self):
""" Retry the cancellation of a the invoice cfdi document that failed to be cancelled. """
self.ensure_one()
source_document = self._get_source_document_from_cancel('invoice_sent')
if source_document:
records = self._get_source_records()
records._l10n_mx_edi_cfdi_invoice_try_cancel(source_document, self.cancellation_reason)
def _action_retry_payment_try_cancel(self):
""" Retry the cancellation of a the payment cfdi document that failed to be cancelled. """
self.ensure_one()
source_document = self._get_source_document_from_cancel('payment_sent')
if source_document:
self.move_id._l10n_mx_edi_cfdi_invoice_try_cancel_payment(source_document)
def _action_retry_global_invoice_try_send(self):
""" Retry the sending of a global invoice cfdi document that failed to be sent. """
self.ensure_one()
cfdi_infos = self._decode_cfdi_attachment(self.attachment_id.raw)
if not cfdi_infos:
return
records = self._get_source_records()
records._l10n_mx_edi_cfdi_global_invoice_try_send(
periodicity=cfdi_infos['periodicity'],
origin=self.attachment_origin,
)
def _action_retry_global_invoice_try_cancel(self):
""" Retry the cancellation of a the global invoice cfdi document that failed to be cancelled. """
self.ensure_one()
source_document = self._get_source_document_from_cancel('ginvoice_sent')
if source_document:
records = self._get_source_records()
records._l10n_mx_edi_cfdi_global_invoice_try_cancel(source_document, self.cancellation_reason)
def action_retry(self):
""" Retry the current document. """
self.ensure_one()
self._get_retry_button_map()[self.state][1](self)
def action_request_cancel(self):
""" Open the cancellation wizard to cancel the current document.
:return: An action opening the 'l10n_mx_edi.invoice.cancel' wizard.
"""
self.ensure_one()
return {
'name': _("Request CFDI Cancellation"),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'l10n_mx_edi.invoice.cancel',
'target': 'new',
'context': {'default_document_id': self.id},
}
def action_request_cancel_payment(self):
""" Cancel the current payment document.
"""
self.ensure_one()
self.move_id._l10n_mx_edi_cfdi_invoice_try_cancel_payment(self)
# -------------------------------------------------------------------------
# CFDI: HELPERS
# -------------------------------------------------------------------------
@api.model
def _get_invoice_cfdi_template(self):
""" Hook to be overridden in case the CFDI version changes.
:return: the qweb_template
"""
return 'l10n_mx_edi.cfdiv40'
@api.model
def _get_payment_cfdi_template(self):
""" Hook to be overridden in case the CFDI version changes.
:return: the qweb_template
"""
return 'l10n_mx_edi.payment20'
@api.model
def _cfdi_sanitize_to_legal_name(self, name):
""" We remove the SA de CV / SL de CV / S de RL de CV as they are never in the official name in the XML.
:param name: The name to clean.
:return: The formatted name.
"""
regex = r"(?i:\s+(s\.?\s?(a\.?)( de c\.?v\.?|)|(s\.?\s?(a\.?s\.?)|s\.? en c\.?( por a\.?)?|s\.?\s?c\.?\s?(l\.?(\s?\(?limitada)?\)?|s\.?(\s?\(?suplementada\)?)?)|s\.? de r\.?l\.?)))\s*$"
return re.sub(regex, "", name or '').upper()
@api.model
def _add_base_cfdi_values(self, cfdi_values):
""" Add the basic values to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
"""
def format_string(text, size):
""" Replace from text received the characters that are not found in the regex. This regex is taken from SAT
documentation: https://goo.gl/C9sKH6
Ex. 'Product ABC (small size)' - 'Product ABC small size'
:param text: Text to format.
:param size: The maximum size of the string
"""
if not text:
return None
text = text.replace('|', ' ')
return text.strip()[:size]
cfdi_values.update({
'format_string': format_string,
'exportacion': '01',
})
@api.model
def _get_company_cfdi_values(self, company):
""" Get the company to consider when creating the CFDI document.
The root company will be the one with configured certificates on the hierarchy.
:param company: The res.company to consider when generating the CFDI.
:return: A dictionary containing:
* company: The company of the document.
* root_company: The company used to interact with the SAT.
* issued_address: The company's address.
"""
root_company = company.sudo().parent_ids[::-1].filtered('partner_id.vat')[:1] or company
cfdi_values = {
'company': company,
'issued_address': company.partner_id.commercial_partner_id,
'root_company': root_company,
}
if root_company.l10n_mx_edi_pac:
pac_test_env = root_company.l10n_mx_edi_pac_test_env
pac_password = root_company.sudo().l10n_mx_edi_pac_password
if not pac_test_env and not pac_password:
cfdi_values['errors'] = [_("No PAC credentials specified.")]
else:
cfdi_values['errors'] = [_("No PAC specified.")]
return cfdi_values
@api.model
def _add_certificate_cfdi_values(self, cfdi_values):
""" Add the values about the certificate to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
"""
company = cfdi_values['company']
root_company = cfdi_values['root_company']
certificate_sudo = company.sudo().l10n_mx_edi_certificate_ids.filtered('is_valid')[:1]
if not certificate_sudo and company != root_company:
certificate_sudo = root_company.sudo().l10n_mx_edi_certificate_ids.filtered('is_valid')[:1]
if not certificate_sudo:
cfdi_values['errors'] = [_("No valid certificate found")]
return
supplier = root_company.partner_id.commercial_partner_id.with_user(self.env.user)
fiscal_regime = company.l10n_mx_edi_fiscal_regime or root_company.l10n_mx_edi_fiscal_regime
cfdi_values.update({
'certificate': certificate_sudo,
'no_certificado': ('%x' % int(certificate_sudo.serial_number))[1::2],
'certificado': certificate_sudo._get_der_certificate_bytes(formatting='base64').decode(),
'emisor': {
'supplier': supplier,
'rfc': supplier.vat,
'nombre': self._cfdi_sanitize_to_legal_name(root_company.name),
'regimen_fiscal': fiscal_regime,
'domicilio_fiscal_receptor': supplier.zip,
},
})
@api.model
def _add_currency_cfdi_values(self, cfdi_values, currency):
""" Add the values about the currency to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param currency: The currency to consider.
"""
currency_precision = currency.l10n_mx_edi_decimal_places
def format_float(amount, precision=currency_precision):
if amount is None or amount is False:
return None
# Avoid things like -0.0, see: https://stackoverflow.com/a/11010869
amount = float_round(amount, precision_digits=precision)
return '%.*f' % (precision, amount if not float_is_zero(amount, precision_digits=precision) else 0.0)
cfdi_values.update({
'format_float': format_float,
'currency': currency,
'currency_precision': currency_precision,
'moneda': currency.name,
})
@api.model
def _add_document_name_cfdi_values(self, cfdi_values, document_name):
""" Add the values about the name of the document to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param document_name: The name of the document.
"""
name_numbers = list(re.finditer(r'\d+', document_name))
cfdi_values.update({
'document_name': document_name,
'folio': name_numbers[-1].group().lstrip('0'),
'serie': document_name[:name_numbers[-1].start()],
})
@api.model
def _add_document_origin_cfdi_values(self, cfdi_values, document_origin):
""" Add the values about the origin of the document to 'cfdi_values'.
Format should follow <code_1>|<uuid_1>,...<uuid_n>,...,<code_n>|...
:param cfdi_values: The current CFDI values.
:param document_origin: The origin of the document.
"""
cfdi_values.update({'cfdi_relationado_data': {}})
group_pattern = r'^(?:0[0-7]\|)?[a-fA-F0-9]{8}-(?:[a-fA-F0-9]{4}-){3}[a-fA-F0-9]{12}$'
groups = (document_origin or '').split(',')
uuid_by_code = defaultdict(list)
current_code = ''
for group in groups:
if not re.match(group_pattern, group): # Return if we found an invalid group
return
splitted = group.split('|')
if len(splitted) == 1 and not current_code:
return
if len(splitted) == 2:
current_code = splitted[0]
uuid = splitted[-1]
uuid_by_code[current_code].append(uuid)
cfdi_values['cfdi_relationado_data'] = uuid_by_code
@api.model
def _get_datetime_now_with_mx_timezone(self, cfdi_values, journal=None):
issued_address = cfdi_values['issued_address']
tz = issued_address._l10n_mx_edi_get_cfdi_timezone()
if journal:
tz_force = self.env['ir.config_parameter'].sudo().get_param(f'l10n_mx_edi_tz_{journal.id}', default=None)
if tz_force:
tz = timezone(tz_force)
return datetime.now(tz)
@api.model
def _add_date_cfdi_values(self, cfdi_values, document_date, journal=None, document_post_time=None):
""" Add the values about the date of the document to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param document_date: The date of the document.
:param journal: An optional accounting journal to retrieve the custom timezone from it.
:param document_post_time: An optional exact time of sending the document if available.
"""
if document_post_time:
cfdi_date = document_post_time
else:
cfdi_date = self._get_min_of_now_and_document_date(document_date, cfdi_values['issued_address'], journal=journal)
cfdi_values['fecha'] = cfdi_date.strftime(CFDI_DATE_FORMAT)
def _get_min_of_now_and_document_date(self, document_date, issued_address, journal=None):
""" This method returns the lesser of the document date and the current time in the
Mexican timezone determined by the issued address and the journal. """
cfdi_values = {'issued_address': issued_address}
now_mx = self._get_datetime_now_with_mx_timezone(cfdi_values, journal).replace(tzinfo=None)
document_datetime = datetime.combine(document_date, time(hour=23, minute=59, second=00))
min_datetime = min(document_datetime, now_mx)
return min_datetime
@api.model
def _add_payment_policy_cfdi_values(self, cfdi_values, payment_policy=None, payment_method=None):
""" Add the values about the payment way of the document to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param payment_policy: PPD or PUE.
:param payment_method: In case of PUE, a payment method is necessary.
"""
if payment_policy == 'PPD':
cfdi_values['metodo_pago'] = 'PPD'
cfdi_values['forma_pago'] = '99'
else:
cfdi_values['metodo_pago'] = 'PUE'
cfdi_values['forma_pago'] = (payment_method.code or '').replace('NA', '99')
@api.model
def _add_customer_cfdi_values(self, cfdi_values, customer=None, usage=None, to_public=False):
""" Add the values about the customer to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param customer: The partner if not PUBLICO EN GENERAL.
:param usage: The partner's reason to ask for this CFDI.
:param to_public: 'CFDI to public' mode.
"""
customer = customer or self.env['res.partner']
invoice_customer = customer if customer.type == 'invoice' else customer.commercial_partner_id
has_missing_vat = not invoice_customer.vat
issued_address = cfdi_values['issued_address']
# If the CFDI is refunding a global invoice, it should be sent as a refund of a global invoice with
# ad 'publico en general'.
is_refund_gi = False
relationado_data = cfdi_values.get('cfdi_relationado_data', {})
if cfdi_values.get('tipo_de_comprobante') == 'E' and ('01' in relationado_data or '03' in relationado_data):
# Force uso_cfdi to G02 since it's a refund of a global invoice.
origin_uuids = set(relationado_data.get('01', []) + relationado_data.get('03', []))
is_refund_gi = bool(self.search([('attachment_uuid', 'in', list(origin_uuids)), ('state', '=', 'ginvoice_sent')], limit=1))
customer_as_publico_en_general = (not customer and to_public) or is_refund_gi
customer_as_xexx_xaxx = to_public or customer.country_id.code != 'MX' or has_missing_vat
if customer_as_publico_en_general or customer_as_xexx_xaxx:
customer_values = {
'to_public': True,
'residencia_fiscal': None,
'domicilio_fiscal_receptor': issued_address.zip,
'regimen_fiscal_receptor': '616',
}
# Default UsoCFDI is S01 (no tax effects).
uso_cfdi = 'S01'
# Exception: credit notes (E) may use G02 under regime 616 or foreign regime.
if cfdi_values.get('tipo_de_comprobante') == 'E' and (usage == 'G02' or is_refund_gi):
uso_cfdi = 'G02'
if customer_as_publico_en_general:
customer_values.update({
'rfc': 'XAXX010101000',
'nombre': "PUBLICO EN GENERAL",
'uso_cfdi': uso_cfdi,
})
else:
has_country = bool(customer.country_id)
company = cfdi_values['company']
export_fiscal_position = company._l10n_mx_edi_get_foreign_customer_fiscal_position()
fiscal_position = customer.with_company(company).property_account_position_id
has_export_fiscal_position = export_fiscal_position and fiscal_position == export_fiscal_position
is_foreign_customer = customer.country_id.code != 'MX' and (has_country or has_export_fiscal_position)
customer_values.update({
'rfc': 'XEXX010101000' if is_foreign_customer else 'XAXX010101000',
'nombre': self._cfdi_sanitize_to_legal_name(invoice_customer.commercial_company_name or invoice_customer.name),
'uso_cfdi': uso_cfdi,
})
else:
customer_values = {
'to_public': False,
'rfc': invoice_customer.vat.strip(),
'nombre': self._cfdi_sanitize_to_legal_name(invoice_customer.commercial_company_name or invoice_customer.name),
'domicilio_fiscal_receptor': invoice_customer.zip,
'regimen_fiscal_receptor': invoice_customer.l10n_mx_edi_fiscal_regime or '616',
'uso_cfdi': usage if usage != 'P01' else 'S01',
}
if invoice_customer.country_id.l10n_mx_edi_code == 'MEX':
customer_values['residencia_fiscal'] = None
else:
customer_values['residencia_fiscal'] = invoice_customer.country_id.l10n_mx_edi_code
customer_values['customer'] = invoice_customer
customer_values['issued_address'] = issued_address
cfdi_values.update({
'receptor': customer_values,
'lugar_expedicion': issued_address.zip,
})
@api.model
def _add_tax_objected_base_line(self, cfdi_values, base_line):
""" Add 'objeto_imp' into base_line.
:param cfdi_values: The current CFDI values.
:param base_line: A dictionary representing one line.
"""
receptor = cfdi_values['receptor']
customer = receptor['customer']
if 'tax_objected' not in base_line:
taxes = base_line['tax_ids'].flatten_taxes_hierarchy().filtered(lambda tax: tax.l10n_mx_tax_type != 'local')
if not taxes:
tax_objected = '01'
elif False:
# TODO PODEBI
tax_objected = '05'
elif (
# ISR Withholding
any(tax.amount < 0.0 and tax.l10n_mx_tax_type == 'isr' for tax in taxes)
# No VAT, No IEPS
and all(tax.l10n_mx_tax_type not in ('iva', 'ieps') for tax in taxes if tax.amount >= 0.0)
):
tax_objected = '06'
elif (
# ISR Withholding
any(tax.amount < 0.0 and tax.l10n_mx_tax_type == 'isr' for tax in taxes)
# IEPS
and any(tax.l10n_mx_tax_type == 'ieps' for tax in taxes if tax.amount >= 0.0)
# No VAT
and all(tax.l10n_mx_tax_type != 'iva' for tax in taxes if tax.amount >= 0.0)
# Partner IEPS breakdown
and customer.l10n_mx_edi_ieps_breakdown
):
tax_objected = '07'
else:
tax_objected = '02'
base_line['tax_objected'] = tax_objected
base_line['ieps_breakdown'] = (
cfdi_values.get('is_global')
or base_line['tax_objected'] == '07'
or customer.l10n_mx_edi_ieps_breakdown and base_line['tax_objected'] != '08'
)
@api.model
def _add_tax_objected_cfdi_values(self, cfdi_values, base_lines):
""" Add the values about the tax objective of the document to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param base_lines: A list of dictionaries representing the lines of the document.
"""
for base_line in base_lines:
self._add_tax_objected_base_line(cfdi_values, base_line)
@api.model
def _add_and_round_tax_details(self, base_lines, company, tax_lines=None):
""" Add the tax details on the base lines and round them.
:param base_lines: A list of base lines generated using the '_prepare_base_line_for_taxes_computation' method.
:param company: The company owning the base lines.
:param tax_lines: A optional list of base lines generated using the '_prepare_tax_line_for_taxes_computation'
method. If specified, the tax amounts will be computed based on those existing tax lines.
It's used to keep the manual tax amounts set by the user.
:return: A new list of base lines.
"""
AccountTax = self.env['account.tax']
AccountTax._add_tax_details_in_base_lines(base_lines, company)
AccountTax._round_base_lines_tax_details(base_lines, company, tax_lines=tax_lines)
for base_line in base_lines:
is_negative = base_line['tax_details']['raw_total_excluded_currency'] < 0.0
if is_negative:
base_line['special_type'] = 'global_discount'
return base_lines
@api.model
def _dispatch_negative_base_lines(self, base_lines, company):
""" Dispatch the negative lines and put them on the others like discounts.
- Pre-compute in advance some data on taxes_data to be used later on the aggregators.
:param base_lines: A list of base lines generated using the '_prepare_base_line_for_taxes_computation' method.
:param company: The company owning the base lines.
:return: A dictionary containing:
* base_lines: The remaining positive base lines.
* remaining_negative_base_lines: The remaining negative base lines.
* nullified_base_lines: The base lines that are fully discounted at the end.
"""
AccountTax = self.env['account.tax']
# Return of merchandise.
# The negative lines will try to reduce the 'quantity' instead of be added as a discount.
base_lines = AccountTax._dispatch_return_of_merchandise_lines(base_lines, company)
AccountTax._squash_return_of_merchandise_lines(base_lines, company)
# Global discount.
# Let's spread the global discount equally across the others lines instead of adding the full amount
# on the biggest lines.
base_lines = AccountTax._dispatch_global_discount_lines(base_lines, company)
AccountTax._squash_global_discount_lines(base_lines, company)
results = {
'base_lines': [],
'remaining_negative_base_lines': [],
'nullified_base_lines': [],
}
for base_line in base_lines:
compare_results = base_line['currency_id'].compare_amounts(
base_line['tax_details']['raw_total_excluded_currency'],
0,
)
is_nullified = bool(
not base_line['quantity']
or not base_line['price_unit']
)
if is_nullified:
results['nullified_base_lines'].append(base_line)
elif compare_results < 0.0:
results['remaining_negative_base_lines'].append(base_line)
else:
results['base_lines'].append(base_line)
return results
@api.model
def _dispatch_base_lines_tax_breakdown_taxes(self, cfdi_values, base_lines):
""" Manage the tax breakdown. When set, all taxes except the local ones are merge into
the base and disappear from the CFDI.
:param cfdi_values: The current CFDI values.
:param base_lines: A list of dictionaries representing the lines of the document.
"""
for base_line in base_lines:
tax_details = base_line['tax_details']
taxes_data = tax_details['taxes_data']
# Exclude some 'tax_data' in case of tax breakdown.
new_taxes_data = tax_details['taxes_data'] = []
for tax_data in taxes_data:
tax = tax_data['tax']
if (
# Local taxes are not affected.
tax.l10n_mx_tax_type != 'local'
and (
# No tax breakdown:
base_line['tax_objected'] in ('01', '03', '04', '05')
# No IEPS tax breakdown:
or (not base_line['ieps_breakdown'] and tax.l10n_mx_tax_type == 'ieps')
)
):
tax_details['raw_total_excluded_currency'] += tax_data['raw_tax_amount_currency']
tax_details['raw_total_excluded'] += tax_data['raw_tax_amount']
tax_details['total_excluded_currency'] += tax_data['tax_amount_currency']
tax_details['total_excluded'] += tax_data['tax_amount']
else:
new_taxes_data.append(tax_data)
@api.model
def _add_base_lines_cfdi_values(self, cfdi_values, base_lines, global_invoice=False):
""" Add the values about the lines to 'cfdi_values'.
:param cfdi_values: The current CFDI values.
:param base_lines: A list of dictionaries representing the lines of the document.
:param global_invoice: Indicate if the document is a global invoice.
"""
AccountTax = self.env['account.tax']
currency = cfdi_values['currency']
company = cfdi_values['company']
is_refund_gi = cfdi_values['receptor']['uso_cfdi'] == 'G02'
cfdi_values['base_lines'] = base_lines
# Manage tax breakdown.
self._dispatch_base_lines_tax_breakdown_taxes(cfdi_values, base_lines)
# Add 'raw_gross_total_excluded_currency' (a.k.a. 'importe') / 'raw_discount_amount_currency' (a.k.a. 'descuento').
# Round all the values with 6 digits and ensure ROUND(SUM(...)) gives exactly the expected total of the document.
AccountTax._round_raw_total_excluded(
base_lines=base_lines,
company=cfdi_values['company'],
precision_digits=6,
apply_strict_tolerance=True,
)
AccountTax._add_and_round_raw_gross_total_excluded_and_discount(
base_lines=base_lines,
company=cfdi_values['company'],
precision_digits=6,
account_discount_base_lines=True,
apply_strict_tolerance=True,
)
# Pre-compute some values in base_line under 'l10n_mx_cfdi_values' and a grouping key per tax_data under 'l10n_mx_tax_grouping_key'.
for base_line in base_lines:
tax_details = base_line['tax_details']
product = base_line['product_id']
quantity = base_line['quantity']
base_line_cfdi_values = base_line['l10n_mx_cfdi_values'] = {
'cantidad': quantity,
'descuento': tax_details['raw_discount_amount_currency'],
'importe': tax_details['raw_gross_total_excluded_currency'],
'traslados_list': [],
'retenciones_list': [],
'ieps_breakdown': base_line['ieps_breakdown'],
'objeto_imp': base_line['tax_objected'],
}
if base_line_cfdi_values['cantidad']:
base_line_cfdi_values['valor_unitario'] = base_line_cfdi_values['importe'] / base_line_cfdi_values['cantidad']
else:
base_line_cfdi_values['valor_unitario'] = 0.0
if global_invoice:
base_line_cfdi_values['clave_prod_serv'] = '01010101'
base_line_cfdi_values['clave_unidad'] = 'ACT'
base_line_cfdi_values['description'] = "Venta"
base_line_cfdi_values['no_identificacion'] = base_line['_document_name']
base_line_cfdi_values['cuenta_predial'] = None
base_line_cfdi_values['unidad'] = None
elif is_refund_gi:
base_line_cfdi_values['clave_prod_serv'] = '84111506'
base_line_cfdi_values['clave_unidad'] = 'ACT'
base_line_cfdi_values['description'] = base_line['name']
base_line_cfdi_values['no_identificacion'] = product.default_code
base_line_cfdi_values['cuenta_predial'] = product.l10n_mx_edi_predial_account
base_line_cfdi_values['unidad'] = (base_line['uom_id'].name or '').upper()
else:
base_line_cfdi_values['clave_prod_serv'] = base_line.get('product_unspsc_code') or product.unspsc_code_id.code
base_line_cfdi_values['clave_unidad'] = base_line.get('uom_unspsc_code') or base_line['uom_id'].unspsc_code_id.code
base_line_cfdi_values['description'] = base_line['name']
base_line_cfdi_values['no_identificacion'] = product.default_code
base_line_cfdi_values['cuenta_predial'] = product.l10n_mx_edi_predial_account
base_line_cfdi_values['unidad'] = (base_line['uom_id'].name or '').upper()
for tax_data in tax_details['taxes_data']:
tax = tax_data['tax']
local_tax_name = tax.tax_group_id.name if tax.l10n_mx_tax_type == 'local' else None
tax_grouping_key = tax_data['l10n_mx_tax_grouping_key'] = {
'tipo_factor': tax.l10n_mx_factor_type,
'impuesto': TAX_TYPE_TO_CFDI_CODE.get(tax.l10n_mx_tax_type),
'is_withholding': tax.amount < 0.0,
'local_tax_name': local_tax_name,
}
if tax_grouping_key['tipo_factor'] == 'Cuota':
if tax.amount_type == 'fixed':
# The user is managing IEPS with fixed tax like 4.6555 * quantity.
# In that case, the tax amount will be the quota and the quantity will be the base.
tax_grouping_key['tasa_o_cuota'] = tax.amount
tax_grouping_key['scale_from_quantity'] = True
tax_grouping_key['product_field'] = None
elif tax.amount_type == 'code':
# The user is managing IEPS with custom tax like 4.6555 * product.l10n_mx_quantity_in_ml.
# In that case, the tax amount will be retrieved from the formula as an arbitrary value, here 4.6555.
# The base amount will be retrieved from the product using the 'l10n_mx_quantity_in_ml' field.
pattern = r'-?(?:\d*\.\d+|\d+)'
candidates_amounts = re.findall(pattern, tax.formula)
tax_grouping_key['tasa_o_cuota'] = abs(float(candidates_amounts[0])) if candidates_amounts else 0.0
pattern = r'\bquantity\b'
tax_grouping_key['scale_from_quantity'] = bool(re.findall(pattern, tax.formula))
product_fields = tax.formula_decoded_info['product_fields']
tax_grouping_key['product_field'] = product_fields[0] if product_fields else None
else:
# Wrong config.
tax_grouping_key['tasa_o_cuota'] = 0.0
tax_grouping_key['scale_from_quantity'] = False
tax_grouping_key['product_field'] = None
elif tax_grouping_key['tipo_factor'] == 'Tasa':
tax_grouping_key['tasa_o_cuota'] = abs(tax.amount / 100.0)
else:
tax_grouping_key['tasa_o_cuota'] = None
if local_tax_name:
if tax.amount_type in ('fixed', 'code'):
tax_grouping_key['tasade'] = tax_grouping_key['tasa_o_cuota']
else:
tax_grouping_key['tasade'] = tax_grouping_key['tasa_o_cuota'] * 100.0
else:
tax_grouping_key['tasade'] = None
# Populate 'traslados_list' / 'retenciones_list' for each base_line.
def regular_tax_grouping_function(base_line, tax_data):
return (
tax_data['l10n_mx_tax_grouping_key']
if tax_data
and not tax_data['l10n_mx_tax_grouping_key']['local_tax_name']
else None
)
base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(base_lines, regular_tax_grouping_function)
AccountTax._round_raw_tax_amounts(
base_lines_aggregated_values=base_lines_aggregated_values,
company=company,
precision_digits=6,
apply_strict_tolerance=True,
)
for base_line, aggregated_values in base_lines_aggregated_values:
for grouping_key, values in aggregated_values.items():
if not grouping_key:
continue
is_withholding = grouping_key['is_withholding']
tax_values = {
'impuesto': grouping_key['impuesto'],
'tipo_factor': grouping_key['tipo_factor'],
'tasa_o_cuota': grouping_key['tasa_o_cuota'],
'importe': float_round(values['raw_tax_amount_currency'] * (-1 if is_withholding else 1), precision_digits=6),
}
if grouping_key['tipo_factor'] == 'Cuota':
if grouping_key['scale_from_quantity']:
tax_values['base'] = float_round(base_line['quantity'], precision_digits=6)
elif grouping_key['product_field']:
tax_values['base'] = float_round(base_line['product_id'][grouping_key['product_field']], precision_digits=6)
else:
tax_values['base'] = 0.0
else:
tax_values['base'] = float_round(values['raw_base_amount_currency'], precision_digits=6)
if float_is_zero(tax_values['base'], precision_digits=6):
tax_values['base'] = 0.000001
target_list = 'retenciones_list' if is_withholding else 'traslados_list'
base_line['l10n_mx_cfdi_values'][target_list].append(tax_values)
# Populate 'traslados_list' / 'retenciones_list' for the whole document.
cfdi_values['retenciones_list'] = []
cfdi_values['retenciones_reduced_list'] = []
cfdi_values['traslados_list'] = []
values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
for grouping_key, values in values_per_grouping_key.items():
if not grouping_key:
continue
is_withholding = grouping_key['is_withholding']
tax_values = {
'impuesto': grouping_key['impuesto'],
'tipo_factor': grouping_key['tipo_factor'],
'tasa_o_cuota': grouping_key['tasa_o_cuota'],
'importe': values['tax_amount_currency'] * (-1 if is_withholding else 1),
}
if grouping_key['tipo_factor'] == 'Cuota':
if grouping_key['scale_from_quantity']:
tax_values['base'] = sum(
float_round(base_line['quantity'], precision_digits=6)
for base_line, _taxes_data in values['base_line_x_taxes_data']
)
elif grouping_key['product_field']:
tax_values['base'] = sum(
float_round(base_line['product_id'][grouping_key['product_field']], precision_digits=6)
for base_line, _taxes_data in values['base_line_x_taxes_data']
if base_line['product_id']
)
else:
tax_values['base'] = 0.0
else:
tax_values['base'] = values['raw_base_amount_currency']
target_list = 'retenciones_list' if is_withholding else 'traslados_list'
cfdi_values[target_list].append(tax_values)
if is_withholding:
cfdi_values['retenciones_reduced_list'].append(dict(tax_values))
# Populate 'local_traslados_list' / 'local_retenciones_list' for the whole document.
cfdi_values['local_retenciones_list'] = []
cfdi_values['local_traslados_list'] = []
def local_tax_grouping_function(base_line, tax_data):
grouping_key = (
tax_data['l10n_mx_tax_grouping_key']
if tax_data
and tax_data['l10n_mx_tax_grouping_key']['local_tax_name']
else None
)
if grouping_key is not None:
if tax_data['tax'].amount_type in ('fixed', 'code'):
grouping_key['tasade'] = grouping_key['tasa_o_cuota']
else:
grouping_key['tasade'] = grouping_key['tasa_o_cuota'] * 100.0
return grouping_key
base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(base_lines, local_tax_grouping_function)
values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
for grouping_key, values in values_per_grouping_key.items():
if not grouping_key:
continue
is_withholding = grouping_key['is_withholding']
target_list = 'local_retenciones_list' if grouping_key['is_withholding'] else 'local_traslados_list'
cfdi_values[target_list].append({
'local_tax_name': grouping_key['local_tax_name'],
'tasade': grouping_key['tasade'],
'impuesto': grouping_key['impuesto'],
'tipo_factor': grouping_key['tipo_factor'],
'tasa_o_cuota': grouping_key['tasa_o_cuota'],
'importe': values['tax_amount_currency'] * (-1 if is_withholding else 1),
'base': values['base_amount_currency'],
})
# Add 'conceptos_list'.
cfdi_values['conceptos_list'] = [
base_line['l10n_mx_cfdi_values']
for base_line in base_lines
]
# Document's 'descuento'.
cfdi_values['descuento'] = currency.round(sum(concepto['descuento'] for concepto in cfdi_values['conceptos_list']))
cfdi_values['subtotal'] = cfdi_values['descuento']
# Document's 'subtotal' / 'total'.
def grouping_function(base_line, tax_data):
return True
base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(base_lines, grouping_function)
values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
cfdi_values['total'] = 0.0
for values in values_per_grouping_key.values():
cfdi_values['subtotal'] += values['total_excluded_currency']
cfdi_values['total'] += values['total_excluded_currency'] + values['tax_amount_currency']
# Document's tax totals.
for target_key, list_key in (
('total_impuestos_trasladados', 'traslados_list'),
('total_local_impuestos_trasladados', 'local_traslados_list'),
('total_impuestos_retenidos', 'retenciones_list'),
('total_local_impuestos_retenidos', 'local_retenciones_list'),
):
tax_amounts = [
x['importe']
for x in cfdi_values[list_key]
if x.get('tipo_factor') != 'Exento'
]
cfdi_values[target_key] = sum(tax_amounts)
cfdi_values[f'need_{target_key}'] = bool(tax_amounts)
# Cleanup attributes for Exento taxes/descuento.
if currency.is_zero(cfdi_values['descuento']):
cfdi_values['descuento'] = None
for concepto in cfdi_values['conceptos_list']:
if float_is_zero(concepto['descuento'], precision_digits=6):
concepto['descuento'] = None
for key in ('traslados_list', 'retenciones_list'):
for tax_values in concepto[key]:
if tax_values['tipo_factor'] == 'Exento':
tax_values['importe'] = None
for key in ('retenciones_list', 'traslados_list', 'local_retenciones_list', 'local_traslados_list'):
for tax_values in cfdi_values[key]:
if tax_values.get('tipo_factor') == 'Exento':
tax_values['importe'] = None
if not cfdi_values['need_total_impuestos_trasladados']:
cfdi_values['total_impuestos_trasladados'] = None
if not cfdi_values['need_total_impuestos_retenidos']:
cfdi_values['total_impuestos_retenidos'] = None
@api.model
def _get_post_fix_tax_amounts_map(self, base_amount, tax_amount, tax_rate, precision_digits):
if float_round(abs(base_amount * tax_rate - tax_amount), precision_digits, rounding_method='DOWN') == 0.0:
new_base_amount = float_round(base_amount, precision_digits=precision_digits)
new_tax_amount = float_round(tax_amount, precision_digits=precision_digits)
else:
total = base_amount + tax_amount
new_base_amount = float_round(total / (1 + tax_rate), precision_digits=precision_digits)
new_tax_amount = total - new_base_amount
return {
'new_base_amount': new_base_amount,
'new_tax_amount': new_tax_amount,
'delta_base_amount': new_base_amount - base_amount,
'delta_tax_amount': new_tax_amount - tax_amount,
}
@api.model
def _clean_cfdi_values(self, cfdi_values):
""" Clean values from 'cfdi_values' that could represent a security risk like sudoed records.
:param cfdi_values: The current CFDI values.
"""
def clean_node(values):
to_clear = []
for k, v in values.items():
if isinstance(v, dict):
clean_node(v)
elif isinstance(v, (list, tuple)):
for v2 in v:
if isinstance(v2, dict):
clean_node(v2)
elif isinstance(v, models.Model):
if v.env.su:
to_clear.append(k)
for k in to_clear:
values.pop(k)
clean_node(cfdi_values)
@api.model
def _convert_xml_to_attachment_data(self, xml_string):
"""
Create and return a raw XML string value with custom hardcoded declaration.
This ensures the generated string to have double quote in the XML declaration,
because some third party vendors do not accept single quoted declaration.
"""
custom_declaration = b'<?xml version="1.0" encoding="UTF-8"?>\n'
return custom_declaration + etree.tostring(
element_or_tree=xml_string,
pretty_print=True,
encoding='UTF-8',
)
# -------------------------------------------------------------------------
# GLOBAL CFDI
# -------------------------------------------------------------------------
@api.model
def _get_global_invoice_cfdi_sequence(self, company, create_if_missing=True):
""" Get or create the ir.sequence to be used to get the global invoice document name.
:param company: The company owning the sequence.
:param create_if_missing: Create the sequence if not exists yet.
:return: An ir.sequence record.
"""
sequence = company.l10n_mx_edi_global_invoice_sequence_id
if sequence:
return sequence
if not sequence and create_if_missing:
return company._create_l10n_mx_edi_global_invoice_sequence()
@api.model
def _consume_global_invoice_cfdi_sequence(self, sequence, number_next):
""" Update the ir.sequence used to get the folio of the global invoice.
:param sequence: The sequence.
:param number_next: The consumed number.
:return:
"""
sequence.sudo().number_next = number_next + 1
sequence.flush_recordset(fnames=['number_next'])
@api.model
def _add_global_invoice_cfdi_values(self, cfdi_values, cfdi_lines, document_date=None, periodicity='04', origin=None):
""" Add the generic values about the global invoice in 'cfdi_values'.
:param cfdi_values: The cfdi_values collected so far.
:param cfdi_lines: The lines in the global invoice.
:param document_date: The date of the global invoice.
:param periodicity: The periodicity. Default is '04'. See 'GLOBAL_INVOICE_PERIODICITY_DEFAULT_VALUES'.
:param origin: The origin of the CFDI when creating a replacement.
"""
AccountTax = self.env['account.tax']
currency = cfdi_lines[0]['currency_id']
rates = []
if currency.name != 'MXN':
parents = set()
for line in cfdi_lines:
if line['rate'] and line['document_name'] not in parents:
parents.add(line['document_name'])
rates.append(1 / line['rate'])
rate = sum(rates) / len(rates) if rates else None
cfdi_values['is_global'] = True
self._add_base_cfdi_values(cfdi_values)
self._add_currency_cfdi_values(cfdi_values, currency)
self._add_document_origin_cfdi_values(cfdi_values, origin)
self._add_customer_cfdi_values(cfdi_values, to_public=True)
# Group lines per document_name / tax_ids.
def grouping_function(base_line, tax_data):
return {
'tax_ids': base_line['tax_ids'],
'_document_name': base_line['document_name'],
}
new_base_lines = []
base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(cfdi_lines, grouping_function)
values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
for grouping_key, values in values_per_grouping_key.items():
aggregated_base_lines = [
base_line
for base_line, _taxes_data in values['base_line_x_taxes_data']
]
aggregated_tax_details = None
for base_line in aggregated_base_lines:
if aggregated_tax_details:
aggregated_tax_details = AccountTax._merge_tax_details(
tax_details_1=aggregated_tax_details,
tax_details_2=base_line['tax_details'],
)
else:
aggregated_tax_details = base_line['tax_details']
new_base_line = AccountTax._prepare_base_line_for_taxes_computation(
None,
tax_ids=grouping_key['tax_ids'],
price_unit=sum(
base_line['price_unit'] * (1 - (base_line['discount'] / 100.0)) * base_line['quantity']
for base_line in aggregated_base_lines
),
quantity=1.0,
currency_id=currency,
rate=rate or 1.0,
tax_details=aggregated_tax_details,
_document_name=grouping_key['_document_name'],
)
new_base_lines.append(new_base_line)
self._add_tax_objected_cfdi_values(cfdi_values, new_base_lines)
self._add_base_lines_cfdi_values(cfdi_values, new_base_lines, global_invoice=True)
# Sequence:
sequence = self._get_global_invoice_cfdi_sequence(cfdi_values['company'], create_if_missing=False)
if not sequence:
sequence = self._get_global_invoice_cfdi_sequence(cfdi_values['root_company'])
cfdi_date = fields.Date.context_today(self)
str_date = fields.Date.to_string(cfdi_date)
folio = str(sequence.number_next)
serie, _interpolated_suffix = sequence._get_prefix_suffix(date=str_date, date_range=str_date)
# Periodicity.
document_date = document_date or cfdi_date
month = document_date.month
if periodicity == '05':
periodicity_month = int(12 + ((month + (month % 2)) / 2))
else:
periodicity_month = month
cfdi_values.update({
'sequence': sequence,
'folio': folio,
'serie': serie,
'fecha': cfdi_date.strftime(CFDI_DATE_FORMAT),
'tipo_cambio': rate,
'information_global': {
'periodicidad': periodicity,
'meses': str(periodicity_month).rjust(2, '0'),
'ano': str(document_date.year),
},
'condiciones_de_pago': None,
'tipo_de_comprobante': 'I',
})
# -------------------------------------------------------------------------
# CFDI: PACs
# -------------------------------------------------------------------------
@api.model
def _get_finkok_credentials(self, company):
''' Return the company credentials for PAC: finkok. Does not depend on a recordset
'''
if company.l10n_mx_edi_pac_test_env:
return {
'username': 'cfdi@vauxoo.com',
'password': 'vAux00__',
'sign_url': 'http://demo-facturacion.finkok.com/servicios/soap/stamp.wsdl',
'cancel_url': 'http://demo-facturacion.finkok.com/servicios/soap/cancel.wsdl',
}
else:
if not company.sudo().l10n_mx_edi_pac_username or not company.sudo().l10n_mx_edi_pac_password:
return {
'errors': [_("The username and/or password are missing.")]
}
return {
'username': company.sudo().l10n_mx_edi_pac_username,
'password': company.sudo().l10n_mx_edi_pac_password,
'sign_url': 'http://facturacion.finkok.com/servicios/soap/stamp.wsdl',
'cancel_url': 'http://facturacion.finkok.com/servicios/soap/cancel.wsdl',
}
@api.model
def _finkok_sign(self, credentials, cfdi):
''' Send the CFDI XML document to Finkok for signature. Does not depend on a recordset
'''
def get_in_error(error, key):
if key in error:
return error[key]
try:
client = Client(credentials['sign_url'], timeout=20)
response = client.service.stamp(cfdi, credentials['username'], credentials['password'])
# pylint: disable=broad-except
except Exception as e:
return {
'errors': [_("The Finkok service failed to sign with the following error: %s", str(e))],
}
if response.Incidencias and not response.xml:
error = response.Incidencias.Incidencia[0]
code = get_in_error(error, 'CodigoError')
msg = get_in_error(error, 'MensajeIncidencia')
extra = get_in_error(error, 'ExtraInfo')
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
if extra:
errors.append(_("Extra Info : %s", extra))
return {'errors': errors}
cfdi_signed = response.xml if 'xml' in response else None
if cfdi_signed:
cfdi_signed = cfdi_signed.encode('utf-8')
return {
'cfdi_str': cfdi_signed,
}
@api.model
def _finkok_cancel(self, cfdi_values, credentials, uuid, cancel_reason, cancel_uuid=None):
company = cfdi_values['root_company']
certificate_sudo = cfdi_values['certificate'].sudo()
cer_pem = base64.b64decode(certificate_sudo.pem_certificate)
key_pem = self._get_unencrypted_private_key_pem(certificate_sudo.private_key_id)
try:
client = Client(credentials['cancel_url'], timeout=20)
factory = client.type_factory('apps.services.soap.core.views')
uuid_type = factory.UUID()
uuid_type.UUID = uuid
uuid_type.Motivo = cancel_reason
if cancel_uuid:
uuid_type.FolioSustitucion = cancel_uuid
docs_list = factory.UUIDArray(uuid_type)
response = client.service.cancel(
docs_list,
credentials['username'],
credentials['password'],
company.vat,
cer_pem,
key_pem,
)
# pylint: disable=broad-except
except Exception as e:
return {
'errors': [_("The Finkok service failed to cancel with the following error: %s", str(e))],
}
code = None
msg = None
if 'Folios' in response and response.Folios:
if 'EstatusUUID' in response.Folios.Folio[0]:
response_code = response.Folios.Folio[0].EstatusUUID
if response_code not in ('201', '202'):
code = response_code
msg = _("Cancelling got an error")
elif 'CodEstatus' in response:
code = response.CodEstatus
msg = _("Cancelling got an error")
else:
msg = _('A delay of 2 hours has to be respected before to cancel')
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
if errors:
return {'errors': errors}
return {}
@api.model
def _get_solfact_credentials(self, company):
''' Return the company credentials for PAC: solucion factible. Does not depend on a recordset
'''
if company.l10n_mx_edi_pac_test_env:
return {
'username': 'testing@solucionfactible.com',
'password': 'timbrado.SF.16672',
'url': 'https://testing.solucionfactible.com/ws/services/Timbrado?wsdl',
}
else:
if not company.sudo().l10n_mx_edi_pac_username or not company.sudo().l10n_mx_edi_pac_password:
return {
'errors': [_("The username and/or password are missing.")]
}
return {
'username': company.sudo().l10n_mx_edi_pac_username,
'password': company.sudo().l10n_mx_edi_pac_password,
'url': 'https://solucionfactible.com/ws/services/Timbrado?wsdl',
}
@api.model
def _solfact_sign(self, credentials, cfdi):
''' Send the CFDI XML document to Solucion Factible for signature. Does not depend on a recordset
'''
try:
client = Client(credentials['url'], timeout=20)
response = client.service.timbrar(credentials['username'], credentials['password'], cfdi, False)
# pylint: disable=broad-except
except Exception as e:
return {
'errors': [_("The Solucion Factible service failed to sign with the following error: %s", str(e))],
}
if response.status != 200:
# ws-timbrado-timbrar - status 200 : CFDI correctamente validado y timbrado.
return {
'errors': [_("The Solucion Factible service failed to sign with the following error: %s", response.mensaje)],
}
if response.resultados:
result = response.resultados[0]
else:
result = response
cfdi_signed = result.cfdiTimbrado if 'cfdiTimbrado' in result else None
if cfdi_signed:
return {
'cfdi_str': cfdi_signed,
}
msg = result.mensaje if 'mensaje' in result else None
code = result.status if 'status' in result else None
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
return {'errors': errors}
@api.model
def _solfact_cancel(self, cfdi_values, credentials, uuid, cancel_reason, cancel_uuid=None):
certificate = cfdi_values['certificate']
uuid_param = f"{uuid}|{cancel_reason}|"
if cancel_uuid:
uuid_param += cancel_uuid
cer_pem = base64.b64decode(certificate.pem_certificate)
key_pem = self._get_unencrypted_private_key_pem(certificate.private_key_id)
key_password = certificate.private_key_id.password
try:
client = Client(credentials['url'], timeout=20)
response = client.service.cancelar(
credentials['username'], credentials['password'],
uuid_param, cer_pem, key_pem, key_password
)
# pylint: disable=broad-except
except Exception as e:
return {
'errors': [_("The Solucion Factible service failed to cancel with the following error: %s", str(e))],
}
if response.status not in (200, 201):
# ws-timbrado-cancelar - status 200 : El proceso de cancelación se ha completado correctamente.
# ws-timbrado-cancelar - status 201 : El folio se ha cancelado con éxito.
return {
'errors': [_("The Solucion Factible service failed to cancel with the following error: %s", response.mensaje)],
}
if response.resultados:
response_code = response.resultados[0].statusUUID if 'statusUUID' in response.resultados[0] else None
else:
response_code = response.status if 'status' in response else None
# no show code and response message if cancel was success
msg = None
code = None
if response_code not in ('201', '202'):
code = response_code
if response.resultados:
result = response.resultados[0]
else:
result = response
if 'mensaje' in result:
msg = result.mensaje
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
if errors:
return {'errors': errors}
return {}
@api.model
def _document_get_sw_token(self, credentials):
if credentials['password'] and not credentials['username']:
# token is configured directly instead of user/password
return {
'token': credentials['password'].strip(),
}
try:
headers = {
'user': credentials['username'],
'password': credentials['password'],
'Cache-Control': "no-cache"
}
response = requests.post(credentials['login_url'], headers=headers, timeout=20)
response.raise_for_status()
response_json = response.json()
return {
'token': response_json['data']['token'],
}
except (requests.exceptions.RequestException, KeyError, TypeError) as req_e:
return {
'errors': [str(req_e)],
}
@api.model
def _get_sw_credentials(self, company):
'''Get the company credentials for PAC: SW. Does not depend on a recordset
'''
if not company.sudo().l10n_mx_edi_pac_username or not company.sudo().l10n_mx_edi_pac_password:
return {
'errors': [_("The username and/or password are missing.")]
}
credentials = {
'username': company.sudo().l10n_mx_edi_pac_username,
'password': company.sudo().l10n_mx_edi_pac_password,
}
if company.l10n_mx_edi_pac_test_env:
credentials.update({
'login_url': 'https://services.test.sw.com.mx/security/authenticate',
'sign_url': 'https://services.test.sw.com.mx/cfdi33/stamp/v3/b64',
'cancel_url': 'https://services.test.sw.com.mx/cfdi33/cancel/csd',
})
else:
credentials.update({
'login_url': 'https://services.sw.com.mx/security/authenticate',
'sign_url': 'https://services.sw.com.mx/cfdi33/stamp/v3/b64',
'cancel_url': 'https://services.sw.com.mx/cfdi33/cancel/csd',
})
# Retrieve a valid token.
credentials.update(self._document_get_sw_token(credentials))
return credentials
@api.model
def _document_sw_call(self, url, headers, payload=None):
try:
response = requests.post(
url,
data=payload,
headers=headers,
verify=True,
timeout=(20, 120),
)
except requests.exceptions.RequestException as req_e:
return {'status': 'error', 'message': str(req_e)}
msg = ""
try:
response.raise_for_status()
except requests.exceptions.HTTPError as res_e:
msg = str(res_e)
try:
response_json = response.json()
except JSONDecodeError:
# If it is not possible get json then
# use response exception message
return {'status': 'error', 'message': msg}
if (response_json['status'] == 'error' and
response_json['message'].startswith('307')):
# XML signed previously
cfdi = base64.encodebytes(
response_json['messageDetail'].encode('UTF-8'))
cfdi = cfdi.decode('UTF-8')
response_json['data'] = {'cfdi': cfdi}
# We do not need an error message if XML signed was
# retrieved then cleaning them
response_json.update({
'message': None,
'messageDetail': None,
'status': 'success',
})
return response_json
@api.model
def _sw_sign(self, credentials, cfdi):
''' calls the SW web service to send and sign the CFDI XML.
Method does not depend on a recordset
'''
cfdi_b64 = base64.encodebytes(cfdi).decode('UTF-8')
random_values = [random.choice(string.ascii_letters + string.digits) for n in range(30)]
boundary = ''.join(random_values)
payload = """--%(boundary)s
Content-Type: text/xml
Content-Transfer-Encoding: binary
Content-Disposition: form-data; name="xml"; filename="xml"
%(cfdi_b64)s
--%(boundary)s--
""" % {'boundary': boundary, 'cfdi_b64': cfdi_b64}
payload = payload.replace('\n', '\r\n').encode('UTF-8')
headers = {
'Authorization': "bearer " + credentials['token'],
'Content-Type': ('multipart/form-data; '
'boundary="%s"') % boundary,
}
response_json = self._document_sw_call(credentials['sign_url'], headers, payload=payload)
try:
cfdi_signed = response_json['data']['cfdi']
except (KeyError, TypeError):
cfdi_signed = None
if cfdi_signed:
return {
'cfdi_str': base64.decodebytes(cfdi_signed.encode('UTF-8')),
}
else:
code = response_json.get('message')
msg = response_json.get('messageDetail')
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
return {'errors': errors}
@api.model
def _sw_cancel(self, cfdi_values, credentials, uuid, cancel_reason, cancel_uuid=None):
company = cfdi_values['root_company']
certificate_sudo = cfdi_values['certificate'].sudo()
headers = {
'Authorization': "bearer " + credentials['token'],
'Content-Type': "application/json"
}
payload_dict = {
'rfc': company.vat,
'b64Cer': certificate_sudo.pem_certificate.decode('UTF-8'),
'b64Key': base64.b64encode(self._get_unencrypted_private_key_pem(certificate_sudo.private_key_id)).decode('UTF-8'),
'password': certificate_sudo.private_key_id.password,
'uuid': uuid,
'motivo': cancel_reason,
}
if cancel_uuid:
payload_dict['folioSustitucion'] = cancel_uuid
payload = json.dumps(payload_dict)
response_json = self._document_sw_call(credentials['cancel_url'], headers, payload=payload.encode('UTF-8'))
cancelled = response_json['status'] == 'success'
if cancelled:
data_codes = response_json.get('data', {}).get('uuid', {}).values()
data_code = next(iter(data_codes)) if data_codes else ''
code = '' if data_code in ('201', '202') else data_code
msg = '' if data_code in ('201', '202') else _("Cancelling got an error")
else:
code = response_json.get('message')
msg = response_json.get('messageDetail')
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
if errors:
return {'errors': errors}
return {}
@api.model
def _get_unencrypted_private_key_pem(self, key):
return serialization.load_pem_private_key(
base64.b64decode(key.pem_key),
key.password.encode() if key.password else None,
).private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
# -------------------------------------------------------------------------
# BUSINESS METHODS
# -------------------------------------------------------------------------
def _create_update_document(self, records, document_values, accept_method):
""" Create/update a new document.
:param records: The records owning the document.
:param document_values: The values to create the document.
:param accept_method: A method taking document can be updated.
:return The newly created or updated document.
"""
def create_attachment(attachment_values):
return self.env['ir.attachment'].with_context(clean_context(self.env.context)).with_user(SUPERUSER_ID).create({
**attachment_values,
'res_model': records._name,
'res_id': records.id if len(records) == 1 else None,
'type': 'binary',
'mimetype': 'application/xml',
})
today = fields.Datetime.now()
result_document = None
# Prepare values for the attachment.
if isinstance(document_values.get('attachment_id'), dict):
attachment_values = document_values.pop('attachment_id')
# Pretty-print the xml.
xml_string = etree.fromstring(attachment_values['raw'])
attachment_values['raw'] = self.env['l10n_mx_edi.document']._convert_xml_to_attachment_data(xml_string)
else:
attachment_values = None
for existing_document in self.sorted():
if accept_method(existing_document):
if attachment_values:
if existing_document.attachment_id:
existing_document.attachment_id.update(attachment_values)
else:
document_values['attachment_id'] = create_attachment(attachment_values).id
existing_document.write({
'message': None,
**document_values,
'datetime': today,
})
result_document = existing_document
break
if not result_document:
if attachment_values:
document_values['attachment_id'] = create_attachment(attachment_values).id
result_document = self.create({
**document_values,
'datetime': today,
})
# During Global Invoice creation, this method is called from an empty recordset `records=self.env['l10n_mx_edi.document']`.
# In that case we want the attachment res_id to be the newly created document.
# sudo because attachment made by SUPERUSER requires group_system to modify
result_attachment = result_document.attachment_id
if result_attachment.res_model == result_document._name and not result_attachment.res_id:
result_attachment.with_user(SUPERUSER_ID).res_id = result_document.id
return result_document
@api.model
def _create_update_invoice_document_from_invoice(self, invoice, document_values):
""" Create/update a new document for invoice.
:param invoice: An invoice.
:param document_values: The values to create the document.
"""
# Never remove a document that is already recorded in the SAT system.
remaining_documents = invoice.l10n_mx_edi_invoice_document_ids\
.filtered(lambda doc: (
doc.sat_state not in ('valid', 'cancelled', 'skip')
or (doc.sat_state == 'cancelled' and doc.state == 'invoice_cancel_requested')
))
if document_values['state'] in ('invoice_sent', 'invoice_cancel', 'invoice_cancel_requested'):
accept_method_state = f"{document_values['state']}_failed"
else:
accept_method_state = document_values['state']
document = remaining_documents._create_update_document(
invoice,
document_values,
lambda x: x.state == accept_method_state,
)
document_states_to_remove = {
'invoice_sent_failed',
'invoice_cancel_requested_failed',
'invoice_cancel_failed',
'ginvoice_sent_failed',
'ginvoice_cancel_failed',
}
# In case we successfully cancel the invoice, we no longer need the previous cancellation requests.
# So, let's remove them.
if document.state == 'invoice_cancel':
document_states_to_remove.add('invoice_cancel_requested')
remaining_documents\
.filtered(lambda x: x != document and x.state in document_states_to_remove) \
.unlink()
if document.state in ('invoice_sent', 'invoice_cancel', 'invoice_cancel_requested'):
remaining_documents \
.exists() \
.filtered(lambda x: x != document and x.attachment_uuid == document.attachment_uuid) \
.write({'sat_state': 'skip'})
return document
@api.model
def _create_update_payment_document(self, payment, document_values):
""" Create/update a new document for payment.
:param payment: A payment reconciled with some invoices.
:param document_values: The values to create the document.
"""
# Never remove a document that is already recorded in the SAT system.
remaining_documents = payment.l10n_mx_edi_payment_document_ids\
.filtered(lambda doc: doc.sat_state not in ('valid', 'cancelled', 'skip'))
if document_values['state'] in ('payment_sent', 'payment_sent_pue', 'payment_cancel'):
accept_method_state = f"{document_values['state']}_failed"
else:
accept_method_state = document_values['state']
document = remaining_documents\
.filtered(lambda x: x.state not in ('payment_sent', 'payment_cancel'))\
._create_update_document(
payment,
document_values,
lambda x: x.state in (accept_method_state, 'payment_sent_pue'),
)
remaining_documents \
.filtered(lambda x: x != document and x.state in {'payment_sent_failed', 'payment_cancel_failed'}) \
.unlink()
if document.state in ('payment_sent', 'payment_cancel'):
remaining_documents \
.exists() \
.filtered(lambda x: x != document and x.attachment_uuid == document.attachment_uuid) \
.write({'sat_state': 'skip'})
return document
@api.model
def _create_update_global_invoice_document_from_invoices(self, invoices, document_values):
""" Create/update a new document for global invoice.
:param invoices: The related invoices.
:param document_values: The values to create the document.
"""
# Never remove a document that is already recorded in the SAT system.
remaining_documents = invoices[0].l10n_mx_edi_invoice_document_ids\
.filtered(lambda doc: doc.sat_state not in ('valid', 'cancelled', 'skip'))
if document_values['state'] in ('ginvoice_sent', 'ginvoice_cancel'):
accept_method_state = f"{document_values['state']}_failed"
else:
accept_method_state = document_values['state']
document = remaining_documents._create_update_document(
self,
document_values,
lambda x: x.state == accept_method_state,
)
remaining_documents \
.filtered(lambda x: x != document and x.state in {
'invoice_sent_failed',
'invoice_cancel_failed',
'ginvoice_sent_failed',
'ginvoice_cancel_failed',
}) \
.unlink()
if document.state in ('ginvoice_sent', 'ginvoice_cancel'):
remaining_documents \
.exists() \
.filtered(lambda x: x != document and x.attachment_uuid == document.attachment_uuid) \
.write({'sat_state': 'skip'})
return document
@api.model
def _get_cadena_xslts(self):
return 'l10n_mx_edi/data/4.0/xslt/cadenaoriginal_TFD.xslt', 'l10n_mx_edi/data/4.0/xslt/cadenaoriginal.xslt'
@api.model
def _decode_cfdi_attachment(self, cfdi_data):
""" Extract relevant data from the CFDI attachment.
:param: cfdi_data: The cfdi data as raw bytes.
:return: A python dictionary.
"""
cadena_tfd, cadena = self._get_cadena_xslts()
def get_cadena(cfdi_node, template):
if cfdi_node is None:
return None
with tools.file_open(template) as f:
cadena_root = etree.parse(f)
return str(etree.XSLT(cadena_root)(cfdi_node))
def get_node(node, xpath):
nodes = node.xpath(xpath)
return nodes[0] if nodes else None
def get_value(node, key):
if node is None:
return None
upper_key = key[0].upper() + key[1:]
lower_key = key[0].lower() + key[1:]
return node.get(upper_key) or node.get(lower_key)
# Nothing to decode.
if not cfdi_data:
return {}
try:
cfdi_node = etree.fromstring(cfdi_data)
emisor_node = get_node(cfdi_node, "//*[local-name()='Emisor']")
receptor_node = get_node(cfdi_node, "//*[local-name()='Receptor']")
info_global_node = get_node(cfdi_node, "//*[local-name()='InformacionGlobal']")
relacionado_nodes = cfdi_node.xpath("//*[local-name()='CfdiRelacionados']")
except etree.XMLSyntaxError:
# Not an xml
return {}
except AttributeError:
# Not a CFDI
return {}
tfd_node = get_node(cfdi_node, "//*[local-name()='TimbreFiscalDigital']")
origin = None
origin_list = []
cfdi_relation_data = []
for node in relacionado_nodes:
origin_type = get_value(node, "TipoRelacion")
uuid_nodes = node.getchildren()
origin_uuids = []
for uuid_node in uuid_nodes:
if uuid := get_value(uuid_node, 'UUID'):
origin_uuids.append(uuid)
cfdi_relation_data.append({'relation_type': origin_type, 'uuid': uuid})
if origin_uuids and origin_type:
origin_uuids_str = ','.join(origin_uuids)
origin_list.append(f'{origin_type}|{origin_uuids_str}')
if origin_list:
origin = ','.join(origin_list)
return {
'uuid': get_value(tfd_node, 'UUID'),
'supplier_rfc': get_value(emisor_node, 'Rfc'),
'customer_rfc': get_value(receptor_node, 'Rfc'),
'pat_rfc': get_value(tfd_node, 'RfcProvCertif'),
'amount_total': get_value(cfdi_node, 'Total'),
'cfdi_node': cfdi_node,
'usage': get_value(receptor_node, 'UsoCFDI'),
'payment_method': get_value(cfdi_node, 'formaDePago') or get_value(cfdi_node, 'MetodoPago'),
'bank_account': get_value(cfdi_node, 'NumCtaPago'),
'sello': get_value(cfdi_node, 'sello') or 'No identificado',
'sello_sat': get_value(tfd_node, 'SelloSAT') or 'No identificado',
'cadena': get_cadena(tfd_node, cadena_tfd) or get_cadena(cfdi_node, cadena),
'certificate_number': get_value(cfdi_node, 'NoCertificado'),
'certificate_sat_number': get_value(tfd_node, 'NoCertificadoSAT'),
'expedition': get_value(cfdi_node, 'LugarExpedicion'),
'fiscal_regime': get_value(emisor_node, 'RegimenFiscal') or '',
'emission_date_str': (get_value(cfdi_node, 'Fecha') or '').replace('T', ' '),
'stamp_date': (get_value(tfd_node, 'FechaTimbrado') or '').replace('T', ' '),
'periodicity': get_value(info_global_node, 'Periodicidad'),
'origin': origin,
'cfdi_relation_data': cfdi_relation_data
}
@api.model
def _get_pac_method_map(self):
""" Returns a dictionary containing the PAC methods for credentials, sign, or cancel. """
return {
'credentials': {
'finkok': self._get_finkok_credentials,
'solfact': self._get_solfact_credentials,
'sw': self._get_sw_credentials,
},
'sign': {
'finkok': self._finkok_sign,
'solfact': self._solfact_sign,
'sw': self._sw_sign,
},
'cancel': {
'finkok': self._finkok_cancel,
'solfact': self._solfact_cancel,
'sw': self._sw_cancel,
},
}
@api.model
def _send_api(self, company, qweb_template, cfdi_filename, on_populate, on_failure, on_success):
""" Common way to send a document.
:param company: The company.
:param qweb_template: The template name to render the cfdi.
:param cfdi_filename: The filename of the document.
:param on_failure: The method to call in case of failure.
:param on_success: The method to call in case of success.
"""
# == Check the config ==
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(company)
if cfdi_values.get('errors'):
on_failure("\n".join(cfdi_values['errors']))
if self._can_commit():
self.env.cr.commit()
return
root_company = cfdi_values['root_company']
self.env['l10n_mx_edi.document']._add_certificate_cfdi_values(cfdi_values)
if cfdi_values.get('errors'):
on_failure("\n".join(cfdi_values['errors']))
if self._can_commit():
self.env.cr.commit()
return
# == CFDI values ==
populate_return = on_populate(cfdi_values)
if cfdi_values.get('errors'):
on_failure("\n".join(cfdi_values['errors']))
if self._can_commit():
self.env.cr.commit()
return
# == Generate the CFDI ==
certificate_sudo = cfdi_values['certificate'].sudo()
self._clean_cfdi_values(cfdi_values)
cfdi = self.env['ir.qweb']._render(qweb_template, cfdi_values)
if 'cfdi_cartaporte' in qweb_template:
# Since we are inheriting version 3.0 of the Carta Porte template,
# we need to update both the namespace prefix and its URI to version 3.1.
cfdi = re.sub(r'([cC]arta[pP]orte)30', r'\g<1>31', str(cfdi))
# == Append Complementos addenda to send ==
if addenda_complementos := cfdi_values \
.get('addendas', self.env['l10n_mx_edi.addenda']) \
._filter_addenda_by_xml_node('complemento'):
append_values = cfdi_values['move']._l10n_mx_edi_cfdi_invoice_append_addendas(
cfdi_str=cfdi,
addendas=addenda_complementos,
)
if append_values.get('errors'):
on_failure("\n".join(append_values['errors']))
if self._can_commit():
self.env.cr.commit()
return
cfdi = append_values['cfdi']
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(cfdi)
cfdi_infos['cfdi_node'].attrib['Sello'] = certificate_sudo._sign(cfdi_infos['cadena'], formatting='base64')
# -- clean schema locations --
xsi_ns = cfdi_infos['cfdi_node'].nsmap['xsi']
schema_locations = cfdi_infos['cfdi_node'].attrib[f"{{{xsi_ns}}}schemaLocation"].split()
schema_parts = {ns: location for ns, location in zip(schema_locations[::2], schema_locations[1::2]) if ns in cfdi_infos['cfdi_node'].nsmap.values()}
for ns in cfdi_infos['cfdi_node'].nsmap:
if ns != 'xsi' and not cfdi_infos['cfdi_node'].xpath(f'//{ns}:*', namespaces=cfdi_infos['cfdi_node'].nsmap):
schema_parts.pop(cfdi_infos['cfdi_node'].nsmap[ns])
cfdi_infos['cfdi_node'].attrib[f'{{{xsi_ns}}}schemaLocation'] = ' '.join(f"{ns} {location}" for ns, location in schema_parts.items())
# Clean up unused namespaces
etree.cleanup_namespaces(cfdi_infos['cfdi_node'], keep_ns_prefixes=['xsi'])
cfdi_str = self.env['l10n_mx_edi.document']._convert_xml_to_attachment_data(cfdi_infos['cfdi_node'])
# == Check credentials ==
pac_name = root_company.l10n_mx_edi_pac
credentials = self._get_pac_method_map()['credentials'][pac_name](root_company)
if credentials.get('errors'):
on_failure(
"\n".join(credentials['errors']),
cfdi_filename=cfdi_filename,
cfdi_str=cfdi_str,
)
if self._can_commit():
self.env.cr.commit()
return
# == Check PAC ==
sign_results = self._get_pac_method_map()['sign'][pac_name](credentials, cfdi_str)
if sign_results.get('errors'):
on_failure(
"\n".join(sign_results['errors']),
cfdi_filename=cfdi_filename,
cfdi_str=cfdi_str,
)
if self._can_commit():
self.env.cr.commit()
return
# == Success ==
on_success(cfdi_values, cfdi_filename, sign_results['cfdi_str'], populate_return=populate_return)
if self._can_commit():
self.env.cr.commit()
def _cancel_api(self, company, cancel_reason, on_failure, on_success):
""" Common way to cancel a document.
:param company: The company.
:param cancel_reason: The reason for this cancel.
:param on_failure: The method to call in case of failure.
:param on_success: The method to call in case of success.
"""
self.ensure_one()
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(company)
if cfdi_values.get('errors'):
on_failure("\n".join(cfdi_values['errors']))
if self._can_commit():
self.env.cr.commit()
return
root_company = cfdi_values['root_company']
self.env['l10n_mx_edi.document']._add_certificate_cfdi_values(cfdi_values)
if cfdi_values.get('errors'):
on_failure("\n".join(cfdi_values['errors']))
if self._can_commit():
self.env.cr.commit()
return
# == Check credentials ==
pac_name = root_company.l10n_mx_edi_pac
credentials = self._get_pac_method_map()['credentials'][pac_name](root_company)
if credentials.get('errors'):
on_failure("\n".join(credentials['errors']))
if self._can_commit():
self.env.cr.commit()
return
# == Check PAC ==
substitution_doc = self._get_substitution_document()
cancel_uuid = substitution_doc.attachment_uuid
cancel_results = self._get_pac_method_map()['cancel'][pac_name](
cfdi_values,
credentials,
self.attachment_uuid,
cancel_reason,
cancel_uuid=cancel_uuid,
)
if cancel_results.get('errors'):
on_failure("\n".join(cancel_results['errors']))
if self._can_commit():
self.env.cr.commit()
return
# == Success ==
on_success()
if self._can_commit():
self.env.cr.commit()
def _l10n_mx_edi_get_extra_common_report_values(self, l10n_mx_edi_cfdi_attachment):
cfdi_infos = self._decode_cfdi_attachment(l10n_mx_edi_cfdi_attachment.raw)
if not cfdi_infos:
return {}
barcode_value_params = keep_query(
id=cfdi_infos['uuid'],
re=cfdi_infos['supplier_rfc'],
rr=cfdi_infos['customer_rfc'],
tt=cfdi_infos['amount_total'],
)
barcode_sello = url_quote_plus(cfdi_infos['sello'][-8:], safe='=/').replace('%2B', '+')
barcode_value = url_quote_plus(f'https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?{barcode_value_params}&fe={barcode_sello}')
barcode_src = f'/report/barcode/?barcode_type=QR&value={barcode_value}&width=180&height=180'
return {
**cfdi_infos,
'barcode_src': barcode_src,
}
# -------------------------------------------------------------------------
# SAT
# -------------------------------------------------------------------------
def _fetch_sat_status(self, supplier_rfc, customer_rfc, total, uuid):
url = 'https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc?wsdl'
params = f'?id={uuid or ""}' \
f'&re={tools.html_escape(supplier_rfc or "")}' \
f'&rr={tools.html_escape(customer_rfc or "")}' \
f'&tt={total or 0.0}'
transport = Transport(timeout=20)
try:
client = Client(wsdl=url, transport=transport)
response = client.service.Consulta(params)
fetched_state = response['Estado'] if hasattr(response, 'Estado') else ''
# pylint: disable=broad-except
except Exception as e:
return {
'error': _("Failure during update of the SAT status: %s", str(e)),
'value': 'error',
}
if fetched_state == 'Vigente':
return {'value': 'valid'}
elif fetched_state == 'Cancelado':
return {'value': 'cancelled'}
elif fetched_state == 'No Encontrado':
return {'value': 'not_found'}
else:
return {'value': 'not_defined'}
def _update_document_sat_state(self, sat_state, error=None):
""" Update the current document with the newly fetched state from the SAT.
:param sat_state: The SAT state returned by '_fetch_sat_status'.
:param error: In case of error, the message returned by the SAT.
"""
self.ensure_one()
if self.move_id and self.state in ('invoice_sent', 'invoice_cancel', 'invoice_cancel_requested', 'invoice_received'):
self.move_id._l10n_mx_edi_cfdi_invoice_update_sat_state(self, sat_state, error=error)
return True
elif self.state in ('payment_sent', 'payment_cancel'):
if self.move_id:
self.move_id._l10n_mx_edi_cfdi_payment_update_sat_state(self, sat_state, error=error)
return True
else:
source_records = self._get_source_records()
if source_records and self.state in ('ginvoice_sent', 'ginvoice_cancel'):
source_records._l10n_mx_edi_cfdi_global_invoice_update_document_sat_state(self, sat_state, error=error)
return True
return False
def _update_sat_state(self):
""" Update the SAT state.
:param: cadena_tfd: The path to the cadenaoriginal_TFD xslt file.
:param: cadena: The path to the cadenaoriginal xslt file.
"""
self.ensure_one()
self.sat_state = self.sat_state # force update write_date - see _fetch_and_update_sat_status
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(self.attachment_id.raw)
if not cfdi_infos:
return
sat_results = self._fetch_sat_status(
cfdi_infos['supplier_rfc'],
cfdi_infos['customer_rfc'],
cfdi_infos['amount_total'],
cfdi_infos['uuid'],
)
if self.sat_state != sat_results['value']:
self._update_document_sat_state(sat_results['value'], error=sat_results.get('error'))
if self._can_commit():
self.env.cr.commit()
return sat_results
@api.model
def _get_update_sat_status_domains(self, from_cron=True):
results = [
[
('state', 'in', (
'ginvoice_sent',
'invoice_sent',
'payment_sent',
'ginvoice_cancel',
'invoice_cancel',
'invoice_cancel_requested',
'payment_cancel',
)),
('sat_state', 'not in', ('valid', 'cancelled', 'skip')),
],
# always show the 'Update SAT' button for imports, since originator may cancel the invoice anytime
[
('state', '=', 'invoice_received'),
('move_id.state', '=', 'posted'),
],
]
# The user still can cancel the document from the SAT portal. In that case, we need
# to display the SAT button just in case. However, we don't want to retroactively check
# all passed documents so this is happening only for the form view and not for the CRON.
if not from_cron:
results.extend([
[
('state', 'in', ('invoice_sent', 'invoice_cancel_requested', 'payment_sent')),
('move_id.l10n_mx_edi_cfdi_state', '=', 'sent'),
('sat_state', '=', 'valid'),
],
[
('state', '=', 'ginvoice_sent'),
('invoice_ids', 'any', [('l10n_mx_edi_cfdi_state', '=', 'global_sent')]),
('sat_state', '=', 'valid'),
],
])
return results
@api.model
def _get_update_sat_status_domain(self, extra_domain=None, from_cron=True):
""" Build the domain to filter the documents that need an update from the SAT.
:param extra_domain: An optional extra domain to be injected when searching for documents to update.
:param from_cron: Indicate if the call is from the CRON or not.
:return: An odoo domain.
"""
return Domain.AND([
Domain.OR(self._get_update_sat_status_domains(from_cron=from_cron)),
extra_domain or Domain.TRUE,
])
@api.model
def _fetch_and_update_sat_status(self, batch_size=100, extra_domain=None):
""" Call the SAT to know if the invoice is available government-side or if the invoice has been cancelled.
In the second case, the cancellation could be done Odoo-side and then we need to check if the SAT is up-to-date,
or could be done manually government-side forcing Odoo to update the invoice's state.
:param batch_size: The maximum size of the batch of documents to process to avoid timeout.
:param extra_domain: An optional extra domain to be injected when searching for documents to update.
"""
now = fields.Datetime.now()
domain = self._get_update_sat_status_domain(extra_domain=extra_domain)
# 1. Outgoing documents (state != 'invoice_received', checked up to 60 days, re-checked if > 4h ago)
out_domain = Domain.AND([domain, [
('state', '!=', 'invoice_received'),
('write_date', '<=', now - timedelta(hours=4)),
('create_date', '>=', now - timedelta(days=60)),
]])
documents = self.search(out_domain, limit=batch_size + 1, order='create_date, id')
# 2. Vendor bills (state == 'invoice_received', checked up to 7 days, re-checked if > 12 hours ago)
if len(documents) <= batch_size: # we want to enter even if we are exactly at the batch size to check if we need to retrigger
in_domain = Domain.AND([domain, [
('state', '=', 'invoice_received'),
('write_date', '<=', now - timedelta(hours=12)),
('create_date', '>=', now - timedelta(days=7)),
]])
remaining_limit = batch_size + 1 - len(documents)
vendor_docs = self.search(in_domain, limit=remaining_limit, order='create_date, id')
documents |= vendor_docs
need_retrigger = len(documents) > batch_size
for document in documents[:batch_size]:
document._update_sat_state()
if need_retrigger:
self.env.ref('l10n_mx_edi.ir_cron_update_pac_status_invoice')._trigger()