# -*- 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: |,,..., :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 " "|,,..., 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)