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

2638 lines
119 KiB
Python

# -*- coding: utf-8 -*-
from collections import defaultdict
from datetime import datetime
import logging
from lxml import etree
from pytz import timezone
import re
from werkzeug.urls import url_quote_plus
from odoo import api, fields, models, Command, _
from odoo.addons.l10n_mx_edi.models.l10n_mx_edi_document import (
CANCELLATION_REASON_SELECTION,
CANCELLATION_REASON_DESCRIPTION,
CFDI_CODE_TO_TAX_TYPE,
CFDI_DATE_FORMAT,
USAGE_SELECTION,
)
from odoo.exceptions import ValidationError, UserError
from odoo.tools import frozendict
from odoo.tools.float_utils import float_round
from odoo.tools.sql import column_exists, create_column
from odoo.addons.base.models.ir_qweb import keep_query
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
# ==== CFDI flow fields ====
l10n_mx_edi_is_cfdi_needed = fields.Boolean(
compute='_compute_l10n_mx_edi_is_cfdi_needed',
store=True,
)
# The CFDI documents displayed on the invoice.
# This is a many2many because a payment could pay multiple invoices.
l10n_mx_edi_invoice_document_ids = fields.Many2many(
comodel_name='l10n_mx_edi.document',
relation='l10n_mx_edi_invoice_document_ids_rel',
column1='invoice_id',
column2='document_id',
copy=False,
readonly=True,
)
# The CFDI documents displayed on the payment.
l10n_mx_edi_payment_document_ids = fields.One2many(
comodel_name='l10n_mx_edi.document',
inverse_name='move_id',
copy=False,
readonly=True,
)
# The CFDI documents for the view.
l10n_mx_edi_document_ids = fields.One2many(
comodel_name='l10n_mx_edi.document',
compute='_compute_l10n_mx_edi_document_ids',
)
l10n_mx_edi_cfdi_state = fields.Selection(
string="CFDI status",
selection=[
('sent', 'Signed'),
('cancel_requested', 'Cancel Requested'),
('cancel', 'Cancelled'),
('received', 'Received'),
('global_sent', 'Signed Global'),
('global_cancel', 'Cancelled Global'),
],
store=True,
copy=False,
tracking=True,
compute="_compute_l10n_mx_edi_cfdi_state_and_attachment",
)
l10n_mx_edi_cfdi_sat_state = fields.Selection(
string="SAT status",
selection=[
('valid', "Validated"),
('cancelled', "Cancelled"),
('not_found', "Not Found"),
('not_defined', "Not Defined"),
('error', "Error"),
],
store=True,
copy=False,
tracking=True,
compute="_compute_l10n_mx_edi_cfdi_state_and_attachment",
)
l10n_mx_edi_cfdi_attachment_id = fields.Many2one(
comodel_name='ir.attachment',
string="CFDI",
store=True,
copy=False,
index=True,
compute='_compute_l10n_mx_edi_cfdi_state_and_attachment',
)
# Technical field indicating if the "Update Payments" button needs to be displayed on invoice view.
l10n_mx_edi_update_payments_needed = fields.Boolean(compute='_compute_l10n_mx_edi_update_payments_needed')
# Technical field indicating if the "Force PUE" button needs to be displayed on payment view.
l10n_mx_edi_force_pue_payment_needed = fields.Boolean(compute='_compute_l10n_mx_edi_force_pue_payment_needed')
# Technical field indicating if the "Update SAT" button needs to be displayed on invoice/payment view.
l10n_mx_edi_update_sat_needed = fields.Boolean(compute='_compute_l10n_mx_edi_update_sat_needed')
l10n_mx_edi_post_time = fields.Datetime(
string="Posted Time",
readonly=True,
copy=False,
help="Keep empty to use the current México central time",
)
l10n_mx_edi_usage = fields.Selection(
selection=USAGE_SELECTION,
string="Usage",
readonly=False,
store=True,
compute='_compute_l10n_mx_edi_usage',
tracking=True,
help="Used in CFDI to express the key to the usage that will gives the receiver to this invoice. This "
"value is defined by the customer.\nNote: It is not cause for cancellation if the key set is not the usage "
"that will give the receiver of the document.",
)
l10n_mx_edi_cfdi_origin = fields.Char(
string="CFDI Origin",
copy=False,
help="In some cases like payments, credit notes, debit notes, invoices re-signed or invoices that are redone "
"due to payment in advance will need this field filled, the format is:\n"
"Origin Type|UUID1, UUID2, ...., UUIDn.\n"
"Where the origin type could be:\n"
"- 01: Nota de crédito\n"
"- 02: Nota de débito de los documentos relacionados\n"
"- 03: Devolución de mercancía sobre facturas o traslados previos\n"
"- 04: Sustitución de los CFDI previos\n"
"- 05: Traslados de mercancias facturados previamente\n"
"- 06: Factura generada por los traslados previos\n"
"- 07: CFDI por aplicación de anticipo",
)
# When cancelling an invoice, the user needs to provide a valid reason to do so to the SAT.
l10n_mx_edi_invoice_cancellation_reason = fields.Selection(
selection=CANCELLATION_REASON_SELECTION,
string="Cancellation Reason",
compute='_compute_l10n_mx_edi_cfdi_state_and_attachment',
store=True,
help=CANCELLATION_REASON_DESCRIPTION,
)
# Indicate the journal entry substituting the current cancelled one.
# In other words, this is the reason why the current journal entry is cancelled.
l10n_mx_edi_cfdi_cancel_id = fields.Many2one(
comodel_name='account.move',
string="Substituted By",
compute='_compute_l10n_mx_edi_cfdi_cancel_id',
index='btree_not_null',
)
# ==== CFDI certificate fields ====
l10n_mx_edi_certificate_id = fields.Many2one(
comodel_name='l10n_mx_edi.certificate',
string="Source Certificate")
l10n_mx_edi_cer_source = fields.Char(
string='Certificate Source',
help="Used in CFDI like attribute derived from the exception of certificates of Origin of the "
"Free Trade Agreements that Mexico has celebrated with several countries. If it has a value, it will "
"indicate that it serves as certificate of origin and this value will be set in the CFDI node "
"'NumCertificadoOrigen'.")
# ==== CFDI attachment fields ====
l10n_mx_edi_cfdi_uuid = fields.Char(
string="Fiscal Folio",
compute='_compute_l10n_mx_edi_cfdi_uuid',
copy=False,
store=True,
tracking=True,
index='btree_not_null',
help="Folio in electronic invoice, is returned by SAT when send to stamp.",
)
l10n_mx_edi_cfdi_supplier_rfc = fields.Char(
string="Supplier RFC",
compute='_compute_cfdi_values',
help="The supplier tax identification number.",
)
l10n_mx_edi_cfdi_customer_rfc = fields.Char(
string="Customer RFC",
compute='_compute_cfdi_values',
help="The customer tax identification number.",
)
l10n_mx_edi_cfdi_amount = fields.Monetary(
string="Total Amount",
compute='_compute_cfdi_values',
help="The total amount reported on the cfdi.",
)
# ==== Other fields ====
l10n_mx_edi_payment_method_id = fields.Many2one(
comodel_name='l10n_mx_edi.payment.method',
string="Payment Way",
compute='_compute_l10n_mx_edi_payment_method_id',
store=True,
readonly=False,
help="Indicates the way the invoice was/will be paid, where the options could be: "
"Cash, Nominal Check, Credit Card, etc. Leave empty if unkown and the XML will show 'Unidentified'.",
)
# Indicate what kind of payment is expected to pay the current invoice.
# PUE is for a quick payment close to the invoice date paying completely the invoice.
# In that case, by default, you don't need to sent the payment to the SAT.
# PPD means you have either a delay, either multiple partial payments to do.
# In that case, the payment(s) must be sent to the SAT.
l10n_mx_edi_payment_policy = fields.Selection(
string="Payment Policy",
selection=[('PPD', 'PPD'), ('PUE', 'PUE')],
compute='_compute_l10n_mx_edi_payment_policy',
)
# Indicate if you send the invoice to the SAT using 'Publico En General' meaning
# the customer is unknown by the SAT. This is mainly used when the customer doesn't have
# a VAT number registered to the SAT.
l10n_mx_edi_cfdi_to_public = fields.Boolean(
string="CFDI to public",
compute='_compute_l10n_mx_edi_cfdi_to_public',
store=True,
readonly=False,
help="Send the CFDI with recipient 'publico en general'",
)
def _auto_init(self):
"""
Create compute stored field l10n_mx_edi_cfdi_request
here to avoid MemoryError on large databases.
"""
if not column_exists(self.env.cr, 'account_move', 'l10n_mx_edi_payment_method_id'):
create_column(self.env.cr, 'account_move', 'l10n_mx_edi_payment_method_id', 'integer')
return super()._auto_init()
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
def _l10n_mx_edi_is_cfdi_payment(self):
""" Helper to know if the current account.move is a payment or not.
:return: True if the account.move is a payment, False otherwise.
"""
self.ensure_one()
return self.payment_id or self.statement_line_id
def _l10n_mx_edi_cfdi_invoice_append_addenda(self, cfdi, addenda):
''' Append an additional block to the signed CFDI passed as parameter.
:param move: The account.move record.
:param cfdi: The invoice's CFDI as a string.
:param addenda: (ir.ui.view) The addenda to add as a string.
:return cfdi: The cfdi including the addenda.
'''
self.ensure_one()
addenda_values = {'record': self, 'cfdi': cfdi}
addenda = self.env['ir.qweb']._render(addenda.id, values=addenda_values).strip()
if not addenda:
return cfdi
cfdi_node = etree.fromstring(cfdi)
addenda_node = etree.fromstring(addenda)
version = cfdi_node.get('Version')
# Add a root node Addenda if not specified explicitly by the user.
if addenda_node.tag != '{http://www.sat.gob.mx/cfd/%s}Addenda' % version[0]:
node = etree.Element(etree.QName('http://www.sat.gob.mx/cfd/%s' % version[0], 'Addenda'))
node.append(addenda_node)
addenda_node = node
cfdi_node.append(addenda_node)
return etree.tostring(cfdi_node, pretty_print=True, xml_declaration=True, encoding='UTF-8')
def _l10n_mx_edi_cfdi_amount_to_text(self):
"""Method to transform a float amount to text words
E.g. 100 - ONE HUNDRED
:returns: Amount transformed to words mexican format for invoices
:rtype: str
"""
self.ensure_one()
currency_name = self.currency_id.name.upper()
# M.N. = Moneda Nacional (National Currency)
# M.E. = Moneda Extranjera (Foreign Currency)
currency_type = 'M.N' if currency_name == 'MXN' else 'M.E.'
# Split integer and decimal part
amount_i, amount_d = divmod(self.amount_total, 1)
amount_d = round(amount_d, 2)
amount_d = int(round(amount_d * 100, 2))
words = self.currency_id.with_context(lang=self.partner_id.lang or 'es_ES').amount_to_text(amount_i).upper()
return '%(words)s %(amount_d)02d/100 %(currency_type)s' % {
'words': words,
'amount_d': amount_d,
'currency_type': currency_type,
}
def _l10n_mx_edi_check_invoices_for_global_invoice(self, origin=None):
""" Ensure the current records are eligible for the creation of a global invoice.
:param origin: The origin of the GI when cancelling an existing one.
"""
failed_invoices = self.filtered(lambda x: x.state != 'posted')
if failed_invoices:
invoices_str = ", ".join(failed_invoices.mapped('name'))
raise UserError(_("Invoices %s are not posted.", invoices_str))
if len(self.company_id) != 1 or len(self.journal_id) != 1:
raise UserError(_("You can only process invoices sharing the same company and journal."))
refunds = self.reversal_move_id
invoices = self | refunds
failed_invoices = invoices.filtered(lambda x: (
(
not origin
and (
not x.l10n_mx_edi_is_cfdi_needed
or x.l10n_mx_edi_cfdi_state in ('sent', 'global_sent')
)
)
or (x.move_type == 'out_refund' and x.reversed_entry_id not in self)
))
if failed_invoices:
invoices_str = ", ".join(failed_invoices.mapped('name'))
raise UserError(_("Invoices %s are already sent or not eligible for CFDI.", invoices_str))
return invoices
@api.model
def _l10n_mx_edi_write_cfdi_origin(self, code, uuids):
''' Format the code and uuids passed as parameter in order to fill the l10n_mx_edi_cfdi_origin field.
The code corresponds to the following types:
- 01: Nota de crédito
- 02: Nota de débito de los documentos relacionados
- 03: Devolución de mercancía sobre facturas o traslados previos
- 04: Sustitución de los CFDI previos
- 05: Traslados de mercancias facturados previamente
- 06: Factura generada por los traslados previos
- 07: CFDI por aplicación de anticipo
The generated string must match the following template:
<code>|<uuid1>,<uuid2>,...,<uuidn>
:param code: A valid code as a string between 01 and 07.
:param uuids: A list of uuids returned by the government.
:return: A valid string to be put inside the l10n_mx_edi_cfdi_origin field.
'''
return '%s|%s' % (code, ','.join(uuids))
def _l10n_mx_edi_get_extra_common_report_values(self):
self.ensure_one()
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(self.l10n_mx_edi_cfdi_attachment_id.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,
}
def _l10n_mx_edi_get_extra_invoice_report_values(self):
""" Collect extra values used to render the invoice PDF report containing CFDI information.
:return: A python dictionary.
"""
self.ensure_one()
cfdi_infos = self._l10n_mx_edi_get_extra_common_report_values()
if not cfdi_infos:
return cfdi_infos
payment_way = cfdi_infos['cfdi_node'].attrib.get('FormaPago')
if payment_way:
payment_method = self.env['l10n_mx_edi.payment.method'].search([('code', '=', payment_way)])
cfdi_infos['payment_way'] = f'{payment_way} - {payment_method.name}'
cfdi_infos['usage_desc'] = dict(self._fields['l10n_mx_edi_usage']._description_selection(self.env)).get(cfdi_infos['usage'])
return cfdi_infos
def _l10n_mx_edi_get_extra_payment_report_values(self):
""" Collect extra values used to render the payment PDF report containing CFDI information.
:return: A python dictionary.
"""
self.ensure_one()
cfdi_infos = self._l10n_mx_edi_get_extra_common_report_values()
if not cfdi_infos:
return cfdi_infos
node = cfdi_infos['cfdi_node'].xpath("//*[local-name()='Pago']")[0]
payment_info = cfdi_infos['payment_info'] = {}
payment_info['from_account_vat'] = node.get('RfcEmisorCtaOrd')
payment_info['from_account_name'] = node.get('NomBancoOrdExt')
payment_info['from_account_number'] = node.get('CtaOrdenante')
payment_info['to_account_vat'] = node.get('RfcEmisorCtaBen')
payment_info['to_account_number'] = node.get('CtaBeneficiario')
related_invoices = cfdi_infos['invoices'] = []
uuids = []
for node in cfdi_infos['cfdi_node'].xpath("//*[local-name()='DoctoRelacionado']"):
uuids.append(node.attrib['IdDocumento'])
related_invoices.append({
'uuid': node.attrib['IdDocumento'],
'partiality': node.attrib['NumParcialidad'],
'previous_balance': float(node.attrib['ImpSaldoAnt']),
'amount_paid': float(node.attrib['ImpPagado']),
'balance': float(node.attrib['ImpSaldoInsoluto']),
'currency': node.attrib['MonedaDR'],
})
invoices = self.env['account.move'].search([('l10n_mx_edi_cfdi_uuid', 'in', uuids)])
invoices_map = {x.l10n_mx_edi_cfdi_uuid: x for x in invoices}
for invoice_values in related_invoices:
invoice_values['invoice'] = invoices_map.get(invoice_values['uuid'], self.env['account.move'])
return cfdi_infos
def _l10n_mx_edi_get_refund_original_invoices(self):
""" Get the related invoices for the current refunds.
:return: The refunded invoices.
"""
origin_uuids = set()
for move in self.filtered(lambda x: x.move_type == 'out_refund'):
cfdi_values = {}
self.env['l10n_mx_edi.document']._add_document_origin_cfdi_values(cfdi_values, move.l10n_mx_edi_cfdi_origin)
if cfdi_values['tipo_relacion'] in ('01', '03'):
for uuid in cfdi_values['cfdi_relationado_list']:
origin_uuids.add(uuid)
if origin_uuids:
return self.env['account.move'].search([('l10n_mx_edi_cfdi_uuid', 'in', list(origin_uuids))])
return self.env['account.move']
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('l10n_mx_edi_cfdi_state', 'l10n_mx_edi_cfdi_cancel_id')
def _compute_need_cancel_request(self):
# EXTENDS 'account'
super()._compute_need_cancel_request()
@api.depends('country_code')
def _compute_amount_total_words(self):
# EXTENDS 'account'
super()._compute_amount_total_words()
for move in self:
if move.country_code == 'MX':
move.amount_total_words = move._l10n_mx_edi_cfdi_amount_to_text()
@api.depends('move_type', 'company_currency_id', 'payment_id', 'statement_line_id')
def _compute_l10n_mx_edi_is_cfdi_needed(self):
""" Check whatever or not the CFDI is needed on this invoice.
"""
for move in self:
move.l10n_mx_edi_is_cfdi_needed = \
move.country_code == 'MX' \
and move.company_currency_id.name == 'MXN' \
and (move.move_type in ('out_invoice', 'out_refund') or move._l10n_mx_edi_is_cfdi_payment())
@api.depends('l10n_mx_edi_invoice_document_ids.state', 'l10n_mx_edi_invoice_document_ids.sat_state',
'l10n_mx_edi_payment_document_ids.state', 'l10n_mx_edi_payment_document_ids.sat_state')
def _compute_l10n_mx_edi_document_ids(self):
for move in self:
if move.is_invoice():
move.l10n_mx_edi_document_ids = [Command.set(move.l10n_mx_edi_invoice_document_ids.ids)]
elif move._l10n_mx_edi_is_cfdi_payment():
move.l10n_mx_edi_document_ids = [Command.set(move.l10n_mx_edi_payment_document_ids.ids)]
else:
move.l10n_mx_edi_document_ids = [Command.clear()]
@api.depends('l10n_mx_edi_invoice_document_ids.state', 'l10n_mx_edi_invoice_document_ids.sat_state',
'l10n_mx_edi_payment_document_ids.state', 'l10n_mx_edi_payment_document_ids.sat_state')
def _compute_l10n_mx_edi_cfdi_state_and_attachment(self):
for move in self:
move.l10n_mx_edi_cfdi_sat_state = None
move.l10n_mx_edi_cfdi_state = None
move.l10n_mx_edi_cfdi_attachment_id = None
move.l10n_mx_edi_invoice_cancellation_reason = None
if move.is_invoice():
# Compute the SAT & the PAC states in 2 different loops.
# In case of a request cancellation that failed, the SAT state needs
# to be retrieved from the document corresponding to the request cancellation.
# However, the PAC state needs to be retrieved from the original 'invoice_sent'
# document.
documents = move.l10n_mx_edi_invoice_document_ids.sorted()
# 'l10n_mx_edi_cfdi_sat_state'.
for doc in documents.filtered(lambda doc: doc.state in {
'invoice_sent',
'invoice_cancel_requested',
'invoice_cancel',
'ginvoice_sent',
'ginvoice_cancel',
}):
if doc.sat_state != 'skip':
move.l10n_mx_edi_cfdi_sat_state = doc.sat_state
break
# 'l10n_mx_edi_cfdi_state' / 'l10n_mx_edi_cfdi_attachment_id'.
for doc in documents:
if doc.state == 'invoice_sent':
move.l10n_mx_edi_cfdi_state = 'sent'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
break
elif doc.state == 'invoice_received':
move.l10n_mx_edi_cfdi_state = 'received'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
break
elif doc.state == 'ginvoice_sent':
move.l10n_mx_edi_cfdi_state = 'global_sent'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
break
elif doc.state == 'invoice_cancel_requested' and doc.sat_state == 'not_defined':
move.l10n_mx_edi_cfdi_state = 'cancel_requested'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
move.l10n_mx_edi_invoice_cancellation_reason = doc.cancellation_reason
break
elif doc.state == 'invoice_cancel':
move.l10n_mx_edi_cfdi_state = 'cancel'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
move.l10n_mx_edi_invoice_cancellation_reason = doc.cancellation_reason
break
elif doc.state == 'ginvoice_cancel' and doc.cancellation_reason != '01':
move.l10n_mx_edi_cfdi_state = 'global_cancel'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
move.l10n_mx_edi_invoice_cancellation_reason = doc.cancellation_reason
break
elif move._l10n_mx_edi_is_cfdi_payment():
for doc in move.l10n_mx_edi_payment_document_ids.sorted():
if doc.state == 'payment_sent':
move.l10n_mx_edi_cfdi_sat_state = doc.sat_state
move.l10n_mx_edi_cfdi_state = 'sent'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
break
elif doc.state == 'payment_cancel':
move.l10n_mx_edi_cfdi_sat_state = doc.sat_state
move.l10n_mx_edi_cfdi_state = 'cancel'
move.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
move.l10n_mx_edi_invoice_cancellation_reason = doc.cancellation_reason
break
@api.depends('l10n_mx_edi_invoice_document_ids.state')
def _compute_l10n_mx_edi_update_payments_needed(self):
payments_diff = self._origin\
.with_context(bin_size=False)\
._l10n_mx_edi_cfdi_invoice_get_payments_diff()
for move in self:
move.l10n_mx_edi_update_payments_needed = bool(
move in payments_diff['to_remove']
or move in payments_diff['need_update']
or payments_diff['to_process']
)
@api.depends('l10n_mx_edi_payment_document_ids.state')
def _compute_l10n_mx_edi_force_pue_payment_needed(self):
for move in self:
force_pue = False
if move._l10n_mx_edi_is_cfdi_payment() and not move.l10n_mx_edi_cfdi_state:
for doc in move.l10n_mx_edi_payment_document_ids.sorted():
if doc.state == 'payment_sent_pue':
force_pue = True
break
move.l10n_mx_edi_force_pue_payment_needed = force_pue
@api.depends('state', 'l10n_mx_edi_cfdi_state', 'l10n_mx_edi_cfdi_sat_state')
def _compute_l10n_mx_edi_update_sat_needed(self):
for move in self:
if move.is_invoice():
documents = move.l10n_mx_edi_invoice_document_ids
elif move._l10n_mx_edi_is_cfdi_payment():
documents = move.l10n_mx_edi_payment_document_ids
else:
move.l10n_mx_edi_update_sat_needed = False
continue
move.l10n_mx_edi_update_sat_needed = bool(documents.filtered_domain(
documents._get_update_sat_status_domain(from_cron=False)
))
@api.depends('l10n_mx_edi_cfdi_attachment_id')
def _compute_l10n_mx_edi_cfdi_uuid(self):
'''Fill the invoice fields from the cfdi values.
'''
for move in self:
if move.l10n_mx_edi_cfdi_attachment_id:
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(move.l10n_mx_edi_cfdi_attachment_id.raw)
move.l10n_mx_edi_cfdi_uuid = cfdi_infos.get('uuid')
else:
move.l10n_mx_edi_cfdi_uuid = None
@api.depends('l10n_mx_edi_cfdi_attachment_id', 'l10n_mx_edi_cfdi_state')
def _compute_cfdi_values(self):
'''Fill the invoice fields from the cfdi values.
'''
for move in self:
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(move.l10n_mx_edi_cfdi_attachment_id.raw)
move.l10n_mx_edi_cfdi_supplier_rfc = cfdi_infos.get('supplier_rfc')
move.l10n_mx_edi_cfdi_customer_rfc = cfdi_infos.get('customer_rfc')
move.l10n_mx_edi_cfdi_amount = cfdi_infos.get('amount_total')
@api.depends('move_type', 'invoice_date_due', 'invoice_date', 'invoice_payment_term_id')
def _compute_l10n_mx_edi_payment_policy(self):
for move in self:
move.l10n_mx_edi_payment_policy = False
if move.is_invoice(include_receipts=True) \
and move.l10n_mx_edi_is_cfdi_needed \
and move.invoice_date_due \
and move.invoice_date:
# By default PUE means immediate payment and then, no need to send the payments to
# the SAT except if you explicitely send them.
move.l10n_mx_edi_payment_policy = 'PUE'
# In CFDI 3.3 - rule 2.7.1.43 which establish that
# invoice payment term should be PPD as soon as the due date
# is after the last day of the month (the month of the invoice date).
if (
move.move_type == 'out_invoice'
and (
move.invoice_date_due.month > move.invoice_date.month
or move.invoice_date_due.year > move.invoice_date.year
or len(move.invoice_payment_term_id.line_ids) > 1
)
):
move.l10n_mx_edi_payment_policy = 'PPD'
@api.depends('l10n_mx_edi_is_cfdi_needed', 'l10n_mx_edi_cfdi_origin', 'partner_id', 'company_id')
def _compute_l10n_mx_edi_cfdi_to_public(self):
for move in self:
if move.move_type == 'out_refund' and 'global_sent' in set(move._l10n_mx_edi_get_refund_original_invoices().mapped('l10n_mx_edi_cfdi_state')):
move.l10n_mx_edi_cfdi_to_public = True
elif (
not move.l10n_mx_edi_cfdi_to_public
and move.l10n_mx_edi_is_cfdi_needed
and move.partner_id
and move.company_id
):
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(move.company_id)
self.env['l10n_mx_edi.document']._add_customer_cfdi_values(
cfdi_values,
customer=move.partner_id,
)
move.l10n_mx_edi_cfdi_to_public = cfdi_values['receptor']['rfc'] == 'XAXX010101000'
else:
move.l10n_mx_edi_cfdi_to_public = move.l10n_mx_edi_cfdi_to_public
@api.depends('journal_id', 'statement_line_id', 'partner_id')
def _compute_l10n_mx_edi_payment_method_id(self):
otros_payment_method = self.env.ref('l10n_mx_edi.payment_method_otros', raise_if_not_found=False)
transferencia_payment_method = self.env.ref('l10n_mx_edi.payment_method_transferencia', raise_if_not_found=False)
for move in self:
if move.country_code != 'MX':
move.l10n_mx_edi_payment_method_id = False
continue
if move.is_invoice(include_receipts=True):
payment_method = move.partner_id.l10n_mx_edi_payment_method_id or move.l10n_mx_edi_payment_method_id
else:
payment_method = move.l10n_mx_edi_payment_method_id or move.partner_id.l10n_mx_edi_payment_method_id
move.l10n_mx_edi_payment_method_id = (
payment_method or
(move._l10n_mx_edi_is_cfdi_payment() and transferencia_payment_method) or
move.journal_id.l10n_mx_edi_payment_method_id or
otros_payment_method
)
@api.depends('partner_id')
def _compute_l10n_mx_edi_usage(self):
for move in self:
if move.country_code == 'MX':
move.l10n_mx_edi_usage = (
move.partner_id.l10n_mx_edi_usage or
move.l10n_mx_edi_usage or
'G03'
)
else:
move.l10n_mx_edi_usage = False
@api.depends('l10n_mx_edi_cfdi_uuid')
def _compute_l10n_mx_edi_cfdi_cancel_id(self):
for move in self:
if move.company_id and move.l10n_mx_edi_cfdi_uuid:
move.l10n_mx_edi_cfdi_cancel_id = move.search(
[
('l10n_mx_edi_cfdi_origin', '=like', f'04|{move.l10n_mx_edi_cfdi_uuid}%'),
('company_id', '=', move.company_id.id)
],
limit=1,
)
else:
move.l10n_mx_edi_cfdi_cancel_id = None
@api.depends('l10n_mx_edi_cfdi_uuid')
def _compute_duplicated_ref_ids(self):
return super()._compute_duplicated_ref_ids()
@api.depends('l10n_mx_edi_cfdi_state', 'l10n_mx_edi_cfdi_sat_state')
def _compute_show_reset_to_draft_button(self):
# EXTENDS 'account'
# When the PAC approved the cancellation but we are awaiting the SAT confirmation,
# don't allow to reset draft the invoice.
super()._compute_show_reset_to_draft_button()
for move in self:
if (
move.show_reset_to_draft_button
and move.l10n_mx_edi_cfdi_state not in ('cancel', 'received', 'global_cancel', False)
and move.state == 'posted'
):
move.show_reset_to_draft_button = False
# -------------------------------------------------------------------------
# CONSTRAINTS
# -------------------------------------------------------------------------
@api.constrains('l10n_mx_edi_cfdi_origin')
def _check_l10n_mx_edi_cfdi_origin(self):
error_message = _(
"The following CFDI origin %s is invalid and must match the "
"<code>|<uuid1>,<uuid2>,...,<uuidn> template.\n"
"Here are the specification of this value:\n"
"- 01: Nota de crédito\n"
"- 02: Nota de débito de los documentos relacionados\n"
"- 03: Devolución de mercancía sobre facturas o traslados previos\n"
"- 04: Sustitución de los CFDI previos\n"
"- 05: Traslados de mercancias facturados previamente\n"
"- 06: Factura generada por los traslados previos\n"
"- 07: CFDI por aplicación de anticipo\n"
"For example: 01|89966ACC-0F5C-447D-AEF3-3EED22E711EE,89966ACC-0F5C-447D-AEF3-3EED22E711EE"
)
for move in self.filtered('l10n_mx_edi_cfdi_origin'):
cfdi_values = {}
self.env['l10n_mx_edi.document']._add_document_origin_cfdi_values(cfdi_values, move.l10n_mx_edi_cfdi_origin)
if not cfdi_values['tipo_relacion'] or not cfdi_values['cfdi_relationado_list']:
raise ValidationError(error_message % move.l10n_mx_edi_cfdi_origin)
# -------------------------------------------------------------------------
# BUSINESS METHODS
# -------------------------------------------------------------------------
@api.model
def fields_get(self, allfields=None, attributes=None):
# TODO: remove in master
res = super().fields_get(allfields, attributes)
existing_selection = res.get('l10n_mx_edi_cfdi_state', {}).get('selection')
if existing_selection is None:
return res
cancel_requested_state = next(x for x in self._fields['l10n_mx_edi_cfdi_state'].selection if x[0] == 'cancel_requested')
need_update = cancel_requested_state not in existing_selection
if need_update:
self.env['ir.model.fields'].invalidate_model(['selection_ids'])
self.env['ir.model.fields.selection']._update_selection(
'account.move',
'l10n_mx_edi_cfdi_state',
self._fields['l10n_mx_edi_cfdi_state'].selection,
)
self.env['ir.model.fields.selection']._update_selection(
'l10n_mx_edi.document',
'state',
self.env['l10n_mx_edi.document']._fields['state'].selection,
)
self.env.registry.clear_cache()
return res
def _post(self, soft=True):
# OVERRIDE
mexico_tz = self.env['l10n_mx_edi.certificate'].sudo()._get_timezone()
certificate_date = datetime.now(mexico_tz)
for move in self.filtered('l10n_mx_edi_is_cfdi_needed'):
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(move.company_id)
self.env['l10n_mx_edi.document']._add_customer_cfdi_values(
cfdi_values,
customer=move.partner_id,
)
move.l10n_mx_edi_post_time = fields.Datetime.to_string(move._l10n_mx_edi_get_datetime_now_with_mx_timezone(cfdi_values))
# Assign time and date coming from a certificate.
if move.is_invoice() and move.l10n_mx_edi_is_cfdi_needed and not move.invoice_date:
move.invoice_date = certificate_date.date()
return super()._post(soft=soft)
def _l10n_mx_edi_need_cancel_request(self):
self.ensure_one()
return (
self.l10n_mx_edi_cfdi_state == 'sent'
and self.l10n_mx_edi_cfdi_attachment_id
and (
not self.l10n_mx_edi_cfdi_cancel_id
or self.l10n_mx_edi_cfdi_cancel_id.l10n_mx_edi_cfdi_state
)
)
def _need_cancel_request(self):
# EXTENDS 'account'
return super()._need_cancel_request() or self._l10n_mx_edi_need_cancel_request()
def button_request_cancel(self):
# EXTENDS 'account'
super().button_request_cancel()
# Check the CFDI state to restrict this code to MX only.
if self._l10n_mx_edi_need_cancel_request():
doc = self.l10n_mx_edi_document_ids.filtered(lambda x: (
x.attachment_uuid == self.l10n_mx_edi_cfdi_uuid
and x.state in ('invoice_sent', 'payment_sent')
))[0]
return doc.action_request_cancel()
def _reverse_moves(self, default_values_list=None, cancel=False):
# OVERRIDE
# The '01' code is used to indicate the document is a credit note.
if not default_values_list:
default_values_list = [{}] * len(self)
for default_vals, move in zip(default_values_list, self):
if move.l10n_mx_edi_cfdi_uuid:
default_vals['l10n_mx_edi_cfdi_origin'] = move._l10n_mx_edi_write_cfdi_origin('01', [move.l10n_mx_edi_cfdi_uuid])
return super()._reverse_moves(default_values_list, cancel=cancel)
def _get_mail_thread_data_attachments(self):
# EXTENDS 'account'
return super()._get_mail_thread_data_attachments() \
- self.l10n_mx_edi_payment_document_ids.attachment_id \
+ self.l10n_mx_edi_cfdi_attachment_id
@api.model
def get_invoice_localisation_fields_required_to_invoice(self, country_id):
res = super().get_invoice_localisation_fields_required_to_invoice(country_id)
if country_id.code == 'MX':
res.extend([self.env['ir.model.fields']._get(self._name, 'l10n_mx_edi_usage')])
return res
def _get_name_invoice_report(self):
# EXTENDS account
self.ensure_one()
if self.l10n_mx_edi_cfdi_state == 'sent' and self.l10n_mx_edi_cfdi_attachment_id:
return 'l10n_mx_edi.report_invoice_document'
return super()._get_name_invoice_report()
def _get_edi_doc_attachments_to_export(self):
# EXTENDS 'account'
return super()._get_edi_doc_attachments_to_export() + self.l10n_mx_edi_cfdi_attachment_id
def _fetch_duplicate_supplier_reference(self, only_posted=False):
# EXTENDS account
# We check whether there are moves with the same fiscal folio if we have Mexican bills
mx_vendor_bills = self.filtered(lambda m: m.is_purchase_document() and m.l10n_mx_edi_cfdi_uuid and m._origin.id)
if not mx_vendor_bills:
return super()._fetch_duplicate_supplier_reference(only_posted=only_posted)
self.env['account.move'].flush_model(('company_id', 'move_type', 'l10n_mx_edi_cfdi_uuid'))
self.env.cr.execute(
"""
SELECT move.id AS move_id,
ARRAY_AGG(duplicate_move.id) AS duplicate_ids
FROM account_move AS move
JOIN account_move AS duplicate_move
ON move.company_id = duplicate_move.company_id
AND move.move_type = duplicate_move.move_type
AND move.id != duplicate_move.id
AND move.l10n_mx_edi_cfdi_uuid = duplicate_move.l10n_mx_edi_cfdi_uuid
WHERE move.id IN %(moves)s
GROUP BY move.id
""",
{
'moves': tuple(mx_vendor_bills.ids),
},
)
folio_fiscal_duplicates = {
self.env['account.move'].browse(res['move_id']): self.env['account.move'].browse(res['duplicate_ids'])
for res in self.env.cr.dictfetchall()
}
move_duplicates = super()._fetch_duplicate_supplier_reference(only_posted=only_posted)
for move, duplicates in folio_fiscal_duplicates.items():
move_duplicates[move] = move_duplicates.get(move, self.env['account.move']) | duplicates
return move_duplicates
# -------------------------------------------------------------------------
# CFDI Generation: Generic
# -------------------------------------------------------------------------
def _l10n_mx_edi_get_datetime_now_with_mx_timezone(self, cfdi_values):
""" Get datetime.now() but with the mexican timezone depending the CFDI issued address.
:param cfdi_values: The values to create the CFDI collected so far.
:return: A datetime object.
"""
self.ensure_one()
issued_address = cfdi_values['issued_address']
tz = issued_address._l10n_mx_edi_get_cfdi_timezone()
tz_force = self.env['ir.config_parameter'].sudo().get_param(f'l10n_mx_edi_tz_{self.journal_id.id}', default=None)
if tz_force:
tz = timezone(tz_force)
return datetime.now(tz)
def _l10n_mx_edi_add_common_cfdi_values(self, cfdi_values):
''' Populate cfdi values to generate a cfdi for a journal entry. '''
self.ensure_one()
Document = self.env['l10n_mx_edi.document']
Document._add_base_cfdi_values(cfdi_values)
Document._add_currency_cfdi_values(cfdi_values, self.currency_id)
Document._add_document_name_cfdi_values(cfdi_values, self.name)
Document._add_document_origin_cfdi_values(cfdi_values, self.l10n_mx_edi_cfdi_origin)
# -------------------------------------------------------------------------
# CFDI Generation: Invoices
# -------------------------------------------------------------------------
def _l10n_mx_edi_cfdi_invoice_line_ids(self):
""" Get the invoice lines to be considered when creating the CFDI.
:return: A recordset of invoice lines.
"""
self.ensure_one()
return self.invoice_line_ids.filtered(lambda line: (
line.display_type == 'product'
and not line.currency_id.is_zero(line.price_unit * line.quantity)
))
def _l10n_mx_edi_cfdi_check_invoice_config(self):
""" Prepare the CFDI xml for the invoice. """
self.ensure_one()
errors = []
# == Check the 'l10n_mx_edi_decimal_places' field set on the currency ==
currency_precision = self.currency_id.l10n_mx_edi_decimal_places
if currency_precision is False:
errors.append(_(
"The SAT does not provide information for the currency %s.\n"
"You must get manually a key from the PAC to confirm the "
"currency rate is accurate enough.",
self.currency_id,
))
# == Check the invoice ==
invoice_lines = self._l10n_mx_edi_cfdi_invoice_line_ids()
if not invoice_lines:
errors.append(_("The invoice must contain at least one positive line to generate the CFDI."))
negative_lines = invoice_lines.filtered(lambda line: line.price_subtotal < 0)
if negative_lines:
# Line having a negative amount is not allowed.
if not self.env['l10n_mx_edi.document']._is_cfdi_negative_lines_allowed():
errors.append(_(
"Invoice lines having a negative amount are not allowed to generate the CFDI. "
"Please create a credit note instead.",
))
invalid_unspcs_products = invoice_lines.product_id.filtered(lambda product: not product.unspsc_code_id)
if invalid_unspcs_products:
errors.append(_(
"You need to define an 'UNSPSC Product Category' on the following products: %s",
', '.join(invalid_unspcs_products.mapped('display_name')),
))
return errors
def _l10n_mx_edi_add_invoice_cfdi_values(self, cfdi_values, percentage_paid=None, global_invoice=False):
self.ensure_one()
Document = self.env['l10n_mx_edi.document']
base_lines = [
{
**invl._convert_to_tax_base_line_dict(),
'uom': invl.product_uom_id,
'name': invl.name,
}
for invl in self._l10n_mx_edi_cfdi_invoice_line_ids()
]
Document._add_base_lines_tax_amounts(base_lines, cfdi_values=cfdi_values)
if global_invoice and self.reversal_move_id:
refund_base_lines = [
{
**invl._convert_to_tax_base_line_dict(),
'uom': invl.product_uom_id,
'name': invl.name,
}
for invl in self.reversal_move_id._l10n_mx_edi_cfdi_invoice_line_ids()
]
for refund_base_line in refund_base_lines:
refund_base_line['quantity'] *= -1
refund_base_line['price_subtotal'] *= -1
Document._add_base_lines_tax_amounts(refund_base_lines, cfdi_values=cfdi_values)
base_lines += refund_base_lines
# Manage the negative lines.
lines_dispatching = Document._dispatch_cfdi_base_lines(base_lines)
if lines_dispatching['orphan_negative_lines']:
cfdi_values['errors'] = [_("Failed to distribute some negative lines")]
return
cfdi_lines = lines_dispatching['result_lines']
if not cfdi_lines:
cfdi_values['errors'] = ['empty_cfdi']
return
self._l10n_mx_edi_add_common_cfdi_values(cfdi_values)
cfdi_values['tipo_de_comprobante'] = 'I' if self.move_type == 'out_invoice' else 'E'
Document._add_customer_cfdi_values(
cfdi_values,
customer=self.partner_id,
usage=self.l10n_mx_edi_usage,
to_public=self.l10n_mx_edi_cfdi_to_public,
)
Document._add_tax_objected_cfdi_values(cfdi_values, cfdi_lines)
Document._add_base_lines_cfdi_values(
cfdi_values,
cfdi_lines,
percentage_paid=percentage_paid,
)
# Date.
timezoned_now = self._l10n_mx_edi_get_datetime_now_with_mx_timezone(cfdi_values)
timezoned_today = timezoned_now.date()
if self.invoice_date >= timezoned_today:
cfdi_values['fecha'] = timezoned_now.strftime(CFDI_DATE_FORMAT)
else:
cfdi_time = datetime.strptime('23:59:00', '%H:%M:%S').time()
cfdi_values['fecha'] = datetime\
.combine(fields.Datetime.from_string(self.invoice_date), cfdi_time)\
.strftime(CFDI_DATE_FORMAT)
# Payment terms.
cfdi_values['metodo_pago'] = self.l10n_mx_edi_payment_policy
if cfdi_values['metodo_pago'] == 'PPD':
cfdi_values['forma_pago'] = '99'
else:
cfdi_values['forma_pago'] = (self.l10n_mx_edi_payment_method_id.code or '').replace('NA', '99')
cfdi_values['condiciones_de_pago'] = self.invoice_payment_term_id.name
# Currency.
if self.currency_id.name == 'MXN':
cfdi_values['tipo_cambio'] = None
else:
mxn_currency = self.company_currency_id
current_currency = self.currency_id
cfdi_values["tipo_cambio"] = current_currency._get_conversion_rate(
from_currency=current_currency,
to_currency=mxn_currency,
company=self.company_id,
date=self.date,
) if self.amount_total else 1.0
def _l10n_mx_edi_get_invoice_cfdi_filename(self):
""" Get the filename of the CFDI.
:return: The filename as a string.
"""
self.ensure_one()
return f"{self.journal_id.code}-{self.name}-MX-Invoice-4.0.xml".replace('/', '')
def _get_invoice_report_filename(self, extension='pdf'):
# EXTENDS 'account'
return f'{self._l10n_mx_edi_get_invoice_cfdi_filename()[:-4]}.{extension}'\
if self.l10n_mx_edi_is_cfdi_needed else super()._get_invoice_report_filename(extension=extension)
# -------------------------------------------------------------------------
# CFDI Generation: Payments
# -------------------------------------------------------------------------
def _l10n_mx_edi_add_payment_cfdi_values(self, cfdi_values, pay_results):
""" Prepare the values to render the payment cfdi.
:param cfdi_values: Prepared cfdi_values.
:param pay_results: The amounts to consider for each invoice.
See '_l10n_mx_edi_cfdi_payment_get_reconciled_invoice_values'.
:return: The dictionary to render the xml.
"""
self.ensure_one()
self._l10n_mx_edi_add_common_cfdi_values(cfdi_values)
company = cfdi_values['company']
company_curr = company.currency_id
# Misc.
cfdi_values['exportacion'] = '01'
cfdi_values['forma_de_pago'] = (self.l10n_mx_edi_payment_method_id.code or '').replace('NA', '99')
cfdi_values['moneda'] = self.currency_id.name
cfdi_values['num_operacion'] = self.ref
# Amounts.
total_in_payment_curr = sum(x['payment_amount_currency'] for x in pay_results['invoice_results'])
total_in_company_curr = sum(x['balance'] + x['payment_exchange_balance'] for x in pay_results['invoice_results'])
if self.currency_id == company_curr:
cfdi_values['monto'] = total_in_company_curr
else:
cfdi_values['monto'] = total_in_payment_curr
# Exchange rate.
# 'tipo_cambio' is a conditional attribute used to express the exchange rate of the currency on the date the
# payment was made.
# The value must reflect the number of Mexican pesos that are equivalent to a unit of the currency indicated
# in the 'moneda' attribute.
# It is required when the MonedaP attribute is different from MXN.
cfdi_values['tipo_cambio_dp'] = 6
if self.currency_id == company_curr:
payment_rate = None
else:
raw_payment_rate = abs(total_in_company_curr / total_in_payment_curr) if total_in_payment_curr else 0.0
payment_rate = float_round(raw_payment_rate, precision_digits=cfdi_values['tipo_cambio_dp'])
cfdi_values['tipo_cambio'] = payment_rate
# === Create the list of invoice data ===
invoice_values_list = []
for invoice_values in pay_results['invoice_results']:
invoice = invoice_values['invoice']
inv_cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(invoice.company_id)
self.env['l10n_mx_edi.document']._add_certificate_cfdi_values(inv_cfdi_values)
invoice._l10n_mx_edi_add_invoice_cfdi_values(inv_cfdi_values)
# Apply the percentage paid to the tax amounts.
if invoice.amount_total:
percentage_paid = abs(invoice_values['reconciled_amount'] / invoice.amount_total)
else:
percentage_paid = 0.0
for key in ('retenciones_list', 'traslados_list'):
for tax_values in inv_cfdi_values[key]:
for tax_key in ('base', 'importe'):
if tax_values[tax_key] is not None:
tax_values[tax_key] = invoice.currency_id.round(tax_values[tax_key] * percentage_paid)
# CRP20261:
# - 'base' * 'tasa_o_cuota' must give 'importe' with 0.01 rounding error allowed.
# Suppose an invoice of 5 * 0.47 with 16% tax. Each line gives a tax amount of 0.08 so 0.40 for the whole invoice.
# However, 5 * 0.47 = 2.35 and 2.35 * 0.16 = 0.38 so the constraint is failing.
# - 'base' + 'importe' must be exactly equal to the part that is actually paid.
# Using the same example, we need to report 2.35 + 0.40 = 2.75
# => To solve that, let's proceed backward. 2.75 * 0.16 / 1.16 = 0.38 (importe) and 2.75 - 0.38 = 2.27 (base).
if (
company.tax_calculation_rounding_method == 'round_per_line'
and all(tax_values[key] is not None for key in ('base', 'importe', 'tasa_o_cuota'))
):
post_amounts_map = self.env['l10n_mx_edi.document']._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=invoice.currency_id.decimal_places,
)
tax_values['importe'] = post_amounts_map['new_tax_amount']
tax_values['base'] = post_amounts_map['new_base_amount']
# 'equivalencia' (rate) is a conditional attribute used to express the exchange rate according to the currency
# registered in the document related. It is required when the currency of the related document is different
# from the payment currency.
# The number of units of the currency must be recorded indicated in the related document that are
# equivalent to a unit of the currency of the payment.
def calculate_rate(invoice, invoice_amount, payment_amount):
if not payment_amount:
return 0.0
rate = self.currency_id._get_conversion_rate(self.currency_id, invoice.currency_id, self.company_id, self.date)
converted_invoice_amount = self.currency_id.round(invoice_amount / rate) if rate else 0.0
converted_payment_amount = invoice.currency_id.round(payment_amount * rate)
if (
self.currency_id.is_zero(converted_invoice_amount - payment_amount)
and invoice.currency_id.is_zero(invoice_amount - converted_payment_amount)
):
return rate
return abs(invoice_amount / payment_amount)
if invoice.currency_id == self.currency_id:
# Same currency.
rate = None
elif invoice.currency_id == company_curr != self.currency_id:
# Adapt the payment rate to find the reconciled amount of the invoice but expressed in payment currency.
balance = invoice_values['balance'] + invoice_values['invoice_exchange_balance']
rate = calculate_rate(invoice, balance, invoice_values['payment_amount_currency'])
elif self.currency_id == company_curr != invoice.currency_id:
# Adapt the invoice rate to find the reconciled amount of the payment but expressed in invoice currency.
balance = invoice_values['balance'] + invoice_values['payment_exchange_balance']
rate = calculate_rate(invoice, invoice_values['invoice_amount_currency'], balance)
else:
# Both are expressed in different currencies.
rate = calculate_rate(invoice, invoice_values['invoice_amount_currency'], invoice_values['payment_amount_currency'])
invoice_values_list.append({
**inv_cfdi_values,
'id_documento': invoice.l10n_mx_edi_cfdi_uuid,
'equivalencia': rate,
'num_parcialidad': invoice_values['number_of_payments'],
'imp_pagado': invoice_values['reconciled_amount'],
'imp_saldo_ant': invoice_values['amount_residual_before'],
'imp_saldo_insoluto': invoice_values['amount_residual_after'],
})
cfdi_values['docto_relationado_list'] = invoice_values_list
# Customer.
rfcs = set(x['receptor']['rfc'] for x in invoice_values_list)
if len(rfcs) > 1:
cfdi_values['errors'] = [_("You can't register a payment for invoices having different RFCs.")]
return
customer_values = invoice_values_list[0]['receptor']
customer = customer_values['customer']
cfdi_values['receptor'] = customer_values
cfdi_values['lugar_expedicion'] = cfdi_values['issued_address'].zip
# Date.
cfdi_date = datetime.combine(fields.Datetime.from_string(self.date), datetime.strptime('12:00:00', '%H:%M:%S').time())
cfdi_values['fecha'] = self._l10n_mx_edi_get_datetime_now_with_mx_timezone(cfdi_values).strftime(CFDI_DATE_FORMAT)
cfdi_values['fecha_pago'] = cfdi_date.strftime(CFDI_DATE_FORMAT)
# Bank information.
payment_method_code = self.l10n_mx_edi_payment_method_id.code
is_payment_code_emitter_ok = payment_method_code in ('02', '03', '04', '05', '06', '28', '29', '99')
is_payment_code_receiver_ok = payment_method_code in ('02', '03', '04', '05', '28', '29', '99')
is_payment_code_bank_ok = payment_method_code in ('02', '03', '04', '28', '29', '99')
bank_account = customer.bank_ids.filtered(lambda x: x.company_id.id in (False, company.id))[:1]
partner_bank = bank_account.bank_id
if partner_bank.country and partner_bank.country.code != 'MX':
partner_bank_vat = 'XEXX010101000'
else: # if no partner_bank (e.g. cash payment), partner_bank_vat is not set.
partner_bank_vat = partner_bank.l10n_mx_edi_vat
payment_account_ord = re.sub(r'\s+', '', bank_account.acc_number or '') or None
payment_account_receiver = re.sub(r'\s+', '', self.journal_id.bank_account_id.acc_number or '') or None
cfdi_values.update({
'rfc_emisor_cta_ord': is_payment_code_emitter_ok and partner_bank_vat,
'nom_banco_ord_ext': is_payment_code_bank_ok and partner_bank.name,
'cta_ordenante': is_payment_code_emitter_ok and payment_account_ord,
'rfc_emisor_cta_ben': is_payment_code_receiver_ok and self.journal_id.bank_account_id.bank_id.l10n_mx_edi_vat,
'cta_beneficiario': is_payment_code_receiver_ok and payment_account_receiver,
})
# Taxes.
cfdi_values.update({
'monto_total_pagos': total_in_company_curr,
'mxn_digits': company_curr.decimal_places,
})
def update_tax_amount(key, amount):
if key not in cfdi_values:
cfdi_values[key] = 0.0
cfdi_values[key] += amount
def check_transferred_tax_values(tax_values, tag, tax_class, amount):
return (
tax_values['impuesto'] == tag
and tax_values['tipo_factor'] == tax_class
and company_curr.compare_amounts(tax_values['tasa_o_cuota'] or 0.0, amount) == 0
)
withholding_values_map = defaultdict(lambda: {'importe': 0.0})
transferred_values_map = defaultdict(lambda: {'base': 0.0, 'importe': 0.0})
pay_rate = cfdi_values['tipo_cambio'] or 1.0
for cfdi_inv_values in invoice_values_list:
inv_rate = cfdi_inv_values['equivalencia'] or 1.0
to_mxn_rate = pay_rate / inv_rate
for tax_values in cfdi_inv_values['retenciones_list']:
key = frozendict({'impuesto': tax_values['impuesto']})
withholding_values_map[key]['importe'] += tax_values['importe'] / inv_rate
tax_amount_mxn = tax_values['importe'] * to_mxn_rate
if tax_values['impuesto'] == '001':
update_tax_amount('total_retenciones_isr', tax_amount_mxn)
elif tax_values['impuesto'] == '002':
update_tax_amount('total_retenciones_iva', tax_amount_mxn)
elif tax_values['impuesto'] == '003':
update_tax_amount('total_retenciones_ieps', tax_amount_mxn)
for tax_values in cfdi_inv_values['traslados_list']:
key = frozendict({
'impuesto': tax_values['impuesto'],
'tipo_factor': tax_values['tipo_factor'],
'tasa_o_cuota': tax_values['tasa_o_cuota']
})
tax_amount = tax_values['importe'] or 0.0
transferred_values_map[key]['base'] += tax_values['base'] / inv_rate
transferred_values_map[key]['importe'] += tax_amount / inv_rate
base_amount_mxn = tax_values['base'] * to_mxn_rate
tax_amount_mxn = tax_amount * to_mxn_rate
if check_transferred_tax_values(tax_values, '002', 'Tasa', 0.0):
update_tax_amount('total_traslados_base_iva0', base_amount_mxn)
update_tax_amount('total_traslados_impuesto_iva0', tax_amount_mxn)
elif check_transferred_tax_values(tax_values, '002', 'Exento', 0.0):
update_tax_amount('total_traslados_base_iva_exento', base_amount_mxn)
elif check_transferred_tax_values(tax_values, '002', 'Tasa', 0.08):
update_tax_amount('total_traslados_base_iva8', base_amount_mxn)
update_tax_amount('total_traslados_impuesto_iva8', tax_amount_mxn)
elif check_transferred_tax_values(tax_values, '002', 'Tasa', 0.16):
update_tax_amount('total_traslados_base_iva16', base_amount_mxn)
update_tax_amount('total_traslados_impuesto_iva16', tax_amount_mxn)
# Rounding global tax amounts.
for dictionary in (withholding_values_map, transferred_values_map):
for values in dictionary.values():
if 'base' in values:
values['base'] = self.currency_id.round(values['base'])
values['importe'] = self.currency_id.round(values['importe'])
for key in (
'total_traslados_base_iva0',
'total_traslados_impuesto_iva0',
'total_traslados_base_iva_exento',
'total_traslados_base_iva8',
'total_traslados_impuesto_iva8',
'total_traslados_base_iva16',
'total_traslados_impuesto_iva16',
'total_retenciones_isr',
'total_retenciones_iva',
'total_retenciones_ieps',
):
if key in cfdi_values:
cfdi_values[key] = company_curr.round(cfdi_values[key])
else:
cfdi_values[key] = None
cfdi_values['retenciones_list'] = [
{**k, **v}
for k, v in withholding_values_map.items()
]
cfdi_values['traslados_list'] = [
{**k, **v}
for k, v in transferred_values_map.items()
]
# Cleanup attributes for Exento taxes.
for tax_values in cfdi_values['traslados_list']:
if tax_values['tipo_factor'] == 'Exento':
tax_values['importe'] = None
# -------------------------------------------------------------------------
# CFDI: DOCUMENTS
# -------------------------------------------------------------------------
def _l10n_mx_edi_cfdi_invoice_document_sent_failed(self, error, cfdi_filename=None, cfdi_str=None):
""" Create/update the invoice document for 'sent_failed'.
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
:param error: The error.
:param cfdi_filename: The optional filename of the cfdi.
:param cfdi_str: The optional content of the cfdi.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_sent_failed',
'sat_state': None,
'message': error,
}
if cfdi_filename and cfdi_str:
document_values['attachment_id'] = {
'name': cfdi_filename,
'raw': cfdi_str,
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_invoice_document_sent(self, cfdi_filename, cfdi_str):
""" Create/update the invoice document for 'sent'.
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
:param cfdi_filename: The filename of the cfdi.
:param cfdi_str: The content of the cfdi.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_sent',
'sat_state': 'not_defined',
'message': None,
'attachment_id': {
'name': cfdi_filename,
'raw': cfdi_str,
'description': "CFDI",
},
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_invoice_document_empty(self):
""" Create/update the invoice document for an empty invoice.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_sent',
'sat_state': 'skip',
'message': None,
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_invoice_document_cancel_requested_failed(self, error, cfdi, cancel_reason):
""" Create/update the invoice document for 'cancel_requested_failed'.
:param error: The error.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_cancel_requested_failed',
'sat_state': None,
'message': error,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_invoice_document_cancel_requested(self, cfdi, cancel_reason):
""" Create/update the invoice document for 'cancel_requested'.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_cancel_requested',
'sat_state': 'not_defined',
'message': None,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_invoice_document_cancel_failed(self, error, cfdi, cancel_reason):
""" Create/update the invoice document for 'cancel_failed'.
:param error: The error.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_cancel_failed',
'sat_state': None,
'message': error,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_invoice_document_cancel(self, cfdi, cancel_reason):
""" Create/update the invoice document for 'cancel'.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(self.ids)],
'state': 'invoice_cancel',
'sat_state': 'not_defined',
'message': None,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_invoice(self, document_values)
def _l10n_mx_edi_cfdi_payment_document_sent_pue(self, invoices):
""" Create/update the invoice document for 'sent_pue'.
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
:param invoices: The invoices reconciled with the payment and sent to the government.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(invoices.ids)],
'state': 'payment_sent_pue',
'sat_state': None,
'message': None,
}
return self.env['l10n_mx_edi.document']._create_update_payment_document(self, document_values)
def _l10n_mx_edi_cfdi_payment_document_sent_failed(self, error, invoices, cfdi_filename=None, cfdi_str=None):
""" Create/update the invoice document for 'sent_failed'.
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
:param error: The error.
:param cfdi: The cancelled cfdi attachment.
:param invoices: The invoices reconciled with the payment and sent to the government.
:param cfdi_filename: The optional filename of the cfdi.
:param cfdi_str: The optional content of the cfdi.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(invoices.ids)],
'state': 'payment_sent_failed',
'sat_state': None,
'message': error,
}
if cfdi_filename and cfdi_str:
document_values['attachment_id'] = {
'name': cfdi_filename,
'raw': cfdi_str,
}
return self.env['l10n_mx_edi.document']._create_update_payment_document(self, document_values)
def _l10n_mx_edi_cfdi_payment_document_sent(self, invoices, cfdi_filename, cfdi_str):
""" Create/update the invoice document for 'sent'.
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
:param invoices: The invoices reconciled with the payment and sent to the government.
:param cfdi_filename: The filename of the cfdi.
:param cfdi_str: The content of the cfdi.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(invoices.ids)],
'state': 'payment_sent',
'sat_state': 'not_defined',
'message': None,
'attachment_id': {
'name': cfdi_filename,
'raw': cfdi_str,
'description': "CFDI",
},
}
return self.env['l10n_mx_edi.document']._create_update_payment_document(self, document_values)
def _l10n_mx_edi_cfdi_payment_document_cancel_failed(self, error, cfdi, cancel_reason):
""" Create/update the payment document for 'cancel_failed'.
:param error: The error.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(cfdi.invoice_ids.ids)],
'state': 'payment_cancel_failed',
'sat_state': None,
'message': error,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_payment_document(self, document_values)
def _l10n_mx_edi_cfdi_payment_document_cancel(self, cfdi, cancel_reason):
""" Create/update the payment document for 'cancel'.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.ensure_one()
document_values = {
'move_id': self.id,
'invoice_ids': [Command.set(cfdi.invoice_ids.ids)],
'state': 'payment_cancel',
'sat_state': 'not_defined',
'message': None,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_payment_document(self, document_values)
def _l10n_mx_edi_cfdi_global_invoice_document_sent_failed(self, error, cfdi_filename=None, cfdi_str=None):
""" Create/update the global invoice document for 'sent_failed'.
:param error: The error.
:param cfdi_filename: The optional filename of the cfdi.
:param cfdi_str: The optional content of the cfdi.
:return: The created/updated document.
"""
document_values = {
'invoice_ids': [Command.set(self.ids)],
'state': 'ginvoice_sent_failed',
'sat_state': None,
'message': error,
}
if cfdi_filename and cfdi_str:
document_values['attachment_id'] = {
'name': cfdi_filename,
'raw': cfdi_str,
}
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_invoices(self, document_values)
def _l10n_mx_edi_cfdi_global_invoice_document_sent(self, cfdi_filename, cfdi_str):
""" Create/update the global invoice document for 'sent'.
:param cfdi_filename: The filename of the cfdi.
:param cfdi_str: The content of the cfdi.
:return: The created/updated document.
"""
document_values = {
'invoice_ids': [Command.set(self.ids)],
'state': 'ginvoice_sent',
'sat_state': 'not_defined',
'message': None,
'attachment_id': {
'name': cfdi_filename,
'raw': cfdi_str,
'description': "CFDI",
},
}
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_invoices(self, document_values)
def _l10n_mx_edi_cfdi_global_invoice_document_empty(self):
""" Create/update the global invoice document for an empty cfdi.
:return: The created/updated document.
"""
document_values = {
'invoice_ids': [Command.set(self.ids)],
'state': 'ginvoice_sent',
'sat_state': 'skip',
'message': None,
}
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_invoices(self, document_values)
def _l10n_mx_edi_cfdi_global_invoice_document_cancel_failed(self, error, cfdi, cancel_reason):
""" Create/update the invoice document for 'cancel_failed'.
:param error: The error.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
document_values = {
'invoice_ids': [Command.set(self.ids)],
'state': 'ginvoice_cancel_failed',
'sat_state': None,
'message': error,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_invoices(self, document_values)
def _l10n_mx_edi_cfdi_global_invoice_document_cancel(self, cfdi, cancel_reason):
""" Create/update the invoice document for 'cancel'.
:param cfdi: The source cfdi attachment to cancel.
:param cancel_reason: The reason for this cancel.
:return: The created/updated document.
"""
self.l10n_mx_edi_cfdi_attachment_id.ensure_one()
document_values = {
'invoice_ids': [Command.set(self.ids)],
'state': 'ginvoice_cancel',
'sat_state': 'not_defined',
'message': None,
'attachment_id': cfdi.attachment_id.id,
'cancellation_reason': cancel_reason,
}
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_invoices(self, document_values)
# -------------------------------------------------------------------------
# CFDI: FLOWS
# -------------------------------------------------------------------------
def _l10n_mx_edi_cfdi_move_post_cancel(self):
""" Cancel the current move after the document has been cancelled.
This method is common between invoice & payment:
"""
self.ensure_one()
self \
.with_context(no_new_invoice=True) \
.message_post(body=_("The CFDI document has been successfully cancelled."))
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(self.company_id)
if cfdi_values['root_company'].l10n_mx_edi_pac_test_env:
try:
self._check_fiscalyear_lock_date()
self.line_ids._check_tax_lock_date()
self.button_draft()
self.button_cancel()
except UserError:
pass
def _l10n_mx_edi_cfdi_move_update_sat_state(self, document, sat_state, error=None):
""" Update the SAT state of the document for the current move.
:param document: The CFDI document to be updated.
:param sat_state: The newly fetched state from the SAT
:param error: In case of error, the message returned by the SAT.
"""
self.ensure_one()
document.message = None
if sat_state == 'error' and error:
document.message = error
self.message_post(body=error)
# Automatic cancel for production environment.
if (
self.l10n_mx_edi_cfdi_state == 'cancel'
and self.l10n_mx_edi_cfdi_sat_state == 'cancelled'
and self.state == 'posted'
):
try:
self._check_fiscalyear_lock_date()
self.line_ids._check_tax_lock_date()
self.button_draft()
self.button_cancel()
except UserError:
pass
def _l10n_mx_edi_cfdi_invoice_retry_send(self):
""" Retry generating the PDF and CFDI for the current invoice. """
self.ensure_one()
option_vals = self.env['account.move.send']._get_wizard_vals_restrict_to({'l10n_mx_edi_checkbox_cfdi': True})
move_send = self.env['account.move.send'].new(option_vals)
self.env['account.move.send']._process_send_and_print(moves=self, wizard=move_send)
def _l10n_mx_edi_cfdi_invoice_try_send(self):
""" Try to generate and send the CFDI for the current invoice. """
self.ensure_one()
if self.state != 'posted' or self.l10n_mx_edi_cfdi_state not in (False, 'cancel', 'global_cancel'):
return
# == Check the config ==
errors = self._l10n_mx_edi_cfdi_check_invoice_config()
if errors:
self._l10n_mx_edi_cfdi_invoice_document_sent_failed("\n".join(errors))
return
# == Lock ==
self.env['res.company']._with_locked_records(self)
# == Send ==
def on_populate(cfdi_values):
self._l10n_mx_edi_add_invoice_cfdi_values(cfdi_values)
def on_failure(error, cfdi_filename=None, cfdi_str=None):
if error == 'empty_cfdi':
self._l10n_mx_edi_cfdi_invoice_document_empty()
else:
self._l10n_mx_edi_cfdi_invoice_document_sent_failed(error, cfdi_filename=cfdi_filename, cfdi_str=cfdi_str)
def on_success(_cfdi_values, cfdi_filename, cfdi_str, populate_return=None):
addenda = self.partner_id.l10n_mx_edi_addenda or self.commercial_partner_id.l10n_mx_edi_addenda
if addenda:
cfdi_str = self._l10n_mx_edi_cfdi_invoice_append_addenda(cfdi_str, addenda)
document = self._l10n_mx_edi_cfdi_invoice_document_sent(cfdi_filename, cfdi_str)
self \
.with_context(no_new_invoice=True) \
.message_post(
body=_("The CFDI document was successfully created and signed by the government."),
attachment_ids=document.attachment_id.ids,
)
qweb_template, _xsd_attachment_name = self.env['l10n_mx_edi.document']._get_invoice_cfdi_template()
self.env['l10n_mx_edi.document']._send_api(
self.company_id,
qweb_template,
self._l10n_mx_edi_get_invoice_cfdi_filename(),
on_populate,
on_failure,
on_success,
)
def _l10n_mx_edi_cfdi_invoice_post_cancel(self):
""" Cancel the current invoice and drop a message in the chatter.
This method is only there to unify the flows since they are multiple
ways to cancel an invoice:
- The user can request a cancellation from Odoo.
- The user can cancel the invoice from the SAT, then update the SAT state in Odoo.
"""
self._l10n_mx_edi_cfdi_move_post_cancel()
def _l10n_mx_edi_cfdi_invoice_try_cancel(self, document, cancel_reason):
""" Try to cancel the CFDI for the current invoice.
:param document: The source invoice document to cancel.
:param cancel_reason: The reason for the cancellation.
"""
self.ensure_one()
if self.state != 'posted' or self.l10n_mx_edi_cfdi_state != 'sent':
return
# == Lock ==
self.env['res.company']._with_locked_records(self)
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(self.company_id)
is_test_env = cfdi_values['root_company'].l10n_mx_edi_pac_test_env
# == Cancel ==
def on_failure(error):
if is_test_env:
self._l10n_mx_edi_cfdi_invoice_document_cancel_failed(error, document, cancel_reason)
else:
self._l10n_mx_edi_cfdi_invoice_document_cancel_requested_failed(error, document, cancel_reason)
def on_success():
if is_test_env:
self._l10n_mx_edi_cfdi_invoice_document_cancel(document, cancel_reason)
else:
self._l10n_mx_edi_cfdi_invoice_document_cancel_requested(document, cancel_reason)
self._l10n_mx_edi_cfdi_invoice_post_cancel()
document._cancel_api(self.company_id, cancel_reason, on_failure, on_success)
def _l10n_mx_edi_cfdi_invoice_update_sat_state(self, document, sat_state, error=None):
""" Update the SAT state of the document for the current invoice.
:param document: The CFDI document to be updated.
:param sat_state: The newly fetched state from the SAT
:param error: In case of error, the message returned by the SAT.
"""
self.ensure_one()
# The user manually cancelled the document in the SAT portal.
if document.state == 'invoice_sent' and sat_state == 'cancelled':
if document.sat_state not in ('valid', 'cancelled', 'skip'):
document.sat_state = 'skip'
document = self._l10n_mx_edi_cfdi_invoice_document_cancel(
document,
CANCELLATION_REASON_SELECTION[1][0], # Force '02'.
)
document.sat_state = sat_state
self._l10n_mx_edi_cfdi_invoice_post_cancel()
# The cancellation request has been approved by the SAT.
elif document.state == 'invoice_cancel_requested' and sat_state == 'cancelled':
document.sat_state = sat_state
document = self._l10n_mx_edi_cfdi_invoice_document_cancel(
document,
document.cancellation_reason,
)
document.sat_state = 'cancelled'
self._l10n_mx_edi_cfdi_invoice_post_cancel()
else:
document.sat_state = sat_state
self._l10n_mx_edi_cfdi_move_update_sat_state(document, sat_state, error=error)
def _l10n_mx_edi_cfdi_invoice_get_reconciled_payments_values(self):
""" Compute the residual amounts before/after each payment reconciled with the current invoices.
:return: A mapping invoice => dictionary containing:
* payment: The account.move of the payment.
* reconciled_amount: The reconciled amount.
* amount_residual_before: The residual amount before reconciliation.
* amount_residual_after: The residual_amount after reconciliation.
"""
# Only consider the invoices already signed.
invoices = self.filtered(lambda x: x.is_invoice() and x.l10n_mx_edi_cfdi_state == 'sent').sorted()
# Collect the reconciled amounts.
reconciliation_values = {}
for invoice in invoices:
pay_rec_lines = invoice.line_ids\
.filtered(lambda line: line.account_type in ('asset_receivable', 'liability_payable'))
# Track the amount in exchange moves too.
exchange_move_map = {}
# Track the reconciliation to each payment because they have to be sent too.
reconciliation_values[invoice] = {
'payments': defaultdict(lambda: {
'invoice_amount_currency': 0.0,
'balance': 0.0,
'invoice_exchange_balance': 0.0,
'payment_amount_currency': 0.0,
'other_residual': 0.0,
}),
}
# If a reconciliation has been made with something that is not a payment like a credit note, it has to be taken into account
# when computing the residual amounts before and after.
other_residual = 0.0
for field1, field2 in (('credit', 'debit'), ('debit', 'credit')):
for partial in pay_rec_lines[f'matched_{field1}_ids'].sorted(lambda x: (
not x.exchange_move_id,
x[f'{field1}_move_id'].invoice_date or x[f'{field1}_move_id'].date,
x[f'{field1}_move_id'].id,
)):
counterpart_line = partial[f'{field1}_move_id']
counterpart_move = counterpart_line.move_id
is_payment = counterpart_move._l10n_mx_edi_is_cfdi_payment()
if partial.exchange_move_id:
exchange_move_map[partial.exchange_move_id] = counterpart_move
if counterpart_move in exchange_move_map:
if is_payment:
pay_results = reconciliation_values[invoice]['payments'][exchange_move_map[counterpart_move]]
pay_results['invoice_exchange_balance'] += partial.amount
else:
other_residual += partial[f'{field2}_amount_currency']
elif is_payment:
pay_results = reconciliation_values[invoice]['payments'][counterpart_move]
pay_results['invoice_amount_currency'] += partial[f'{field2}_amount_currency']
pay_results['payment_amount_currency'] += partial[f'{field1}_amount_currency']
pay_results['balance'] += partial.amount
pay_results['other_residual'] += other_residual
other_residual = 0.0
else:
other_residual += partial[f'{field2}_amount_currency']
# Compute the chain of payments.
results = {}
for invoice, invoice_values in reconciliation_values.items():
payment_values = invoice_values['payments']
invoice_results = results[invoice] = []
residual = invoice.amount_total
for pay, pay_results in sorted(list(payment_values.items()), key=lambda x: x[0].date):
# Extract the part reconciled by the payment.
reconciled_invoice_amount = pay_results['invoice_amount_currency']
if invoice.currency_id == invoice.company_currency_id:
reconciled_invoice_amount += pay_results['invoice_exchange_balance']
# Subtract the residual amount implies by others reconciliation like credit notes.
residual -= pay_results['other_residual']
invoice_results.append({
**pay_results,
'payment': pay,
'invoice': invoice,
'number_of_payments': len(payment_values),
'reconciled_amount': reconciled_invoice_amount,
'amount_residual_before': residual,
'amount_residual_after': residual - reconciled_invoice_amount,
})
residual -= reconciled_invoice_amount
return results
def _l10n_mx_edi_cfdi_payment_get_reconciled_invoice_values(self):
""" Compute the amounts to send to the PAC from the current payments.
:return: A mapping payment => dictionary containing:
* invoices: The reconciled invoices.
* invoice_results: A list of payment values, see '_l10n_mx_edi_cfdi_invoice_get_reconciled_payments_values'.
"""
# Find all invoices linked to the current payments.
results = {}
payments = self.filtered(lambda x: x._l10n_mx_edi_is_cfdi_payment() and x.l10n_mx_edi_cfdi_state != 'cancel')
all_invoices = self.env['account.move']
exchange_move_map = {}
exchange_move_balances = defaultdict(lambda: defaultdict(lambda: 0.0))
for payment in payments:
# Only the fully reconciled payments need to be sent.
pay_rec_lines = payment.line_ids\
.filtered(lambda line: line.account_type in ('asset_receivable', 'liability_payable'))
if any(not x.reconciled for x in pay_rec_lines):
continue
# The payments must only be sent when all reconciled invoices are sent.
skip = False
invoices = self.env['account.move']
for field in ('debit', 'credit'):
for partial in pay_rec_lines[f'matched_{field}_ids'].sorted(lambda x: not x.exchange_move_id):
counterpart_line = partial[f'{field}_move_id']
counterpart_move = counterpart_line.move_id
if counterpart_move in exchange_move_map:
exchange_move_balances[payment][exchange_move_map[counterpart_move]] += partial.amount
continue
if not counterpart_move.is_invoice() or not counterpart_move.l10n_mx_edi_cfdi_state:
skip = True
break
if partial.exchange_move_id:
exchange_move_map[partial.exchange_move_id] = counterpart_move
invoices |= counterpart_move
if skip:
continue
all_invoices |= invoices
reconciled_amls = pay_rec_lines.matched_debit_ids.debit_move_id \
+ pay_rec_lines.matched_credit_ids.credit_move_id
invoices = reconciled_amls.move_id.filtered(lambda x: x.l10n_mx_edi_is_cfdi_needed and x.is_invoice())
if any(
not invoice.l10n_mx_edi_cfdi_state
for invoice in invoices
):
continue
all_invoices |= invoices
results[payment] = {
'invoices': invoices,
'invoice_results': [],
}
# Compute the amounts to send for each invoice.
reconciled_invoice_values = all_invoices._l10n_mx_edi_cfdi_invoice_get_reconciled_payments_values()
for invoice, pay_results_list in reconciled_invoice_values.items():
for pay_results in pay_results_list:
payment = pay_results['payment']
if payment not in results:
continue
pay_results['payment_exchange_balance'] = exchange_move_balances[payment][invoice]
results[payment]['invoice_results'].append(pay_results)
return results
def l10n_mx_edi_cfdi_invoice_try_update_payment(self, pay_results, force_cfdi=False):
""" Update the CFDI state of the current payment.
:param pay_results: The amounts to consider for each invoice.
See '_l10n_mx_edi_cfdi_payment_get_reconciled_invoice_values'.
:param force_cfdi: Force the sending of the CFDI if the payment is PUE.
"""
self.ensure_one()
last_document = self.l10n_mx_edi_payment_document_ids.sorted()[:1]
invoices = pay_results['invoices']
# == Check PUE/PPD ==
if (
not last_document
and not force_cfdi
and 'PPD' not in set(invoices.mapped('l10n_mx_edi_payment_policy'))
):
self._l10n_mx_edi_cfdi_payment_document_sent_pue(invoices)
return
# == Retry a cancellation flow ==
if last_document.state == 'payment_cancel_failed':
last_document._action_retry_payment_try_cancel()
return
qweb_template = self.env['l10n_mx_edi.document']._get_payment_cfdi_template()
# == Lock ==
self.env['res.company']._with_locked_records(self + invoices)
# == Send ==
def on_populate(cfdi_values):
self._l10n_mx_edi_add_payment_cfdi_values(cfdi_values, pay_results)
def on_failure(error, cfdi_filename=None, cfdi_str=None):
self._l10n_mx_edi_cfdi_payment_document_sent_failed(error, invoices, cfdi_filename=cfdi_filename, cfdi_str=cfdi_str)
def on_success(_cfdi_values, cfdi_filename, cfdi_str, populate_return=None):
self._l10n_mx_edi_cfdi_payment_document_sent(invoices, cfdi_filename, cfdi_str)
cfdi_filename = f'{self.journal_id.code}-{self.name}-MX-Payment-20.xml'.replace('/', '')
self.env['l10n_mx_edi.document']._send_api(
self.company_id,
qweb_template,
cfdi_filename,
on_populate,
on_failure,
on_success,
)
def _l10n_mx_edi_cfdi_payment_post_cancel(self):
""" Cancel the current payment and drop a message in the chatter.
This method is only there to unify the flows since they are multiple
ways to cancel a payment:
- The user can request a cancellation from Odoo.
- The user can cancel the payment from the SAT, then update the SAT state in Odoo.
"""
self._l10n_mx_edi_cfdi_move_post_cancel()
def _l10n_mx_edi_cfdi_invoice_try_cancel_payment(self, document):
""" Cancel the CFDI payment document passed as parameter
:param document: The source payment document to cancel.
"""
self.ensure_one()
substitution_doc = document._get_substitution_document()
cancel_uuid = substitution_doc.attachment_uuid
cancel_reason = '01' if cancel_uuid else '02'
# == Lock ==
self.env['res.company']._with_locked_records(self + document.invoice_ids)
# == Cancel ==
def on_failure(error):
self._l10n_mx_edi_cfdi_payment_document_cancel_failed(error, document, cancel_reason)
def on_success():
self._l10n_mx_edi_cfdi_payment_document_cancel(document, cancel_reason)
self._l10n_mx_edi_cfdi_payment_post_cancel()
document._cancel_api(self.company_id, cancel_reason, on_failure, on_success)
def _l10n_mx_edi_cfdi_invoice_get_payments_diff(self):
results = {
'to_remove': defaultdict(list),
'to_process': [],
'need_update': set(),
}
# Find the payments reconciled with the current invoices.
reconciled_invoice_values = self._l10n_mx_edi_cfdi_invoice_get_reconciled_payments_values()
# Collect the reconciled invoices for each payment that have been sent to the SAT.
sat_sent_payments = defaultdict(set)
# All payments currently reconciled with the current invoices.
all_payments = self.env['account.move']
for invoice, pay_results_list in reconciled_invoice_values.items():
payments = self.env['account.move']
for pay_results in pay_results_list:
payments |= pay_results['payment']
all_payments |= payments
commands = []
for doc in invoice.l10n_mx_edi_invoice_document_ids:
# Collect the payments that are no longer reconciled with the invoices.
if (
doc.state.startswith('payment_')
and doc.state not in ('payment_sent', 'payment_cancel')
and doc.move_id not in payments
):
commands.append(Command.delete(doc.id))
# Track the payment previously sent to the SAT.
if doc.move_id not in sat_sent_payments and doc.state in ('payment_sent', 'payment_sent_pue', 'payment_cancel'):
sat_sent_payments[doc.move_id] = set(doc.invoice_ids)
if commands:
results['to_remove'][invoice] = commands
# Update the payments.
reconciled_payment_values = all_payments._l10n_mx_edi_cfdi_payment_get_reconciled_invoice_values()
for payment, pay_results in reconciled_payment_values.items():
last_document = payment.l10n_mx_edi_payment_document_ids.sorted()[:1]
invoices = pay_results['invoices']
if last_document.state == 'payment_sent_pue':
continue
# Check if a reconciliation is missing.
if set(invoices) != sat_sent_payments[payment]:
for invoice in sat_sent_payments[payment]:
results['need_update'].add(invoice)
# Check if something changed in the already sent payment.
if last_document.state == 'payment_sent':
current_uuids = set(invoices.mapped('l10n_mx_edi_cfdi_uuid'))
previous_uuids = set()
if not last_document.attachment_id.raw:
_logger.warning(
"Payment document (id %s) has an empty attachment (id %s)",
last_document.id,
last_document.attachment_id.id,
)
continue
cfdi_node = etree.fromstring(last_document.attachment_id.raw)
for node in cfdi_node.xpath("//*[local-name()='DoctoRelacionado']"):
previous_uuids.add(node.attrib['IdDocumento'])
if current_uuids == previous_uuids:
continue
results['to_process'].append((payment, pay_results))
return results
def l10n_mx_edi_cfdi_invoice_try_update_payments(self):
""" Try to update the state of payments for the current invoices. """
payments_diff = self._l10n_mx_edi_cfdi_invoice_get_payments_diff()
# Cleanup the payments that are no longer reconciled with the invoices.
for invoice, commands in payments_diff['to_remove'].items():
invoice.l10n_mx_edi_invoice_document_ids = commands
# Update the payments.
for payment, pay_results in payments_diff['to_process']:
payment.l10n_mx_edi_cfdi_invoice_try_update_payment(pay_results)
def _l10n_mx_edi_cfdi_payment_try_send(self, force_cfdi=False):
""" Force the sending of the current payment.
:param force_cfdi: Force the sending of the payment, even if the payment is PUE.
"""
self.ensure_one()
reconciled_payment_values = self._l10n_mx_edi_cfdi_payment_get_reconciled_invoice_values()
for payment, pay_results in reconciled_payment_values.items():
payment.l10n_mx_edi_cfdi_invoice_try_update_payment(pay_results, force_cfdi=force_cfdi)
def _l10n_mx_edi_cfdi_payment_update_sat_state(self, document, sat_state, error=None):
""" Update the SAT state of the document for the current payment.
:param document: The CFDI document to be updated.
:param sat_state: The newly fetched state from the SAT
:param error: In case of error, the message returned by the SAT.
"""
self.ensure_one()
# The user manually cancelled the document in the SAT portal.
if document.state == 'payment_sent' and sat_state == 'cancelled':
if document.sat_state not in ('valid', 'cancelled', 'skip'):
document.sat_state = 'skip'
document = self._l10n_mx_edi_cfdi_payment_document_cancel(
document,
CANCELLATION_REASON_SELECTION[1][0], # Force '02'.
)
document.sat_state = sat_state
self._l10n_mx_edi_cfdi_payment_post_cancel()
else:
document.sat_state = sat_state
self._l10n_mx_edi_cfdi_move_update_sat_state(document, sat_state, error=error)
def l10n_mx_edi_cfdi_payment_force_try_send(self):
self._l10n_mx_edi_cfdi_payment_try_send(force_cfdi=True)
def _l10n_mx_edi_cfdi_global_invoice_try_send(self, periodicity='04', origin=None):
""" Create a CFDI global invoice for multiple invoices.
:param periodicity: The value to fill the 'Periodicidad' value.
:param origin: The origin of the GI when cancelling an existing one.
"""
cfdi_date = fields.Date.context_today(self)
invoices = self._l10n_mx_edi_check_invoices_for_global_invoice(origin=origin)
# == Check the config ==
errors = []
for invoice in invoices:
errors += invoice._l10n_mx_edi_cfdi_check_invoice_config()
if errors:
invoices._l10n_mx_edi_cfdi_global_invoice_document_sent_failed("\n".join(set(errors)))
return
# == Lock ==
self.env['res.company']._with_locked_records(invoices)
# == Send ==
def on_populate(cfdi_values):
invoices_per_error = defaultdict(lambda: self.env['account.move'])
inv_cfdi_values_list = []
for invoice in invoices:
# The refund are managed by the invoice.
if invoice.reversed_entry_id:
continue
inv_cfdi_values = dict(cfdi_values)
invoice._l10n_mx_edi_add_invoice_cfdi_values(inv_cfdi_values, global_invoice=True)
inv_errors = inv_cfdi_values.get('errors')
if inv_errors:
for error in inv_cfdi_values['errors']:
# The invoice is empty. Skip it.
if error == 'empty_cfdi':
break
invoices_per_error[error] |= invoice
else:
inv_cfdi_values_list.append(inv_cfdi_values)
if invoices_per_error:
errors = []
for error, invoices_in_error in invoices_per_error.items():
invoices_str = ",".join(invoices_in_error.mapped('name'))
errors.append(_("On %s: %s", invoices_str, error))
cfdi_values['errors'] = errors
return
# The global invoice is empty.
if not inv_cfdi_values_list:
cfdi_values['errors'] = ['empty_cfdi']
return
cfdi_values.update(
**self.env['l10n_mx_edi.document']._get_global_invoice_cfdi_values(
inv_cfdi_values_list,
cfdi_date,
periodicity=periodicity,
origin=origin,
)
)
self.env['res.company']._with_locked_records(cfdi_values['sequence'])
return cfdi_values['sequence']
def on_failure(error, cfdi_filename=None, cfdi_str=None):
if error == 'empty_cfdi':
self._l10n_mx_edi_cfdi_global_invoice_document_empty()
else:
self._l10n_mx_edi_cfdi_global_invoice_document_sent_failed(error, cfdi_filename=cfdi_filename, cfdi_str=cfdi_str)
def on_success(cfdi_values, cfdi_filename, cfdi_str, populate_return=None):
# Consume the next sequence number.
self.env['l10n_mx_edi.document']._consume_global_invoice_cfdi_sequence(populate_return, int(cfdi_values['folio']))
# Create the document.
document = self._l10n_mx_edi_cfdi_global_invoice_document_sent(cfdi_filename, cfdi_str)
# Chatters.
for invoice in self:
invoice \
.with_context(no_new_invoice=True) \
.message_post(
body=_("The Global CFDI document was successfully created and signed by the government."),
attachment_ids=document.attachment_id.ids,
)
qweb_template, _xsd_attachment_name = self.env['l10n_mx_edi.document']._get_invoice_cfdi_template()
cfdi_filename = f"{self.journal_id.code}-MX-Global-Invoice-4.0.xml".replace('/', '')
self.env['l10n_mx_edi.document']._send_api(
self.company_id,
qweb_template,
cfdi_filename,
on_populate,
on_failure,
on_success,
)
def _l10n_mx_edi_cfdi_global_invoice_post_cancel(self):
""" Cancel the current payment and drop a message in the chatter.
This method is only there to unify the flows since they are multiple
ways to cancel a payment:
- The user can request a cancellation from Odoo.
- The user can cancel the payment from the SAT, then update the SAT state in Odoo.
"""
for record in self:
record \
.with_context(no_new_invoice=True) \
.message_post(body=_("The Global CFDI document has been successfully cancelled."))
def _l10n_mx_edi_cfdi_global_invoice_try_cancel(self, document, cancel_reason):
""" Create a CFDI global invoice for multiple invoices.
:param document: The Global invoice document to cancel.
:param cancel_reason: The reason for the cancellation.
"""
# == Lock ==
self.env['res.company']._with_locked_records(self)
# == Cancel ==
def on_failure(error):
self._l10n_mx_edi_cfdi_global_invoice_document_cancel_failed(error, document, cancel_reason)
def on_success():
self._l10n_mx_edi_cfdi_global_invoice_document_cancel(document, cancel_reason)
self._l10n_mx_edi_cfdi_global_invoice_post_cancel()
document._cancel_api(self.company_id, cancel_reason, on_failure, on_success)
def _l10n_mx_edi_cfdi_global_invoice_update_document_sat_state(self, document, sat_state, error=None):
""" Update the SAT state of the document for the current global invoice.
:param document: The CFDI document to be updated.
:param sat_state: The newly fetched state from the SAT
:param error: In case of error, the message returned by the SAT.
"""
# The user manually cancelled the document in the SAT portal.
if document.state == 'ginvoice_sent' and sat_state == 'cancelled':
if document.sat_state not in ('valid', 'cancelled', 'skip'):
document.sat_state = 'skip'
document = self._l10n_mx_edi_cfdi_global_invoice_document_cancel(
document,
CANCELLATION_REASON_SELECTION[1][0], # Force '02'.
)
document.sat_state = sat_state
self._l10n_mx_edi_cfdi_global_invoice_post_cancel()
else:
document.sat_state = sat_state
document.message = None
if sat_state == 'error' and error:
document.message = error
self.invoice_ids._message_log_batch(bodies={invoice.id: error for invoice in self.invoice_ids})
def l10n_mx_edi_action_create_global_invoice(self):
""" Action to open the wizard allowing to create a global invoice CFDI document for the
selected invoices.
:return: An action to open the wizard.
"""
return {
'name': _("Create Global Invoice"),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'l10n_mx_edi.global_invoice.create',
'target': 'new',
'context': {'default_move_ids': [Command.set(self.ids)]},
}
def l10n_mx_edi_cfdi_try_sat(self):
self.ensure_one()
if self.is_invoice():
documents = self.l10n_mx_edi_invoice_document_ids
elif self._l10n_mx_edi_is_cfdi_payment():
documents = self.l10n_mx_edi_payment_document_ids
else:
return
for document in documents.filtered_domain(documents._get_update_sat_status_domain(from_cron=False)):
document._update_sat_state()
# -------------------------------------------------------------------------
# CFDI: IMPORT
# -------------------------------------------------------------------------
@api.model
def _l10n_mx_edi_import_cfdi_get_tax_from_node(self, tax_node, line, is_withholding=False):
tax_type = CFDI_CODE_TO_TAX_TYPE.get(tax_node.attrib.get('Impuesto'))
tasa_o_cuota = tax_node.attrib.get('TasaOCuota')
if not tasa_o_cuota:
self.message_post(body=_("Tax ID %s can not be imported", tax_type))
return False
else:
amount = float(tasa_o_cuota) * (-100 if is_withholding else 100)
domain = [
*self.env['account.journal']._check_company_domain(line.company_id),
('amount', '=', amount),
('type_tax_use', '=', 'sale' if self.journal_id.type == 'sale' else 'purchase'),
('amount_type', '=', 'percent'),
]
if tax_type:
domain.append(('l10n_mx_tax_type', '=', tax_type))
taxes = self.env['account.tax'].search(domain, limit=2)
if len(taxes) != 1:
line.move_id.to_check = True
if not taxes:
msg = _('Could not retrieve the %s tax with rate %s%%.', tax_type, amount)
msg_wh = _('Could not retrieve the %s withholding tax with rate %s%%.', tax_type, amount)
line.move_id.message_post(body=msg_wh if is_withholding else msg)
return taxes[:1]
def _l10n_mx_edi_import_cfdi_fill_invoice_line(self, tree, line):
# Product
code = tree.attrib.get('NoIdentificacion') # default_code if export from Odoo
unspsc_code = tree.attrib.get('ClaveProdServ') # UNSPSC code
description = tree.attrib.get('Descripcion') # label of the invoice line "[{p.default_code}] {p.name}"
cleaned_name = re.sub(r"^\[.*\] ", "", description)
product = self.env['product.product']._retrieve_product(
name=cleaned_name,
default_code=code,
extra_domain=[('unspsc_code_id.code', '=', unspsc_code)],
company=self.company_id,
)
if not product:
product = self.env['product.product']._retrieve_product(name=cleaned_name, default_code=code)
line.product_id = product
# Taxes
tax_ids = []
for tax_node in tree.findall("{*}Impuestos/{*}Traslados/{*}Traslado"):
tax = self._l10n_mx_edi_import_cfdi_get_tax_from_node(tax_node, line)
if tax:
tax_ids.append(tax.id)
tax_type = CFDI_CODE_TO_TAX_TYPE.get(tax_node.attrib.get('Impuesto'))
tasa_o_cuota = tax_node.attrib.get('TasaOCuota')
tipo_factor = tax_node.attrib.get('TipoFactor')
if not tasa_o_cuota and tipo_factor != "Exento":
self.message_post(body=_("Tax ID %s can not be imported", tax_type))
else:
amount = float(tasa_o_cuota) * 100 if tipo_factor != "Exento" else 0
domain = [
*self.env['account.journal']._check_company_domain(line.company_id),
('amount', '=', amount),
('type_tax_use', '=', 'sale' if self.journal_id.type == 'sale' else 'purchase'),
('amount_type', '=', 'percent'),
]
tax_group = self.env.ref(f'account.{line.company_id.id}_tax_group_exe_0', raise_if_not_found=False)
if tax_group and tipo_factor == 'Exento':
domain.append(('tax_group_id', '=', tax_group.id))
if tax_type:
domain.append(('repartition_line_ids.tag_ids.name', '=', tax_type))
tax = self.env['account.tax'].search(domain, limit=1)
if not tax:
# try without again without using the tags: some are IVA but only have 'DIOT' tags
domain.pop()
tax = self.env['account.tax'].search(domain, limit=1)
if tax:
tax_ids.append(tax.id)
elif tax_type:
line.move_id.message_post(body=_("Could not retrieve the %s tax with rate %s%%.", tax_type, amount))
else:
line.move_id.message_post(body=_("Could not retrieve the tax with rate %s%%.", amount))
# Withholding Taxes
for wh_tax_node in tree.findall("{*}Impuestos/{*}Retenciones/{*}Retencion"):
wh_tax = self._l10n_mx_edi_import_cfdi_get_tax_from_node(wh_tax_node, line, is_withholding=True)
if wh_tax:
tax_ids.append(wh_tax.id)
# Discount
discount_percent = 0
discount_amount = float(tree.attrib.get('Descuento') or 0)
gross_price_subtotal_before_discount = float(tree.attrib.get('Importe'))
if not self.currency_id.is_zero(discount_amount):
discount_percent = (discount_amount/gross_price_subtotal_before_discount)*100
line.write({
'quantity': float(tree.attrib.get('Cantidad')),
'price_unit': float(tree.attrib.get('ValorUnitario')),
'discount': discount_percent,
'tax_ids': [Command.set(tax_ids)],
})
return True
def _l10n_mx_edi_import_cfdi_fill_partner(self, tree):
outgoing_invoice = self.journal_id.type == 'sale'
role = "Receptor" if outgoing_invoice else "Emisor"
partner_node = tree.find("{*}" + role)
rfc = partner_node.attrib.get('Rfc')
name = partner_node.attrib.get('Nombre')
partner = self.partner_id._retrieve_partner(
name=name,
vat=rfc,
company=self.company_id,
)
# create a partner if it's not found
if not partner:
is_foreign_partner = rfc == 'XEXX010101000'
partner_vals = {
'name': name,
'country_id': not is_foreign_partner and self.env.ref('base.mx').id,
}
if not (is_foreign_partner or rfc == 'XAXX010101000'):
partner_vals['vat'] = rfc
if outgoing_invoice:
zip_code = partner_node.attrib.get('DomicilioFiscalReceptor')
partner_vals['zip'] = zip_code
elif is_foreign_partner:
export_fiscal_position = self.company_id._l10n_mx_edi_get_foreign_customer_fiscal_position()
if export_fiscal_position:
partner_vals['property_account_position_id'] = export_fiscal_position.id
partner = self.env['res.partner'].create(partner_vals)
return partner
def _l10n_mx_edi_import_cfdi_fill_invoice(self, tree):
# Partner
cfdi_vals = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(etree.tostring(tree, encoding='utf-8'))
partner = self._l10n_mx_edi_import_cfdi_fill_partner(tree)
if not partner:
return
self.partner_id = partner
# Payment way
forma_pago = tree.attrib.get('FormaPago')
self.l10n_mx_edi_payment_method_id = self.env['l10n_mx_edi.payment.method'].search(
[('code', '=', forma_pago)], limit=1)
# Payment policy
self.l10n_mx_edi_payment_policy = tree.attrib.get('MetodoPago')
# Usage
usage = cfdi_vals['usage']
if usage in dict(self._fields['l10n_mx_edi_usage'].selection):
self.l10n_mx_edi_usage = usage
# Invoice date
date = cfdi_vals['stamp_date'] or cfdi_vals['emission_date_str']
if date:
self.invoice_date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S').date()
# Currency
currency_name = tree.attrib.get('Moneda')
currency = self.env['res.currency'].search([('name', '=', currency_name)], limit=1)
if currency:
self.currency_id = currency
# Fiscal folio
self.l10n_mx_edi_cfdi_uuid = cfdi_vals['uuid']
# Lines
for invl_el in tree.findall("{*}Conceptos/{*}Concepto"):
line = self.invoice_line_ids.create({'move_id': self.id, 'company_id': self.company_id.id})
self._l10n_mx_edi_import_cfdi_fill_invoice_line(invl_el, line)
return True
def _l10n_mx_edi_import_cfdi_invoice(self, invoice, file_data, new=False):
with invoice._get_edi_creation() as invoice:
invoice.ensure_one()
if invoice.l10n_mx_edi_cfdi_attachment_id:
# invoice is already associated with a CFDI document, do nothing
return False
tree = file_data['xml_tree']
# handle payments
if tree.findall('.//{*}Pagos'):
invoice.message_post(body=_("Importing a CFDI Payment is not supported."))
return
move_type = 'refund' if tree.attrib.get('TipoDeComprobante') == 'E' else 'invoice'
if invoice.journal_id.type == 'sale':
move_type = 'out_' + move_type
elif invoice.journal_id.type == 'purchase':
move_type = 'in_' + move_type
else:
return
invoice.move_type = move_type
if not invoice.invoice_line_ids:
# don't fill the invoice if it already has lines, simply give it the cfdi info
invoice._l10n_mx_edi_import_cfdi_fill_invoice(tree)
# create the document
self.env['l10n_mx_edi.document'].create({
'move_id': invoice.id,
'invoice_ids': [Command.set(invoice.ids)],
'state': 'invoice_sent' if invoice.is_sale_document() else 'invoice_received',
'sat_state': 'not_defined',
'attachment_id': file_data['attachment'].id,
'datetime': fields.Datetime.now(),
})
return True
def _get_edi_decoder(self, file_data, new=False):
# EXTENDS 'account'
if file_data.get('is_cfdi', False):
return self._l10n_mx_edi_import_cfdi_invoice
return super()._get_edi_decoder(file_data, new=new)