Automatiza cuentas por pagar en México: extrae facturas con Claude, concilia contra el CFDI recibido y monitorea la 69-B (Python, 2026) — Cesar Ayala
← Todos los artículos

Automatiza cuentas por pagar en México: extrae facturas con Claude, concilia contra el CFDI recibido y monitorea la 69-B (Python, 2026)

En cuentas por pagar en México, algunos documentos son CFDI XML válido que validas contra el SAT; muchos llegan como PDFs, tickets o fotos no-CFDI. Usa Structured Outputs de Claude para extraer campos tipados y luego concilia en código — no en el LLM — cotejando UUID, RFC emisor, RFC receptor y total exacto contra el CFDI recibido, con un monitor 69-B programado para el riesgo retroactivo.

Automatiza cuentas por pagar en México: la respuesta corta

En cuentas por pagar en México algunos documentos son un CFDI XML válido que puedes validar contra el SAT; muchos otros llegan como PDFs, tickets o fotos que no son CFDI. La arquitectura que sí aguanta producción es esta: usa Structured Outputs de Claude para extraer campos tipados de los documentos sucios, y luego concilia en código — no en el LLM — cotejando UUID, RFC emisor, RFC receptor y el total exacto contra el CFDI recibido. Encima de eso corre un monitor 69-B programado que atrapa el riesgo retroactivo: un proveedor que ya contabilizaste y de pronto aparece como Definitivo. El LLM extrae; tu código decide.

Aviso de una vez: soy ingeniero, no tu contador ni tu abogado. Esto es arquitectura de software. Los umbrales fiscales, las consecuencias de un apócrifo y qué puedes o no deducir los confirmas contra sat.gob.mx y con tu contador antes de contabilizar nada.

La realidad de cuentas por pagar en México: algunos pagos son CFDI XML limpio, la mayoría llega como PDFs, tickets y fotos no-CFDI

Si automatizas AP contra el mundo real, el primer error es asumir que todo lo que entra es un CFDI. No lo es. Tu buzón de proveedores tiene dos poblaciones muy distintas:

  • CFDI XML de proveedores mexicanos formales. Aquí tienes el comprobante autoritativo: UUID, sellos, RFC emisor y receptor, totales. A esto le aplicas la validación criptográfica completa.
  • No-CFDI: la factura de un proveedor extranjero en PDF, el ticket escaneado del taxi, la foto de un recibo de mano, el PDF “bonito” que alguien exportó sin adjuntar el XML. No tienen sello ni UUID válido. No los puedes validar contra el SAT porque no son comprobantes fiscales.

Un pipeline de AP serio necesita una sola tubería que haga dos cosas: (a) extraiga datos estructurados de los documentos sucios y (b) los concilie contra el XML autoritativo del CFDI cuando exista. El detalle criptográfico de validar el CFDI XML —sello, cadena original, vigencia, 69-B— vive en el post hermano de validación de CFDI recibidos. Aquí lo resumo y me concentro en la parte que ese post no cubre: qué haces con los PDFs y fotos que no son CFDI, y cómo los amarras al comprobante real.

CFDI XML (autoritativo)

  • Tiene UUID, sello y cadena original
  • Se valida contra el SAT (ConsultaCFDIService)
  • RFC emisor/receptor y total confiables
  • Es lo que contabilizas y deduces

No-CFDI (PDF, ticket, foto)

  • Factura extranjera, recibo, ticket escaneado
  • No tiene sello ni UUID validable
  • Datos hay que EXTRAERLOS del documento
  • Se concilia contra el CFDI, no lo reemplaza

Extrae con Structured Outputs de Claude: JSON garantizado por esquema, sin regex frágil

Para los documentos sucios, el regex y las plantillas por proveedor son una trampa: cada emisor cambia el layout y tu parser truena. La alternativa robusta es pasarle el PDF o la imagen a Claude y forzar un esquema JSON con Structured Outputs, para que la salida siempre tenga la forma que esperas. El campo canónico de la API cruda es output_config.format con type: json_schema; el detalle completo está en el post de Structured Outputs en producción.

import anthropic, base64

client = anthropic.Anthropic()

AP_SCHEMA = {
    "type": "object",
    "properties": {
        "rfc_emisor":  {"type": "string"},
        "rfc_receptor":{"type": "string"},
        "uuid":        {"type": ["string", "null"]},
        "subtotal":    {"type": "string"},
        "iva":         {"type": "string"},
        "total":       {"type": "string"},
        "fecha":       {"type": "string"},
        "es_cfdi":     {"type": "boolean"},
        "conceptos":   {"type": "array", "items": {"type": "string"}},
    },
    "required": ["rfc_emisor", "rfc_receptor", "total", "es_cfdi"],
    "additionalProperties": False,
}

def extraer(pdf_bytes: bytes) -> dict:
    b64 = base64.standard_b64encode(pdf_bytes).decode()
    resp = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=1024,
        output_config={"format": {"type": "json_schema", "schema": AP_SCHEMA}},
        messages=[{
            "role": "user",
            "content": [
                {"type": "document",
                 "source": {"type": "base64",
                            "media_type": "application/pdf",
                            "data": b64}},
                {"type": "text",
                 "text": "Extrae los campos de este documento de cuentas por pagar. "
                         "Si no es un CFDI, pon uuid en null y es_cfdi en false."},
            ],
        }],
    )
    import json
    return json.loads(resp.content[0].text)

Dos decisiones deliberadas en ese esquema. Primero, total, subtotal e iva van como string, no como number: cuando después compares el total extraído contra el Total del CFDI XML esa comparación es como cadena con 2 decimales exactos, y convertir a float te mete errores de punto flotante que la rompen (ese detalle está en el post hermano). Segundo, uuid es nullable y es_cfdi es booleano: así el modelo te dice honestamente cuándo un documento no trae UUID porque no es un CFDI.

Structured Outputs garantiza la forma, no la verdad. El JSON siempre cumplirá el esquema, pero el RFC extraído puede estar mal leído o el total puede no cuadrar con nada. Por eso la conciliación es un paso aparte, en código.

Un detalle de higiene antes de mandar nada al modelo: si el documento trae PII mexicana —CURP, CLABE, un RFC de persona física— y tu política lo exige, ocúltala antes de la llamada. El cómo está en ocultar PII mexicana antes del LLM.

Concilia en código, no en el LLM: cruza UUID, RFC emisor, RFC receptor y total exacto contra el XML del CFDI recibido

Aquí está el principio que separa un juguete de un sistema de AP: el LLM extrae, tu código decide. La conciliación es determinista. No le preguntas a Claude “¿este PDF cuadra con esta factura?” — eso lo resuelves con comparaciones exactas contra el XML del CFDI recibido, que es tu fuente autoritativa.

Las llaves duras son cuatro: UUID, RFC emisor, RFC receptor y total. El total se compara como string con 2 decimales, nunca como float.

from decimal import Decimal

def total_str(x: str) -> str:
    # normaliza a 2 decimales exactos, comparación como cadena
    return str(Decimal(x).quantize(Decimal("0.01")))

def conciliar(extraido: dict, cfdi_xml: dict) -> dict:
    razones = []
    if extraido.get("uuid") != cfdi_xml["uuid"]:
        razones.append("uuid_no_coincide")
    if extraido["rfc_emisor"] != cfdi_xml["rfc_emisor"]:
        razones.append("rfc_emisor_no_coincide")
    if extraido["rfc_receptor"] != cfdi_xml["rfc_receptor"]:
        razones.append("rfc_receptor_no_coincide")
    if total_str(extraido["total"]) != total_str(cfdi_xml["total"]):
        razones.append("total_desviado")
    return {"ok": not razones, "razones": razones}

El XML del CFDI recibido no lo inventas: lo obtienes por Descarga Masiva del SAT, un flujo aparte que documento en Descarga Masiva de CFDI con e.firma y sus límites de EnProceso. Sobre ese acervo de XML autoritativos corren tus comparaciones. Si el PDF trae un UUID que sí existe en tu descarga y los cuatro campos cuadran, lo amarras. Si no cuadra, se detiene.

  1. Extrae con ClaudeStructured Outputs devuelve JSON tipado del PDF/foto
  2. Busca el CFDI por UUIDEn tu acervo de XML descargados del SAT
  3. Cruza las 4 llavesUUID + RFC emisor + RFC receptor + total (string, 2 decimales)
  4. Marca o amarraCuadra = candidato a contabilizar; no cuadra = a revisión

Marca lo que falla: apócrifos, UUID duplicados y totales que se desvían del XML

La conciliación no solo confirma lo bueno; su verdadero valor es detectar lo malo antes de que entre a tu contabilidad. Tres patrones de fraude o error salen gratis de este diseño:

  • Apócrifos: un PDF cuyos números no coinciden con ningún CFDI real de tu acervo. Alguien te mandó un documento con aspecto de factura, pero no existe el comprobante fiscal detrás. Si tras buscar por UUID (o por combinación RFC emisor + total + fecha) no aparece ningún XML que cuadre, es candidato a apócrifo y no se contabiliza.
  • UUID duplicados: el mismo UUID te llega dos veces —el proveedor reenvió, o alguien intenta cobrar dos veces la misma factura—. Un índice único sobre UUID en tu tabla de cuentas por pagar lo bloquea en la escritura.
  • Totales que se desvían del XML: el PDF dice $11,600.00 pero el CFDI recibido dice $11,594.00. Puede ser un error de captura o un intento de inflar el pago. La comparación string a 2 decimales lo atrapa sin ambigüedad.
def clasificar(extraido, buscar_cfdi_por_uuid, uuid_ya_visto):
    uuid = extraido.get("uuid")
    if not extraido["es_cfdi"] or uuid is None:
        return "NO_CFDI"                 # a conciliar por RFC+total+fecha
    if uuid_ya_visto(uuid):
        return "DUPLICADO"               # bloquea: índice único sobre UUID
    cfdi = buscar_cfdi_por_uuid(uuid)
    if cfdi is None:
        return "APOCRIFO_SOSPECHOSO"     # ningún XML real cuadra
    r = conciliar(extraido, cfdi)
    return "CONCILIADO" if r["ok"] else "DESVIADO"

Ninguna de estas decisiones la toma el modelo. Son reglas deterministas sobre datos que Claude ya estructuró. Esa separación es lo que te deja auditar por qué el sistema retuvo un pago.

El filtro de vigencia + 69-B: reutiliza ConsultaCFDIService y el Listado Completo antes de contabilizar

Que el PDF concilie con un CFDI real no es suficiente para contabilizar. Un comprobante puede estar bien conciliado y aun así ser fiscalmente riesgoso por dos motivos: que el CFDI ya no esté vigente ante el SAT, o que el emisor esté en la lista 69-B. Este es el resumen; el detalle criptográfico completo —cadena original, verificación de sello, el endpoint exacto— está en el post hermano de validación de CFDI recibidos.

En corto, reutilizas dos piezas de ese post:

  • ConsultaCFDIService, el web service del SAT que te dice el estatus del comprobante. Ojo con el formato del total en la consulta: va como cadena con el formato del Anexo 20, que omite ceros no significativos (por ejemplo 0.99, 1.0 o 2010.01), no como un dos-decimales fijo. La regla estricta de 2 decimales exactos como string es para tu propia conciliación del XML contra lo extraído, no para este parámetro.
  • El Listado Completo 69-B (los datos abiertos del SAT), donde aparecen los contribuyentes con operaciones presuntamente inexistentes. Sus estatus son Presunto, Desvirtuado, Definitivo y Sentencia Favorable. Antes de contabilizar, cruzas el RFC emisor contra ese listado.

La regla operativa: si el emisor aparece como Definitivo en el 69-B, retienes — no contabilizas ni pagas hasta revisión. Desvirtuado y Sentencia Favorable son señales de que el proveedor se defendió; Presunto es una bandera amarilla que tu contador debe evaluar. No inventes campos ni estatus fuera de esos cuatro: son los que publica el SAT.

El monitor 69-B programado: un cron rebaja la lista, la compara con la corrida anterior y alerta cuando un proveedor ya contabilizado pasa a Definitivo

Esta es la pieza que las herramientas comerciales te cobran caro, y es apenas un cron. El problema que resuelve es el riesgo retroactivo: validaste al proveedor el mes pasado, todo limpio, contabilizaste y pagaste. Este mes ese RFC aparece como Definitivo en el 69-B. La factura que ya dedujiste ahora es un problema, y nadie te va a avisar salvo tú mismo.

La solución es un job programado que baje el Listado Completo 69-B, lo compare (diff) contra la corrida anterior y alerte cuando un RFC que ya está en tus cuentas contabilizadas cambia a Definitivo.

def monitor_69b(descargar_listado, cargar_snapshot_previo,
                rfcs_ya_contabilizados, alertar):
    actual  = descargar_listado()          # {rfc: estatus} del CSV de datos abiertos
    previo  = cargar_snapshot_previo()      # la corrida anterior

    for rfc in rfcs_ya_contabilizados():
        antes  = previo.get(rfc)
        ahora  = actual.get(rfc)
        if ahora == "Definitivo" and antes != "Definitivo":
            alertar(rfc, antes, ahora)      # riesgo retroactivo: ya lo dedujiste

    guardar_snapshot(actual)                # se vuelve el "previo" de la próxima corrida
# corre a diario, temprano; el CSV del SAT se actualiza periódicamente
0 6 * * *  /usr/bin/python3 /opt/ap/monitor_69b.py >> /var/log/ap/69b.log 2>&1

El patrón es un diff de estado con snapshots: guardas la foto de cada corrida y comparas contra la anterior. Solo alertas en la transición hacia Definitivo de un RFC que te importa, no en cada corrida, para no ahogar a tu contador en ruido. Sobre webhooks vs polling vs cron para este tipo de vigilancia, escribí el trade-off en webhooks vs polling vs API; aquí el 69-B es un CSV que rebajas por polling, no hay webhook del SAT.

Extracción (Claude)Estructura el PDF/foto
Conciliación (código)UUID + RFC + total exacto
Vigencia + 69-BEstatus ante el SAT
Monitor 69-B (cron)Riesgo retroactivo

Una arquitectura limpia: extraer, validar, conciliar, contabilizar o retener

Junta todo y el pipeline es una máquina de estados con cuatro etapas y una decisión al final. Cada etapa tiene una responsabilidad y ninguna invade a la otra: el modelo estructura, el código decide, el SAT es la autoridad.

Extraer (Claude)
Validar (sello/vigencia/69-B)
Conciliar (UUID/RFC/total)
Contabilizar o Retener

En código, la decisión final es aburrida a propósito —así debe ser—:

def procesar_documento(doc_bytes, deps):
    extraido = extraer(doc_bytes)                      # Claude: estructura
    estado   = clasificar(extraido, deps.buscar, deps.visto)
    if estado != "CONCILIADO":
        return retener(extraido, motivo=estado)        # a revisión humana

    cfdi = deps.buscar(extraido["uuid"])
    if not deps.vigente(cfdi):                          # ConsultaCFDIService
        return retener(extraido, motivo="NO_VIGENTE")
    if deps.estatus_69b(cfdi["rfc_emisor"]) == "Definitivo":
        return retener(extraido, motivo="69B_DEFINITIVO")

    return contabilizar(cfdi)                           # y el cron sigue vigilando

Ese return retener(...) es la mitad valiosa del sistema: un documento que no cuadra no entra a tu contabilidad, y queda registrado por qué. Si además emites tus propios CFDI, el flujo de salida —timbrado, idempotencia, fallas del PAC— es el espejo de esto y lo cubro en facturación CFDI automática desde Stripe/Mercado Pago y en el outbox de timbrado con idempotencia. Todo el contexto fiscal de estos posts vive en el hub de facturación CFDI en México.

Ingeniero, no contador: qué decide este pipeline y qué sigue firmando tu contador

Seamos claros sobre la frontera. Este pipeline automatiza el trabajo mecánico que hoy hace alguien a mano: leer un PDF, teclear los campos, buscar el XML, comparar totales, revisar el 69-B una vez y olvidarlo. Todo eso lo hace determinista, auditable y a escala.

Lo que no hace, y no debe hacer: decidir la deducibilidad de un gasto, decidir el tratamiento fiscal de un apócrifo detectado, o firmar tu declaración. El sistema retiene y señala; un humano —tu contador— decide qué se contabiliza y cómo se corrige. El valor de la automatización no es reemplazar ese juicio, es que llegue a tu contador solo lo que de verdad necesita su criterio, ya conciliado y con el motivo de la retención escrito.

Y el recordatorio con el que abrí: soy ingeniero, no tu contador ni tu abogado. Las fuentes autoritativas son los datos abiertos y servicios del SAT para el 69-B, la vigencia y la Descarga Masiva, y la documentación de Structured Outputs de Claude para la extracción. Los estatus 69-B, los umbrales fiscales y las consecuencias legales confírmalos contra el SAT y con tu contador antes de contabilizar. El código estructura y concilia; la responsabilidad fiscal sigue siendo humana.