Cómo validar un CFDI recibido antes de deducirlo: XSD, sello, vigencia y lista negra 69-B, en Python — Cesar Ayala
← Todos los artículos

Cómo validar un CFDI recibido antes de deducirlo: XSD, sello, vigencia y lista negra 69-B, en Python

Antes de deducir un CFDI recibido, corre cuatro checks: el XML es válido contra el XSD de CFDI 4.0; el sello verifica como RSA-SHA256 sobre la cadena original (reconstruida con el XSLT del SAT) contra el certificado del emisor; el SAT devuelve Estado Vigente (no Cancelado) en ConsultaCFDIService; y el RFC del emisor no está Definitivo en la lista negra 69-B.

La respuesta corta: cuatro checks antes de confiar en un CFDI recibido

Antes de deducir un CFDI que recibiste de un proveedor, corre cuatro checks en orden y no confíes en el XML hasta que los cuatro pasen. Primero, que el XML sea válido contra el XSD de CFDI 4.0. Segundo, que el sello verifique como RSA-SHA256 sobre la cadena original contra el certificado del emisor. Tercero, que el SAT devuelva Estado Vigente (no Cancelado) en ConsultaCFDIService. Cuarto, que el RFC del emisor no esté Definitivo en la lista negra 69-B. Si cualquiera falla, no lo deduces: lo rechazas o lo retienes.

Esto es una guía de ingeniero, no de contador. Aquí aprendes cómo cablear los cuatro checks en Python. Las reglas fiscales vigentes, los efectos de deducibilidad y los plazos los confirmas contra el SAT.

Por qué validas lo que recibes: deducibilidad y riesgo de apócrifos

Cuando emites un CFDI, tú controlas la cadena original y el sello — ese es el flujo de salida que cubrí en el outbox para timbrar CFDI. Cuando recibes uno de un proveedor, el problema es el opuesto: no controlas nada del comprobante, pero tu deducción depende de que sea real. Es “confía pero verifica” aplicado a cuentas por pagar.

Un CFDI recibido puede fallarte de cuatro maneras: el XML no cumple el esquema (mal formado o adulterado), el sello no corresponde al certificado del emisor (apócrifo), el comprobante ya fue Cancelado por el emisor después de emitirlo, o el emisor está en la lista negra 69-B por facturar operaciones simuladas. Cualquiera de las cuatro tumba tu deducción — y la 69-B, en particular, es la que el SAT usa para desconocer deducciones de forma retroactiva.

Check 1 — XSDXML válido contra el esquema CFDI 4.0
Check 2 — SelloRSA-SHA256 sobre la cadena original vs certificado del emisor
Check 3 — VigenciaEstado Vigente en ConsultaCFDIService
Check 4 — 69-BRFC del emisor no Definitivo en la lista negra

Check 1 — el XML es válido contra el XSD de CFDI 4.0

El primer filtro es barato y descarta basura antes de que gastes crypto o llamadas de red: valida el XML contra el XSD de CFDI 4.0. Esto atrapa comprobantes mal formados, nodos fuera de esquema y adulteraciones groseras. Es una compuerta binaria: pasa o no pasa.

from lxml import etree

def validate_xsd(xml_bytes: bytes, xsd_path: str):
    schema = etree.XMLSchema(etree.parse(xsd_path))
    doc = etree.fromstring(xml_bytes)          # falla si no está bien formado
    schema.assertValid(doc)                    # falla si no cumple el esquema
    return doc

El XSD de CFDI 4.0 (y los XSD de cada complemento incluido, como Timbre Fiscal Digital) los tomas del propio SAT; no los inventes ni los caches de una fuente cualquiera. Si el emisor incluyó un complemento — Pagos, Nómina, Carta Porte — cada uno trae su propio esquema que también debe validar. Este check no dice nada sobre autenticidad; solo dice que la forma es correcta. La autenticidad viene en el check 2.

Check 2 — el sello: reconstruye la cadena original con el XSLT del SAT y verifica RSA-SHA256 contra el certificado del emisor (usa una librería, no lo hagas a mano)

El Sello del comprobante es la firma del CSD del emisor: base64 de una firma RSA sobre el hash SHA-256 de la cadena original — esa cadena delimitada por pipes que produce el XSLT oficial del SAT al transformar el XML. Verificarlo tiene tres pasos: reconstruir la cadena original con el XSLT del SAT, hashearla, y verificar el Sello con RSA-SHA256 contra el certificado del emisor (el Certificado embebido en el XML, identificado por su NoCertificado).

import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import load_der_x509_certificate
from satcfdi.cfdi import CFDI

def verify_sello(cfdi: CFDI) -> None:
    # 1. la librería reconstruye la cadena original con el XSLT del SAT (NO lo hagas a mano)
    cadena = cfdi.cadena_original().encode("utf-8")
    # 2. el Certificado del emisor (DER en base64) viene en el XML, por su NoCertificado
    cert = load_der_x509_certificate(base64.b64decode(cfdi["Certificado"]))
    sello = base64.b64decode(cfdi["Sello"])
    # 3. verifica RSA-SHA256 del sello contra la llave pública del emisor; levanta si no coincide
    cert.public_key().verify(sello, cadena, padding.PKCS1v15(), hashes.SHA256())

La tentación de ingeniero es reimplementar el XSLT tú mismo. No lo hagas: el XSLT del SAT tiene reglas sutiles de espaciado, escapado y concatenación de nodos, y un solo carácter distinto en la cadena original hace que el hash no coincida y el sello parezca inválido cuando en realidad tu transformación está mal. Por eso cadena_original() la deja la librería — SAT-CFDI/python-satcfdi en Python, o phpcfdi/cfdiutils si estás en PHP. La misma crypto que verificas aquí, del lado receptor, es la que aplicas del lado emisor al timbrar con outbox e idempotencia: mismo motor, dirección opuesta.

  1. Reconstruye la cadena originalAplica el XSLT oficial del SAT al XML para obtener la cadena delimitada por pipes.
  2. Hashea SHA-256El hash de la cadena original es lo que se firmó.
  3. Verifica RSA-SHA256Comprueba el Sello base64 contra el Certificado del emisor (por su NoCertificado).
  4. Rechaza si no coincideSello inválido = comprobante apócrifo o adulterado. No lo deduces.

Check 3 — vigencia ante el SAT con ConsultaCFDIService (re/rr/tt/id, y la respuesta Estado Vigente vs Cancelado)

Un sello válido prueba que el emisor firmó el comprobante — pero el emisor puede cancelarlo después de emitirlo. Para saber si sigue vivo, consultas al SAT directamente con el servicio ConsultaCFDIService. El endpoint SOAP es https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc y toma cuatro valores del propio CFDI: RFC emisor (re), RFC receptor (rr), total (tt) y UUID (id), a menudo expresados como la “expresión impresa” ?re=...&rr=...&tt=...&id=....

import requests

WSDL_ENV = """<soapenv:Envelope
  xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:tem="http://tempuri.org/">
  <soapenv:Body>
    <tem:Consulta>
      <tem:expresionImpresa>?re={re}&amp;rr={rr}&amp;tt={tt}&amp;id={id}</tem:expresionImpresa>
    </tem:Consulta>
  </soapenv:Body>
</soapenv:Envelope>"""

def consulta_vigencia(re, rr, tt, uuid):
    body = WSDL_ENV.format(re=re, rr=rr, tt=tt, id=uuid)
    r = requests.post(
        "https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc",
        data=body.encode("utf-8"),
        headers={
            "Content-Type": "text/xml; charset=utf-8",
            "SOAPAction": "http://tempuri.org/IConsultaCFDIService/Consulta",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.text  # CodigoEstatus, Estado, EsCancelable, EstatusCancelacion

La respuesta incluye CodigoEstatus (S = obtenido satisfactoriamente), el campo clave Estado (Vigente o Cancelado), además de EsCancelable y EstatusCancelacion. La regla de compuerta es simple: solo Estado igual a Vigente con CodigoEstatus satisfactorio pasa el check. Un Cancelado significa que el emisor ya lo dio de baja y no lo puedes deducir, sin importar que el sello sea perfecto. En producción me apoyo en phpcfdi/sat-estado-cfdi, que envuelve este mismo servicio y normaliza la respuesta para que no parsees SOAP crudo a mano. Esto es análogo a validar el estado de un RFC, tema que desmenuzo en validar un RFC sin la Constancia de Situación Fiscal.

La causa #1 de que la vigencia falle en silencio: el total se compara como string, no como número

Aquí está el error que hace que el check 3 falle en silencio y te vuelva loco depurando: el web service compara el total como STRING, no como número. Si el CFDI trae 15230.00 y tú envías 15230 o 15230.0, la consulta falla — no porque el comprobante esté mal, sino porque tu total no coincide carácter por carácter con el impreso.

# MAL: normalizas el total a número y pierdes los decimales exactos
tt = str(float(cfdi_total))      # 15230.00 -> "15230.0"  -> la consulta FALLA
tt = f"{cfdi_total:.0f}"         # 15230.00 -> "15230"     -> la consulta FALLA

# BIEN: envía el total EXACTAMENTE como viene impreso en el XML
tt = cfdi.get("Total")           # "15230.00"  -> coincide, la consulta pasa

Envía el total exactamente como está impreso, con sus decimales exactos (normalmente 2, pero respeta lo que traiga el atributo Total del XML). No lo parsees a float, no lo redondees, no le quites ceros. Toma el string literal del atributo Total del comprobante y pásalo tal cual. Esta es la razón número uno de que las consultas de vigencia fallen sin un mensaje de error claro: el SAT no te dice “el total no coincide”, simplemente no encuentra el comprobante.

Número (rompe la consulta)

  • float(total) -> 15230.0
  • redondear -> 15230
  • quitar ceros a la derecha
  • el SAT no encuentra el CFDI

String exacto (funciona)

  • cfdi.Total -> 15230.00
  • decimales exactos del XML
  • carácter por carácter
  • el SAT devuelve Estado

Check 4 — filtra al emisor contra la lista negra 69-B (descarga el Listado Completo, cruza el RFC, lee la situación)

El sello es válido y el CFDI está Vigente — pero si el emisor vende facturas de operaciones simuladas, tu deducción no vale. El SAT publica el Listado Completo 69-B como datos abiertos (CSV/Excel) en Datos Abiertos SAT, en omawww.sat.gob.mx/cifras_sat/Paginas/datos/vinculo.html?page=ListCompleta69.html. Descargas el listado, cruzas el RFC del emisor y lees su situación.

import csv

def load_69b(csv_path: str) -> dict:
    situacion = {}
    with open(csv_path, newline="", encoding="latin-1") as f:
        for row in csv.DictReader(f):
            rfc = row["RFC"].strip().upper()
            situacion[rfc] = row["Situacion del contribuyente"].strip()
    return situacion

def screen_emisor(rfc_emisor: str, listado: dict) -> str:
    # devuelve la situación si el emisor aparece, o "No listado" si no está
    return listado.get(rfc_emisor.strip().upper()) or "No listado"

En la 69-B, EFOS es la Empresa que Factura Operaciones Simuladas (el emisor que presuntamente vende facturas falsas) y EDOS es la Empresa que Deduce Operaciones Simuladas (quien las dedujo). Cada fila trae una Situacion del contribuyente que puede ser Presunto, Desvirtuado, Definitivo o Sentencia Favorable, con su oficio y fechas de DOF/publicación. Lee esa columna, no solo si el RFC aparece: un emisor Desvirtuado o con Sentencia Favorable se limpió; el que te importa es el Definitivo.

Por qué un emisor Definitivo mata tu deducción — y por qué refrescas la lista, nunca la fijas como snapshot

Un CFDI de un emisor en situación Definitivo no es deducible — esa es la fila que te hace daño. Presunto es una alerta que puede revertirse; Desvirtuado y Sentencia Favorable significan que el contribuyente se limpió; pero Definitivo es la determinación firme del SAT de que ese emisor factura operaciones simuladas, y las deducciones que sustentaste con sus CFDI quedan desconocidas.

def deduccion_bloqueada(situacion: str) -> bool:
    # solo Definitivo bloquea la deducción; los demás estados no
    return situacion == "Definitivo"

El listado se actualiza aproximadamente cada trimestre (a veces más seguido), y un emisor limpio hoy puede aparecer como Definitivo en la siguiente publicación. Por eso descargas y refrescas la lista en un cron; nunca la fijes como un snapshot hardcodeado. Si cacheas la 69-B de hace seis meses, vas a deducir de un emisor que ya cayó como Definitivo y no te vas a enterar hasta la auditoría. Trátala como los otros datos que jalas del SAT en un calendario, igual que la descarga masiva de CFDI por e.firma.

Presuntoalerta
Desvirtuadook
Sentencia Favorableok
Definitivobloquea

Cablea todo en un gate de cuentas por pagar: rechaza o retén ante cualquier check fallido

Los cuatro checks no viven sueltos: los cableas en una sola compuerta de cuentas por pagar que decide si un CFDI recibido puede pagarse y deducirse. La regla es de cortocircuito — el primer check que falle detiene el flujo, y el comprobante se rechaza (apócrifo, cancelado) o se retiene para revisión humana (emisor en la lista).

def ap_gate(xml_bytes: bytes, xsd_path: str, listado_69b: dict) -> dict:
    doc = validate_xsd(xml_bytes, xsd_path)              # 1  levanta -> rechazado
    cfdi = CFDI.from_bytes(xml_bytes)

    verify_sello(cfdi)                                   # 2  levanta -> rechazado (apócrifo)

    # 3. vigencia (total como STRING exacto, sin float)
    resp = consulta_vigencia(
        cfdi["Emisor"]["Rfc"],
        cfdi["Receptor"]["Rfc"],
        doc.get("Total"),                                # "15230.00"
        cfdi.uuid,
    )
    if "Cancelado" in resp:
        return {"decision": "rechazar", "motivo": "Estado=Cancelado en el SAT"}

    # 4. 69-B
    situacion = screen_emisor(cfdi["Emisor"]["Rfc"], listado_69b)
    if deduccion_bloqueada(situacion):
        return {"decision": "rechazar", "motivo": "emisor 69-B Definitivo"}
    if situacion == "Presunto":
        return {"decision": "retener", "motivo": "emisor 69-B Presunto"}

    return {"decision": "pagar", "uuid": cfdi.uuid}

Este gate es determinista y auditable: cada decisión guarda el motivo, así que cuando el SAT te pregunte por qué dedujiste un comprobante, tienes la traza. Si quieres poner un LLM a leer el XML, clasificar el gasto y explicar el rechazo en lenguaje natural, ese es el paso siguiente que cubro en automatizar cuentas por pagar en México con Claude — pero la decisión de deducibilidad la toman estos cuatro checks, no el modelo.

Ingeniero, no contador: qué confirmar contra el SAT antes de producción

Yo soy ingeniero y sí he puesto esto en producción contra el SAT — no soy tu contador ni tu abogado. Este post te da el patrón: los cuatro checks, el XSLT vía librería, el total como string y el refresco de la 69-B. Lo que confirmas contra el SAT antes de producción son los efectos fiscales: qué situación exacta de la 69-B desconoce cuáles deducciones y con qué retroactividad, cómo tratar un Presunto frente a un Definitivo en tu política de pagos, y los plazos y reglas vigentes de cancelación que afectan la vigencia.

Los endpoints y los valores de estatus (Vigente, Cancelado, S, las situaciones de la 69-B) cámbialos solo si el SAT los cambia — no confíes en tu memoria ni en la mía. Para el ecosistema CFDI completo del que cuelga este flujo de entrada, arranca en el hub de facturación CFDI, que reúne los flujos de salida que validan lo mismo en la dirección opuesta.

Fuentes y referencias

Verifica siempre contra las fuentes oficiales vigentes, no contra este post:

Los cuatro checks — XSD, sello, vigencia y 69-B — son la diferencia entre deducir un CFDI real y sostener una deducción sobre un comprobante apócrifo o cancelado. Automatízalos en tu gate de cuentas por pagar, refresca la 69-B en un cron y confirma los efectos fiscales con el SAT; el resto es plomería.