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

2408 lines
105 KiB
Python

# -*- coding: utf-8 -*-
import base64
import json
import random
import re
import requests
import string
from collections import defaultdict
from datetime import datetime
from json.decoder import JSONDecodeError
from lxml import etree
from odoo.tools.zeep import Client
from odoo import _, api, models, modules, fields, tools
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import frozendict
from odoo.tools.float_utils import float_is_zero, float_round
CFDI_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S'
CANCELLATION_REASON_SELECTION = [
('01', "01 - Invoice issued with errors (with related document)"),
('02', "02 - Invoice 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 invoice 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 L10nMxEdiDocument(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', auto_join=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')
# -------------------------------------------------------------------------
# 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:
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,
# pylint: disable=unnecessary-lambda
lambda x: x.move_id._l10n_mx_edi_cfdi_invoice_try_cancel_payment(x),
),
}
@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': (
None,
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_show_button_needed(self):
""" Compute whatever or not the 'show' button should be displayed. """
for doc in self:
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 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': 'tree,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_force_payment_cfdi(self):
""" Force the CFDI for the PUE payment document."""
self.ensure_one()
self.move_id.l10n_mx_edi_cfdi_payment_force_try_send()
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},
}
# -------------------------------------------------------------------------
# CFDI: HELPERS
# -------------------------------------------------------------------------
@api.model
def _get_invoice_cfdi_template(self):
""" Hook to be overridden in case the CFDI version changes.
:return: a tuple (<qweb_template>, <xsd_attachment_name>)
"""
return 'l10n_mx_edi.cfdiv40', 'cfdv40.xsd'
@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 = company.sudo().l10n_mx_edi_certificate_ids._get_valid_certificate()
if not certificate and company != root_company:
certificate = root_company.l10n_mx_edi_certificate_ids._get_valid_certificate()
if not certificate:
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,
'no_certificado': certificate.serial_number,
'certificado': certificate._get_data()[0].decode('utf-8'),
'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
return '%.*f' % (precision, amount if not float_is_zero(amount, precision_digits=precision) else 0.0)
if cfdi_values['company'].tax_calculation_rounding_method == 'round_per_line':
line_base_importe_dp = currency_precision
else:
# In case of round_globally, we need to round the tax amounts for each line with an higher
# number of decimals to avoid rounding issues.
# Indeed, the total per invoice per tax must be equal to the sum of the reported tax amounts for
# each line.
line_base_importe_dp = 6
cfdi_values.update({
'format_float': format_float,
'currency': currency,
'currency_precision': currency_precision,
'line_base_importe_dp': line_base_importe_dp,
'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'.
:param cfdi_values: The current CFDI values.
:param document_origin: The origin of the document.
"""
origin_type = None
origin_uuids = []
splitted = (document_origin or '').split('|')
if len(splitted) == 2:
try:
code = int(splitted[0])
if 1 <= code <= 7:
origin_type = splitted[0]
origin_uuids = [uuid.strip() for uuid in splitted[1].split(',') if uuid]
except ValueError:
pass
cfdi_values['tipo_relacion'] = origin_type
cfdi_values['cfdi_relationado_list'] = origin_uuids
@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
if cfdi_values.get('tipo_de_comprobante') == 'E' and cfdi_values.get('tipo_relacion') in ('01', '03'):
# Force uso_cfdi to G02 since it's a refund of a global invoice.
origin_uuids = cfdi_values['cfdi_relationado_list']
is_refund_gi = bool(self.search([('attachment_uuid', 'in', 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',
}
if customer_as_publico_en_general:
customer_values.update({
'rfc': 'XAXX010101000',
'nombre': "PUBLICO EN GENERAL",
'uso_cfdi': 'G02' if is_refund_gi else 'S01',
})
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': 'S01',
})
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_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.
(see '_convert_to_tax_base_line_dict' in account.tax).
"""
customer = cfdi_values['receptor']['customer']
if customer.l10n_mx_edi_no_tax_breakdown:
# Tax exempted.
tax_objected = '03'
elif all(not x['taxes'] for x in base_lines):
tax_objected = '01'
else:
tax_objected = '02'
cfdi_values['objeto_imp'] = tax_objected
@api.model
def _get_taxes_cfdi_values(self, base_lines, filter_tax_values=None, cfdi_values=None):
""" Compute the taxes for the CFDI document based on the lines passed as parameter.
:param base_lines: A list of dictionaries representing the lines of the document.
(see '_convert_to_tax_base_line_dict' in account.tax).
:param filter_tax_values: See '_aggregate_taxes' in account.tax.
:param cfdi_values: The current CFDI values.
:return The results of the '_aggregate_taxes' method in account.tax.
"""
def grouping_key_generator(_base_line, tax_values):
tax_rep = tax_values['tax_repartition_line']
tax = tax_rep.tax_id
return {
'tipo_factor': tax.l10n_mx_factor_type,
'impuesto': TAX_TYPE_TO_CFDI_CODE.get(tax.l10n_mx_tax_type),
'tax_amount_field': tax.amount,
}
company = cfdi_values.get('company')
distribute_total_on_line = not company or company.tax_calculation_rounding_method != 'round_globally'
taxes_values_to_aggregate = []
for base_line in base_lines:
# Don't consider fully discounted lines for taxes computation.
if base_line['discount'] == 100.0:
continue
to_update_vals, tax_values_list = self.env['account.tax']._compute_taxes_for_single_line(base_line)
taxes_values_to_aggregate.append((base_line, to_update_vals, tax_values_list))
return self.env['account.tax']._aggregate_taxes(
taxes_values_to_aggregate,
filter_tax_values_to_apply=filter_tax_values,
grouping_key_generator=grouping_key_generator,
distribute_total_on_line=distribute_total_on_line,
)
@api.model
def _is_cfdi_negative_lines_allowed(self):
""" Negative lines are not allowed by the Mexican government making some features unavailable like sale_coupon
or global discounts. This method allows odoo to distribute the negative discount lines to each others making
such features available even for Mexican people.
EDIT: Since the introduction of the global invoice, we need to manage pos order refund properly so everyone
needs this feature now.
:return: True if odoo needs to distribute the negative discount lines, False otherwise.
"""
return True
@api.model
def _dispatch_cfdi_base_lines(self, base_lines):
""" Process the base lines passed as parameter and try to distribute the negative ones across the
others since negative lines are not allowed in the CFDI.
:param base_lines: A list of dictionaries representing the base lines.
:return: A dictionary containing:
* cfdi_lines: A list of dictionaries representing the remaining base lines for the CFDI
after the distribution of the negative lines.
* orphan_negative_lines: A list of remaining negative lines that failed to be distributed.
"""
def _dispatch_tax_amounts(**values):
def get_tax_key(tax_values):
return frozendict({k: v for k, v in tax_values.items() if k not in ('base', 'importe')})
neg_base_line = values.get('neg_base_line')
is_zero = values.get('is_zero')
discount_to_distribute = values.get('discount_to_distribute')
candidate = values.get('candidate')
for key in ('transferred_values_list', 'withholding_values_list'):
for tax_values in neg_base_line[key]:
if is_zero:
base = tax_values['base']
tax = tax_values['importe']
else:
distribute_ratio = abs(discount_to_distribute / tax_values['base'])
base = neg_base_line['currency'].round(tax_values['base'] * distribute_ratio)
tax = neg_base_line['currency'].round(tax_values['importe'] * distribute_ratio)
tax_key = get_tax_key(tax_values)
other_tax_values = next(x for x in candidate[key] if get_tax_key(x) == tax_key)
other_tax_values['base'] += base
other_tax_values['importe'] += tax
tax_values['base'] -= base
tax_values['importe'] -= tax
def same_document_first(candidate, negative_line):
return negative_line.get('document_id') != candidate.get('document_id')
def prior_records_first(candidate, negative_line):
return candidate.get('record_id') not in negative_line.get('prior_record_ids', [])
sorting_criteria = [same_document_first, prior_records_first] + self.env['account.tax']._get_negative_lines_sorting_candidate_criteria()
results = self.env['account.tax']._dispatch_negative_lines(base_lines, sorting_criteria=sorting_criteria, additional_dispatching_method=_dispatch_tax_amounts)
for line in results.get('result_lines', []):
# discount_amount_before_dispatching is not needed as is, but needs to be updated in case of chains of dispatching
line['discount'] = line['discount_amount_before_dispatching'] = line['discount_amount']
return results
@api.model
def _preprocess_cfdi_base_lines(self, currency, base_lines, tax_details_transferred, tax_details_withholding):
""" Decode the current invoice lines into dictionaries and try to distribute the negative ones across the
others since negative lines are not allowed in the CFDI.
:param currency: The currency of the document.
:param base_lines: A list of dictionaries representing the base lines.
:param tax_details_transferred: The computed taxes results for transferred taxes.
:param tax_details_withholding: The computed taxes results for withholding taxes.
:return: A list of dictionaries representing the invoice lines values to consider for the CFDI.
"""
# TO BE REMOVED IN MASTER
# Mimic '_add_base_lines_taxes_amounts'
for base_line in base_lines:
base_line['tax_details_transferred'] = list(tax_details_transferred['tax_details_per_record'][base_line['record']]['tax_details'].values())
base_line['tax_details_withholding'] = list(tax_details_withholding['tax_details_per_record'][base_line['record']]['tax_details'].values())
return self._dispatch_cfdi_base_lines(base_lines)['cfdi_lines']
@api.model
def _add_base_lines_tax_amounts(self, base_lines, cfdi_values=None):
""" Add the taxes to each base line.
:param base_lines: A list of dictionaries representing the lines of the document.
(see '_convert_to_tax_base_line_dict' in account.tax).
:param cfdi_values: The current CFDI values.
"""
tax_details_transferred = self._get_taxes_cfdi_values(
base_lines,
filter_tax_values=lambda _base_line, tax_values: tax_values['tax_repartition_line'].tax_id.amount >= 0.0,
cfdi_values=cfdi_values,
)
tax_details_withholding = self._get_taxes_cfdi_values(
base_lines,
filter_tax_values=lambda _base_line, tax_values: tax_values['tax_repartition_line'].tax_id.amount < 0.0,
cfdi_values=cfdi_values,
)
for base_line in base_lines:
discount = base_line['discount']
currency = base_line['currency']
price_unit = base_line['price_unit']
quantity = base_line['quantity']
price_subtotal = base_line['price_subtotal']
discount_factor = 1 - discount / 100.0
gross_price_subtotal_before_discount = currency.round(price_unit * quantity)
if not currency.is_zero(gross_price_subtotal_before_discount * discount_factor - price_subtotal):
gross_price_subtotal_before_discount = currency.round(price_subtotal / discount_factor)
base_line['gross_price_subtotal'] = gross_price_subtotal_before_discount
base_line['discount_amount_before_dispatching'] = gross_price_subtotal_before_discount - price_subtotal
# Transferred Taxes.
base_line['transferred_values_list'] = []
for tax_details in list(tax_details_transferred['tax_details_per_record'][base_line['record']]['tax_details'].values()):
tax_values = {
'base': tax_details['base_amount_currency'],
'importe': tax_details['tax_amount_currency'],
'impuesto': tax_details['impuesto'],
'tipo_factor': tax_details['tipo_factor'],
}
if tax_details['tipo_factor'] == 'Tasa':
tax_values['tasa_o_cuota'] = tax_details['tax_amount_field'] / 100.0
elif tax_details['tipo_factor'] == 'Cuota':
tax_values['tasa_o_cuota'] = tax_values['importe'] / tax_values['base']
else:
tax_values['tasa_o_cuota'] = None
base_line['transferred_values_list'].append(tax_values)
# Withholding Taxes.
base_line['withholding_values_list'] = []
for tax_details in list(tax_details_withholding['tax_details_per_record'][base_line['record']]['tax_details'].values()):
tax_values = {
'base': tax_details['base_amount_currency'],
'importe': -tax_details['tax_amount_currency'],
'impuesto': tax_details['impuesto'],
'tipo_factor': tax_details['tipo_factor'],
}
if tax_details['tipo_factor'] == 'Tasa':
tax_values['tasa_o_cuota'] = -tax_details['tax_amount_field'] / 100.0
elif tax_details['tipo_factor'] == 'Cuota':
tax_values['tasa_o_cuota'] = tax_values['importe'] / tax_values['base']
else:
tax_values['tasa_o_cuota'] = None
base_line['withholding_values_list'].append(tax_values)
@api.model
def _add_base_lines_cfdi_values(self, cfdi_values, base_lines, percentage_paid=None):
""" 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.
(see '_convert_to_tax_base_line_dict' in account.tax).
:param percentage_paid: The percentage of the document lines to consider (when computing the payment CFDI).
"""
currency = cfdi_values['currency']
tax_objected = cfdi_values['objeto_imp']
# Invoice lines.
cfdi_values['conceptos_list'] = line_values_list = []
for line in base_lines:
product = line['product']
quantity = line['quantity']
uom = line['uom']
discount = line['discount']
if percentage_paid:
for list_key in ('transferred_values_list', 'withholding_values_list'):
for tax_values in line[list_key]:
tax_values['base'] = currency.round(tax_values['base'] * percentage_paid)
tax_values['importe'] = currency.round(tax_values['importe'] * percentage_paid)
# Post fix the base and tax amounts to be within allowed 0.01 rounding error
total_delta_base = 0.0
if cfdi_values['company'].tax_calculation_rounding_method == 'round_globally':
for list_key in ('transferred_values_list', 'withholding_values_list'):
for tax_values in line[list_key]:
if tax_values['importe'] and tax_values['tasa_o_cuota']:
post_amounts_map = self._get_post_fix_tax_amounts_map(
base_amount=tax_values['base'],
tax_amount=tax_values['importe'],
tax_rate=tax_values['tasa_o_cuota'],
precision_digits=cfdi_values['line_base_importe_dp'],
)
tax_values['base'] = post_amounts_map['new_base_amount']
tax_values['importe'] = post_amounts_map['new_tax_amount']
total_delta_base += post_amounts_map['delta_base_amount']
transferred_values_list = line['transferred_values_list']
withholding_values_list = line['withholding_values_list']
is_refund_gi = cfdi_values['receptor']['uso_cfdi'] == 'G02'
if is_refund_gi:
product_unspsc_code = '84111506'
uom_unspsc_code = 'ACT'
description = "Devoluciones, descuentos o bonificaciones"
else:
product_unspsc_code = product.unspsc_code_id.code
uom_unspsc_code = uom.unspsc_code_id.code
description = line['name']
cfdi_line_values = {
'line': line,
'clave_prod_serv': product_unspsc_code,
'no_identificacion': product.default_code,
'cantidad': quantity,
'clave_unidad': uom_unspsc_code,
'unidad': (uom.name or '').upper(),
'description': description,
'traslados_list': [],
'retenciones_list': [],
}
# Discount.
if currency.is_zero(discount):
discount = None
cfdi_line_values['descuento'] = discount
# Misc.
if transferred_values_list or withholding_values_list:
cfdi_line_values['objeto_imp'] = tax_objected
else:
cfdi_line_values['objeto_imp'] = '01'
cfdi_line_values['importe'] = line['gross_price_subtotal'] + total_delta_base
if cfdi_line_values['objeto_imp'] == '02':
cfdi_line_values['traslados_list'] = transferred_values_list
cfdi_line_values['retenciones_list'] = withholding_values_list
else:
cfdi_line_values['importe'] += sum(x['importe'] for x in transferred_values_list)\
- sum(x['importe'] for x in withholding_values_list)
cfdi_line_values['valor_unitario'] = cfdi_line_values['importe'] / cfdi_line_values['cantidad']
line_values_list.append(cfdi_line_values)
# Taxes.
withholding_values_map = defaultdict(lambda: {'base': 0.0, 'importe': 0.0})
withholding_reduced_values_map = defaultdict(lambda: {'base': 0.0, 'importe': 0.0})
transferred_values_map = defaultdict(lambda: {'base': 0.0, 'importe': 0.0})
for cfdi_line_values in line_values_list:
for tax_values in cfdi_line_values['retenciones_list']:
key = frozendict({'impuesto': tax_values['impuesto']})
withholding_reduced_values_map[key]['importe'] += tax_values['importe']
for result_dict, list_key in (
(withholding_values_map, 'retenciones_list'),
(transferred_values_map, 'traslados_list'),
):
for tax_values in cfdi_line_values[list_key]:
tax_key = frozendict({
'impuesto': tax_values['impuesto'],
'tipo_factor': tax_values['tipo_factor'],
'tasa_o_cuota': tax_values['tasa_o_cuota']
})
result_dict[tax_key]['base'] += tax_values['base']
result_dict[tax_key]['importe'] += tax_values['importe']
for list_key, source_dict in (
('retenciones_list', withholding_values_map),
('retenciones_reduced_list', withholding_reduced_values_map),
('traslados_list', transferred_values_map),
):
cfdi_values[list_key] = [
{
**tax_key,
'base': currency.round(tax_values['base']),
'importe': currency.round(tax_values['importe']),
}
for tax_key, tax_values in source_dict.items()
]
# Totals.
transferred_tax_amounts = [x['importe'] for x in cfdi_values['traslados_list'] if x['tipo_factor'] != 'Exento']
withholding_tax_amounts = [x['importe'] for x in cfdi_values['retenciones_list'] if x['tipo_factor'] != 'Exento']
cfdi_values['total_impuestos_trasladados'] = sum(transferred_tax_amounts)
cfdi_values['total_impuestos_retenidos'] = sum(withholding_tax_amounts)
cfdi_values['subtotal'] = sum(x['importe'] for x in line_values_list)
cfdi_values['descuento'] = sum(x['descuento'] for x in line_values_list if x['descuento'])
cfdi_values['total'] = cfdi_values['subtotal'] \
- cfdi_values['descuento'] \
+ cfdi_values['total_impuestos_trasladados'] \
- cfdi_values['total_impuestos_retenidos']
if currency.is_zero(cfdi_values['descuento']):
cfdi_values['descuento'] = None
# Cleanup attributes for Exento taxes.
for line in base_lines:
for key in ('transferred_values_list', 'withholding_values_list'):
for tax_values in line[key]:
if tax_values['tipo_factor'] == 'Exento':
tax_values['importe'] = None
for key in ('retenciones_list', 'traslados_list'):
for tax_values in cfdi_values[key]:
if tax_values['tipo_factor'] == 'Exento':
tax_values['importe'] = None
if not transferred_tax_amounts:
cfdi_values['total_impuestos_trasladados'] = None
if not withholding_tax_amounts:
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):
""" Get or create the ir.sequence to be used to get the global invoice document name.
:param company: The company owning the sequence.
:return: An ir.sequence record.
"""
code = 'l10n_mx_global_invoice_cfdi'
sequence = self.env['ir.sequence'].sudo().search([('code', '=', code), ('company_id', '=', company.id)], limit=1)
if not sequence:
sequence = self.env['ir.sequence'].sudo().create({
'name': f"Global Invoice CFDI ({company.name})",
'code': code,
'company_id': company.id,
'prefix': 'GINV/',
'implementation': 'standard',
'use_date_range': True,
'padding': 5,
})
return 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.number_next = number_next + 1
sequence.flush_recordset(fnames=['number_next'])
@api.model
def _get_global_invoice_cfdi_values(self, cfdi_values_list, date, periodicity='04', origin=None):
""" Aggregate the list of CFDI values passed as parameter into one global invoice CFDI values.
:param cfdi_values_list: A list of CFDI values.
:param 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.
:return: The CFDI values for the global invoice document.
"""
def aggregate_to_one(values):
values_set = set(values)
return next(iter(values_set)) if len(values_set) == 1 else None
def aggregate_sum_or_none(values):
amounts = [x for x in values if x is not None]
return sum(amounts) if amounts else None
def aggregate_average_or_none(values):
return sum(values) / len(values) if values else None
def add_or_none(results, tax_values, key):
""" Little helper to add an amount by taking care of keeping the None value (for example for 'importe' value).
For some taxes, we don't want to see this attribute (e.g. Exento). So the idea is to keep the original value
as None until we found a tax having a not None 'importe' amount.
:param results: The results in which we need to add the 'importe' amount.
:param tax_values: A dictionary containing the 'importe' amount of the tax.
:param key: The key to access the results.
"""
if tax_values[key] is not None:
results[key] = results[key] or 0.0
results[key] += tax_values[key]
if any(not x['receptor']['to_public'] for x in cfdi_values_list):
raise UserError(_("You can only make a global invoice for documents marked as 'to public'."))
if aggregate_to_one(x['moneda'] for x in cfdi_values_list) is None:
raise UserError(_("You can't make a global invoice for invoices having different currencies."))
root_company = cfdi_values_list[0]['root_company']
currency = cfdi_values_list[0]['currency']
# Sequence:
sequence = self._get_global_invoice_cfdi_sequence(root_company)
str_date = fields.Date.to_string(date)
folio = str(sequence.number_next)
serie, _interpolated_suffix = sequence._get_prefix_suffix(date=str_date, date_range=str_date)
# Periodicity.
document_date = max(datetime.strptime(x['fecha'], CFDI_DATE_FORMAT).date() for x in cfdi_values_list)
month = document_date.month
if periodicity == '05':
periodicity_month = int(12 + ((month + (month % 2)) / 2))
else:
periodicity_month = month
results = {
'root_company': root_company,
'company': cfdi_values_list[0]['company'],
'certificate': cfdi_values_list[0]['certificate'],
'sequence': sequence,
'format_string': cfdi_values_list[0]['format_string'],
'format_float': cfdi_values_list[0]['format_float'],
'line_base_importe_dp': cfdi_values_list[0]['line_base_importe_dp'],
'currency_precision': cfdi_values_list[0]['currency_precision'],
'no_certificado': cfdi_values_list[0]['no_certificado'],
'certificado': cfdi_values_list[0]['certificado'],
'folio': folio,
'serie': serie,
'tipo_relacion': None,
'cfdi_relationado_list': [],
'information_global': {
'periodicidad': periodicity,
'meses': str(periodicity_month).rjust(2, '0'),
'ano': str(max(int(x['fecha'][:4]) for x in cfdi_values_list)),
},
'emisor': cfdi_values_list[0]['emisor'],
'issued_address': cfdi_values_list[0]['issued_address'],
'fecha': date.strftime(CFDI_DATE_FORMAT),
'metodo_pago': 'PUE',
'forma_pago': max(
[(x['total'], x['forma_pago']) for x in cfdi_values_list],
key=lambda x: x[0],
)[1],
'condiciones_de_pago': None,
'moneda': cfdi_values_list[0]['moneda'],
'tipo_cambio': aggregate_average_or_none([x['tipo_cambio'] for x in cfdi_values_list if x['tipo_cambio']]),
'tipo_de_comprobante': 'I',
'exportacion': aggregate_to_one(x['exportacion'] for x in cfdi_values_list),
'total_impuestos_trasladados': 0.0,
'total_impuestos_retenidos': 0.0,
'subtotal': sum(x['subtotal'] - (x['descuento'] or 0.0) for x in cfdi_values_list),
'descuento': None,
}
# Customer needs to be "Publico En General.
self._add_customer_cfdi_values(results, to_public=True)
# Origin.
if origin:
self._add_document_origin_cfdi_values(results, origin)
# Lines.
# Aggregated lines by pair <source document, taxes> and remove the discounts.
global_withholding_reduced_values_map = defaultdict(lambda: {'base': 0.0, 'importe': None})
global_transferred_values_map = defaultdict(lambda: {'base': 0.0, 'importe': None})
results['conceptos_list'] = line_values_list = []
for cfdi_values in cfdi_values_list:
# The default values for the lines to be aggregated.
lines_values_map = defaultdict(lambda: {
'clave_prod_serv': '01010101',
'cantidad': 1,
'clave_unidad': "ACT",
'unidad': None,
'description': "Venta",
'descuento': None,
'importe': 0.0,
'traslados_list': defaultdict(lambda: {'base': 0.0, 'importe': None}),
'retenciones_list': defaultdict(lambda: {'base': 0.0, 'importe': None}),
})
# Taxes.
for line_values in cfdi_values['conceptos_list']:
transferred_values_map = defaultdict(lambda: {'base': 0.0, 'importe': None})
withholding_values_map = defaultdict(lambda: {'base': 0.0, 'importe': None})
# Split the tax amounts and keep them somewhere in order to aggregate them if necessary later.
for tax_values in line_values['retenciones_list']:
tax_key = frozendict({'impuesto': tax_values['impuesto']})
add_or_none(global_withholding_reduced_values_map[tax_key], tax_values, 'importe')
for result_dict, global_result_dict, list_key in (
(withholding_values_map, None, 'retenciones_list'),
(transferred_values_map, global_transferred_values_map, 'traslados_list'),
):
for tax_values in line_values[list_key]:
tax_key = frozendict({
'impuesto': tax_values['impuesto'],
'tipo_factor': tax_values['tipo_factor'],
'tasa_o_cuota': tax_values['tasa_o_cuota']
})
result_dict[tax_key]['base'] += tax_values['base']
add_or_none(result_dict[tax_key], tax_values, 'importe')
if global_result_dict is not None:
global_result_dict[tax_key]['base'] += tax_values['base']
add_or_none(global_result_dict[tax_key], tax_values, 'importe')
# Build the grouping key for taxes.
# This key decide if two lines belonging to the same document could be aggregated together regarding
# the amounts or not.
key = frozendict({
'traslados_list': frozenset(transferred_values_map.keys()),
'retenciones_list': frozenset(withholding_values_map.keys()),
})
aggregated_values = lines_values_map[key]
# Aggregate Taxes.
for tax_result_dict, list_key in (
(withholding_values_map, 'retenciones_list'),
(transferred_values_map, 'traslados_list'),
):
for tax_key, tax_amounts in tax_result_dict.items():
for amount_key in tax_amounts:
add_or_none(aggregated_values[list_key][tax_key], tax_amounts, amount_key)
# Aggregate others fields.
aggregated_values['importe'] += (line_values['importe'] or 0.0) - (line_values['descuento'] or 0.0)
# Append lines.
for line_values, aggregated_values in lines_values_map.items():
cfdi_line_values = {
**line_values,
**aggregated_values,
'no_identificacion': cfdi_values['document_name'],
}
for list_key in ('traslados_list', 'retenciones_list'):
cfdi_line_values[list_key] = []
for tax_key, tax_amounts in aggregated_values[list_key].items():
tax_values = {**tax_key, **tax_amounts}
if tax_values['importe'] and tax_values['tasa_o_cuota']:
post_amounts_map = self._get_post_fix_tax_amounts_map(
base_amount=tax_values['base'],
tax_amount=tax_values['importe'],
tax_rate=tax_values['tasa_o_cuota'],
precision_digits=results['line_base_importe_dp'],
)
tax_values['base'] = post_amounts_map['new_base_amount']
tax_values['importe'] = post_amounts_map['new_tax_amount']
cfdi_line_values['importe'] += post_amounts_map['delta_base_amount']
cfdi_line_values[list_key].append(tax_values)
if cfdi_line_values['traslados_list'] or cfdi_line_values['retenciones_list']:
cfdi_line_values['objeto_imp'] = '02'
else:
cfdi_line_values['objeto_imp'] = '01'
cfdi_line_values['valor_unitario'] = cfdi_line_values['importe'] / cfdi_line_values['cantidad']
# 'valor_unitario' must be different to zero.
if not cfdi_line_values['valor_unitario']:
continue
line_values_list.append(cfdi_line_values)
# Taxes.
for total_key, key, global_result_dict in (
('total_impuestos_retenidos', 'retenciones_reduced_list', global_withholding_reduced_values_map),
('total_impuestos_trasladados', 'traslados_list', global_transferred_values_map),
):
results[key] = []
for tax_key, tax_amounts in global_result_dict.items():
tax_values = {**tax_key, **tax_amounts}
if tax_values['importe']:
if tax_values.get('tasa_o_cuota'):
post_amounts_map = self._get_post_fix_tax_amounts_map(
base_amount=tax_values['base'],
tax_amount=tax_values['importe'],
tax_rate=tax_values['tasa_o_cuota'],
precision_digits=currency.decimal_places,
)
tax_values['base'] = post_amounts_map['new_base_amount']
tax_values['importe'] = post_amounts_map['new_tax_amount']
results['subtotal'] += post_amounts_map['delta_base_amount']
results[total_key] += tax_values['importe']
results[key].append(tax_values)
results['objeto_imp'] = '02' if results['retenciones_reduced_list'] or results['traslados_list'] else '03'
results['total'] = results['subtotal'] + results['total_impuestos_trasladados'] - results['total_impuestos_retenidos']
# Cleanup attributes for Exento taxes.
if all(x['total_impuestos_trasladados'] is None for x in cfdi_values_list):
results['total_impuestos_trasladados'] = None
if all(x['total_impuestos_retenidos'] is None for x in cfdi_values_list):
results['total_impuestos_retenidos'] = None
return results
# -------------------------------------------------------------------------
# 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
'''
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:
if 'CodigoError' in response.Incidencias.Incidencia[0]:
code = response.Incidencias.Incidencia[0].CodigoError
else:
code = None
if 'MensajeIncidencia' in response.Incidencias.Incidencia[0]:
msg = response.Incidencias.Incidencia[0].MensajeIncidencia
else:
msg = None
errors = []
if code:
errors.append(_("Code : %s", code))
if msg:
errors.append(_("Message : %s", msg))
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 = cfdi_values['certificate']
cer_pem = certificate._get_pem_cer(certificate.content)
key_pem = certificate._get_pem_key(certificate.key, certificate.password)
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 = certificate._get_pem_cer(certificate.content)
key_pem = certificate._get_pem_key(certificate.key, certificate.password)
key_password = certificate.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,
)
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 = cfdi_values['certificate']
headers = {
'Authorization': "bearer " + credentials['token'],
'Content-Type': "application/json"
}
payload_dict = {
'rfc': company.vat,
'b64Cer': certificate.content.decode('UTF-8'),
'b64Key': certificate.key.decode('UTF-8'),
'password': certificate.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 {}
# -------------------------------------------------------------------------
# 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'].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,
})
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
cadena_root = etree.parse(tools.file_open(template))
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']")
origin_node = get_node(cfdi_node, "//*[local-name()='CfdiRelacionados']")
origin_nodes = cfdi_node.xpath("//*[local-name()='CfdiRelacionado']")
except etree.XMLSyntaxError:
# Not an xml
return {}
except AttributeError:
# Not a CFDI
return {}
tfd_node = get_node(cfdi_node, "//*[local-name()='TimbreFiscalDigital']")
origin_type = get_value(origin_node, 'TipoRelacion')
origin_uuids = [origin_uuid for node in origin_nodes if (origin_uuid := get_value(node, 'UUID'))]
if origin_type and origin_uuids:
origin_uuids_str = ','.join(origin_uuids)
origin = f'{origin_type}|{origin_uuids_str}'
else:
origin = None
return {
'uuid': get_value(tfd_node, 'UUID'),
'supplier_rfc': get_value(emisor_node, 'Rfc'),
'customer_rfc': get_value(receptor_node, 'Rfc'),
'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,
}
@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._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._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._cr.commit()
return
# == Generate the CFDI ==
certificate = cfdi_values['certificate']
self._clean_cfdi_values(cfdi_values)
cfdi = self.env['ir.qweb']._render(qweb_template, cfdi_values)
if 'cartaporte_30' in qweb_template:
# Due to the multiple inherits and position="replace" used in the XML templates,
# we need to manually rearrange the order of the CartaPorte node's children using lxml etree.
carta_porte_20_etree = etree.fromstring(str(cfdi))
carta_porte_element = carta_porte_20_etree.find('.//{*}CartaPorte')
if carta_porte_element is not None:
regimenes_aduanero_element = carta_porte_element.find('.//{*}RegimenesAduaneros')
if regimenes_aduanero_element is not None:
carta_porte_element.remove(regimenes_aduanero_element)
carta_porte_element.insert(0, regimenes_aduanero_element)
carta_porte_20 = etree.tostring(carta_porte_20_etree).decode()
# Since we are inheriting versions 2.0 and 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)[23]0', r'\g<1>31', carta_porte_20)
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(cfdi)
cfdi_cadena_crypted = certificate._get_encrypted_cadena(cfdi_infos['cadena'])
cfdi_infos['cfdi_node'].attrib['Sello'] = cfdi_cadena_crypted
# -- 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())
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 = getattr(self.env['l10n_mx_edi.document'], f'_get_{pac_name}_credentials')(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._cr.commit()
return
# == Check PAC ==
sign_results = getattr(self.env['l10n_mx_edi.document'], f'_{pac_name}_sign')(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._cr.commit()
return
# == Success ==
on_success(cfdi_values, cfdi_filename, sign_results['cfdi_str'], populate_return=populate_return)
if self._can_commit():
self._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._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._cr.commit()
return
# == Check credentials ==
pac_name = root_company.l10n_mx_edi_pac
credentials = getattr(self.env['l10n_mx_edi.document'], f'_get_{pac_name}_credentials')(root_company)
if credentials.get('errors'):
on_failure("\n".join(credentials['errors']))
if self._can_commit():
self._cr.commit()
return
# == Check PAC ==
substitution_doc = self._get_substitution_document()
cancel_uuid = substitution_doc.attachment_uuid
cancel_results = getattr(self.env['l10n_mx_edi.document'], f'_{pac_name}_cancel')(
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._cr.commit()
return
# == Success ==
on_success()
if self._can_commit():
self._cr.commit()
# -------------------------------------------------------------------------
# SAT
# -------------------------------------------------------------------------
def _fetch_sat_status(self, supplier_rfc, customer_rfc, total, uuid):
url = 'https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc?wsdl'
headers = {
'SOAPAction': 'http://tempuri.org/IConsultaCFDIService/Consulta',
'Content-Type': 'text/xml; charset=utf-8',
}
params = f'<![CDATA[?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}]]>'
envelope = f"""<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope
xmlns:ns0="http://tempuri.org/"
xmlns:ns1="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Header/>
<ns1:Body>
<ns0:Consulta>
<ns0:expresionImpresa>{params}</ns0:expresionImpresa>
</ns0:Consulta>
</ns1:Body>
</SOAP-ENV:Envelope>
"""
namespace = {'a': 'http://schemas.datacontract.org/2004/07/Sat.Cfdi.Negocio.ConsultaCfdi.Servicio'}
try:
soap_xml = requests.post(url, data=envelope, headers=headers, timeout=20)
response = etree.fromstring(soap_xml.text)
fetched_status = response.xpath('//a:Estado', namespaces=namespace)
fetched_state = fetched_status[0].text if fetched_status else None
# 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'):
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'):
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()
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'],
)
self._update_document_sat_state(sat_results['value'], error=sat_results.get('error'))
if self._can_commit():
self._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', '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.
"""
domain = expression.OR(self._get_update_sat_status_domains(from_cron=from_cron))
if extra_domain:
domain = expression.AND([domain, extra_domain])
return domain
@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.
"""
domain = self._get_update_sat_status_domain(extra_domain=extra_domain)
documents = self.search(domain, limit=batch_size + 1)
for counter, document in enumerate(documents):
if counter == batch_size:
self.env.ref('l10n_mx_edi.ir_cron_update_pac_status_invoice')._trigger()
else:
document._update_sat_state()