No canceles el CFDI — emite una nota de crédito: automatiza el CFDI de Egreso para reembolsos de Stripe y Mercado Pago — Cesar Ayala
← Todos los artículos

No canceles el CFDI — emite una nota de crédito: automatiza el CFDI de Egreso para reembolsos de Stripe y Mercado Pago

No. Para reembolsar un pago en México normalmente no cancelas el CFDI de ingreso original: emites un CFDI de Egreso (nota de crédito): TipoDeComprobante "E", CfdiRelacionados con TipoRelacion "01" apuntando al UUID original, UsoCFDI "G02", por el monto reembolsado más su IVA. Automatízalo desde el webhook de reembolso verificado, idempotente por el id del reembolso. Soy ingeniero, no contador: confirma las reglas vigentes del SAT.

Reembolsaste un pago — ¿cancelas el CFDI? (No.)

Reembolsaste un pago a un cliente en México. Tu primer instinto de ingeniero es borrar el rastro: “voy a cancelar el CFDI de ingreso original y listo”. No. Ese es el error más común que veo en código de pagos para clientes mexicanos, y arrastra problemas fiscales que no quieres heredar.

La cancelación es un flujo aparte, con sus propias reglas y límites de tiempo del SAT. El movimiento correcto cuando reembolsas casi siempre es otro: emites un CFDI de Egreso —lo que comúnmente llaman una nota de crédito— relacionado con el ingreso original. El Egreso deja el CFDI de ingreso en pie y revierte el monto, en lugar de fingir que la venta nunca ocurrió.

Un CFDI de Egreso reduce ingresos que ya reportaste en un CFDI de ingreso previo. Documenta devoluciones, descuentos o bonificaciones posteriores a la factura, y correcciones. Es la pieza fiscal que cierra el círculo de un reembolso.

Aquí está el ángulo que me importa: los blogs de contadores y PACs te explican qué es una nota de crédito. Este post te muestra cómo construirla automáticamente desde el webhook de reembolso de tu procesador. Operacional, primero en español, hecho por alguien que lo manda a producción.

Antes de seguir, lo honesto: soy ingeniero, no contador. La decisión de cancelar vs emitir un Egreso, los límites de tiempo y el tratamiento exacto del IVA son territorio de tu contador y del SAT. Todo lo que cito es correcto al 2026; confirma las reglas vigentes. Y si todavía no automatizas el CFDI de ingreso en la venta, empieza por ahí: este post es el CFDI de ingreso que este Egreso revierte, corriendo en la dirección inversa.

¿Qué es exactamente un CFDI de Egreso? (la nota de crédito, en campos)

Definámoslo en los campos exactos que tú, como ingeniero, vas a llenar. Estos son los que el resto del post referencia.

  • TipoDeComprobante = “E” (Egreso). Esto es lo que lo convierte en nota de crédito, frente a un ingreso que lleva “I”.
  • CfdiRelacionados referencia el CFDI de ingreso original por su UUID, con TipoRelacion = “01” (“Nota de crédito de los documentos relacionados”). Puedes relacionar múltiples UUIDs originales en un solo Egreso.
  • UsoCFDI (del receptor) = “G02” (Devoluciones, descuentos o bonificaciones).
  • Monto: la porción reembolsada más su IVA/impuestos proporcionales al original. Reembolso total = monto completo; reembolso parcial = solo la porción reembolsada.
  • Regla 2026: cuando TipoRelacion es “01” o “02”, los documentos relacionados no deben ser tipo “T” (Traslado), “P” (Pago) ni “N” (Nómina). Relaciona el ingreso “I”, no el complemento de pago.

Las claves vienen de la Guía de llenado del CFDI (Anexo 20) del SAT, y los PACs documentan el caso de nota de crédito —por ejemplo Facturapi y Facturama—. Me mantengo neutral de proveedor: cualquier PAC timbra un Egreso con estos mismos campos.

TipoDeComprobante"E" (Egreso / nota de crédito)
CfdiRelacionadosTipoRelacion "01" + el UUID del ingreso original
UsoCFDI"G02" — Devoluciones, descuentos o bonificaciones
MontoLa porción reembolsada + su IVA proporcional
No cancelesRelaciona un Egreso; deja el ingreso en pie
El CFDI de Egreso de un reembolso, en una caja. Las claves al 2026; confirma las reglas vigentes con tu contador y el Anexo 20.

Egreso vs cancelación: ¿cuándo aplica cada uno?

Estos son dos caminos fiscales distintos. Entender por qué el Egreso es el camino por defecto para un reembolso ayuda a no escribir el código equivocado.

El CFDI de Egreso / nota de crédito deja el ingreso original en el registro y revierte —total o parcialmente— el monto vía un documento relacionado. Es el camino normal para una devolución después de que el CFDI de venta ya existe.

Cancelar el CFDI original es un flujo aparte, limitado en el tiempo, con sus propias reglas del SAT. Borra el original en vez de revertirlo. Mecánica distinta, restricciones distintas.

¿Por qué el Egreso suele ser lo correcto para reembolsos? Porque el ingreso se reportó válidamente: lo estás reduciendo, no pretendiendo que la venta nunca pasó. Los reembolsos parciales, en particular, no pueden ser una cancelación — no puedes “des-vender” una tercera parte de algo.

El límite honesto: la elección cancelar-vs-Egreso, los plazos y el tratamiento exacto del IVA son territorio de tu contador y del SAT. Yo te muestro el camino de ingeniería, no hago la decisión fiscal. Confirma las reglas vigentes. (Los contracargos y el reembolso-antes-del-CFDI los vemos más abajo.)

CFDI de Egreso / nota de crédito

  • Deja el ingreso original en el registro
  • Revierte el monto, total o parcial
  • Documento relacionado (TipoRelacion "01")
  • El camino normal para una devolución
  • Único viable para reembolsos parciales

Cancelar el CFDI original

  • Borra el original en vez de revertirlo
  • Flujo aparte, limitado en el tiempo
  • Reglas y plazos propios del SAT
  • Mecánica y restricciones distintas
  • Decisión fiscal — pregunta a tu contador
Los dos caminos fiscales de un reembolso. El Egreso es el normal; la cancelación es otra historia — confirma con tu contador.

Atrapar el reembolso: el webhook (y verificar la firma primero)

Arranquemos la automatización. El disparador no es un redirect ni un reporte manual: es el evento de reembolso de tu procesador.

En Stripe es un evento charge.refunded o un objeto refund; en Mercado Pago es su notificación de refund/chargeback. Lo primero, sin excepción: verifica la firma del webhook antes de actuar. Nunca proceses un payload sin verificar — es la misma disciplina con la que timbras el ingreso. Si te falta esa pieza, este es el detalle: verifica el webhook de reembolso antes de actuar.

Del evento extraes lo que necesitas: el id del reembolso (tu llave de idempotencia), el id del pago/charge original, el monto reembolsado, y si es parcial o total.

# Ilustrativo (Stripe). Confirma nombres de campos con la doc de tu procesador.
import stripe

def handle_refund_webhook(payload: bytes, sig_header: str, secret: str):
    # 1) Verifica la firma ANTES de actuar — nunca confíes en el payload crudo
    event = stripe.Webhook.construct_event(payload, sig_header, secret)

    if event["type"] != "charge.refunded":
        return  # ignora lo que no sea reembolso

    charge = event["data"]["object"]
    # Simplificación: [-1] toma el reembolso más reciente del array.
    # En producción, escucha el evento refund.created (donde data.object ES
    # el refund) o casa el refund por su id — no asumas que es el último.
    refund = charge["refunds"]["data"][-1]

    return {
        "refund_id": refund["id"],          # llave de idempotencia
        "charge_id": charge["id"],          # para resolver el CFDI original
        "monto_reembolsado": refund["amount"] / 100,
        "es_total": charge["amount_refunded"] == charge["amount"],
    }

El patrón es portable: cambia el SDK por el de Mercado Pago y la lógica de resolver-UUID y construir-Egreso es la misma. Un evento de reembolso verificado lleva al siguiente paso — resolver qué CFDI de ingreso revierte.

Resolver el UUID original y construir el Egreso

Aquí está el núcleo. Busca el UUID del CFDI de ingreso original para ese pago —lo guardaste cuando timbraste la venta, por esto persistes el UUID contra el pago— y arma el payload del Egreso con las claves exactas.

# Ilustrativo. Confirma los nombres de campos con tu PAC + el Anexo 20 del SAT.
def construir_egreso(charge_id: str, monto_reembolsado: float,
                     base_reembolsada: float, iva_reembolsado: float):
    # 2) Resuelve el UUID del ingreso original que guardaste al timbrar la venta
    ingreso = db.cfdi.find_by_payment(charge_id)  # tu tabla pago→UUID
    uuid_original = ingreso["uuid"]

    # 3) Arma el CFDI de Egreso con las claves exactas
    return {
        "TipoDeComprobante": "E",                 # Egreso / nota de crédito
        "CfdiRelacionados": {
            "TipoRelacion": "01",                 # nota de crédito de los relacionados
            "CfdiRelacionado": [uuid_original],   # puedes relacionar varios UUIDs
        },
        "Receptor": {"UsoCFDI": "G02"},           # devoluciones, descuentos o bonif.
        "Conceptos": [{
            "Importe": monto_reembolsado,         # solo la porción reembolsada
            "Impuestos": {"Traslados": [{
                "Base": base_reembolsada,         # base de la porción reembolsada
                "Impuesto": "002",                # IVA
                "TipoFactor": "Tasa",
                "TasaOCuota": "0.160000",         # confirma la tasa con tu contador
                "Importe": iva_reembolsado,       # IVA proporcional al original
            }]},
        }],
    }

def emitir_egreso(charge_id, monto, base, iva, refund_id):
    payload = construir_egreso(charge_id, monto, base, iva)
    # 4) Timbra vía tu PAC — misma plomería que el ingreso
    timbrado = pac.timbrar(payload)            # regresa XML, PDF y UUID del Egreso
    # 5) Guarda el UUID del Egreso contra el pago, el refund y el CFDI original
    db.egresos.save(refund_id=refund_id, uuid=timbrado["uuid"], charge_id=charge_id)
    return timbrado["uuid"]

Timbrar el Egreso es la misma plomería que el ingreso: mandas el payload, recibes XML timbrado, PDF y el UUID del Egreso. Guarda ese UUID contra el pago y contra el CFDI original, para que la conciliación y los futuros parciales lo encuentren. Me mantengo neutral del PAC — pac.timbrar es lo que tú ya tienes cableado. (Nota: el nodo Traslados lleva Base, TipoFactor y TasaOCuota obligatorios en el Anexo 20; confirma la tasa de IVA aplicable con tu contador.)

Reembolsos parciales, idempotencia y no emitir doble

Aquí está lo que separa una guía de ingeniero de un post de contador: los detalles operativos.

Reembolsos parciales. Emites un Egreso por solo la porción reembolsada. Puedes emitir múltiples Egresos contra un mismo ingreso a lo largo del tiempo, hasta el total original. Lleva el conteo del total revertido y nunca lo excedas. Y replica el IVA proporcional a la porción reembolsada, no del original completo.

Idempotencia. Un webhook reenviado no debe emitir un Egreso doble. Stripe y Mercado Pago reenvían eventos — es normal. Usa el id del reembolso como llave: verifica y emite en una operación atómica, y guarda el UUID del Egreso bajo ese refund id. Si el refund id ya tiene Egreso, sal temprano.

def procesar_reembolso(evt):
    # Idempotencia: si ya emitimos un Egreso para este refund, no repitas
    if db.egresos.exists(refund_id=evt["refund_id"]):
        return  # webhook reenviado — ya está timbrado

    # Calcula base e IVA de la PORCIÓN reembolsada, no del total
    base, iva = calcular_base_e_iva(evt["monto_reembolsado"])
    emitir_egreso(evt["charge_id"], evt["monto_reembolsado"],
                  base, iva, evt["refund_id"])

El orden operacional: detecta el reembolso (idempotente, con llave en el refund id) → resuelve el UUID original → construye el Egreso por el monto reembolsado + IVA → timbra → concilia parcial vs total.

  1. Detecta el webhook de reembolsoVerifica la firma; idempotente, con llave en el id del reembolso.
  2. Resuelve el UUID originalEl del CFDI de ingreso que guardaste al timbrar la venta.
  3. Construye el EgresoTipoDeComprobante "E", rel "01", G02, monto reembolsado + IVA.
  4. Timbra vía tu PACRecibes XML, PDF y el UUID del Egreso.
  5. Concilia parcial vs totalSuma Egresos contra el original; nunca excedas el total.
Automatiza el Egreso, en orden. El camino feliz no es lo que lo hace seguro en producción — la idempotencia y el conteo de parciales sí.

Contracargos y el caso del reembolso antes del CFDI

Dos casos relacionados pero distintos, manejados con honestidad.

Contracargos. Un contracargo es el banco jalando los fondos —distinto de un reembolso voluntario que tú inicias—. Pero el lado fiscal generalmente se maneja igual: un CFDI de Egreso. El movimiento de dinero es otra historia; ese lado lo cubro en contracargos — el caso fiscal hermano del reembolso. Confirma el tratamiento con tu contador.

Reembolso antes del CFDI. Si el reembolso ocurre antes de que hayas emitido el CFDI de ingreso de la venta, puede que simplemente no emitas el ingreso — no hay nada que revertir. Es una rama en tu código: detecta si existe el UUID original antes de construir el Egreso. Confírmalo con tu contador.

Ambos son el-ingeniero-lo-marca, el-contador-lo-decide: yo te muestro dónde está la rama en el código; el dictamen fiscal no es mío. Y por si no quedó claro: las reglas del SAT evolucionan — verifica las vigentes. Si construyes pagos para México, otra obligación del SAT para builders de pagos probablemente también te toca.

Webhook de reembolsoStripe charge.refunded o el refund de Mercado Pago.
Verifica la firmaNunca actúes sobre un payload sin verificar.
Resuelve el UUID del ingresoEl que guardaste al timbrar la venta original.
Construye el EgresoTipoDeComprobante "E", TipoRelacion "01", UsoCFDI "G02".
Timbra vía tu PACRecibes el UUID del Egreso, XML y PDF.
Guarda y relacionaEl UUID del Egreso contra el pago y el CFDI original.
El Egreso de un reembolso, de punta a punta. Tu pipeline de ingreso corriendo en reversa.

Preguntas frecuentes: reembolsos y el CFDI de Egreso

¿Cancelo el CFDI original cuando reembolso? Generalmente no — emites un CFDI de Egreso relacionado con él. La cancelación es un flujo aparte, limitado en el tiempo; pregunta a tu contador.

¿Qué claves uso? TipoDeComprobante “E”, CfdiRelacionados con TipoRelacion “01” apuntando al UUID original, UsoCFDI “G02”, por el monto reembolsado más su IVA.

¿Un solo Egreso puede relacionar varios CFDIs originales? Sí — puedes relacionar múltiples UUIDs originales en un mismo Egreso.

¿Reembolso parcial dos veces — dos Egresos? Sí, múltiples Egresos contra un mismo ingreso, hasta el total original.

¿Un webhook reenviado emite dos Egresos? No, si eres idempotente por el id del reembolso.

¿Un contracargo es igual fiscalmente? Generalmente también se maneja con un Egreso, pero el banco jala los fondos — confirma con tu contador.

¿Y si reembolsé antes de emitir el CFDI de la venta? Puede que simplemente no emitas el ingreso — confírmalo con tu contador.

Cableando la dirección inversa

El Egreso es tu pipeline de ingreso corrido al revés: webhook verificado → resuelve el UUID → construye con las claves exactas (E, rel “01”, G02) → timbra → guarda y relaciona. Si ya automatizas el ingreso en la venta, ya tienes el 80% de la plomería.

Lo que lo hace seguro en producción no es el camino feliz: es la idempotencia por refund id y la contabilidad de parciales contra el total original. Esos detalles son los que evitan que un webhook reenviado te emita Egresos de más.

Soy ingeniero, no contador — confirma con el tuyo la decisión cancelar-vs-Egreso, los plazos y el IVA, y verifica las reglas vigentes del SAT. Si quieres mapear todo esto, aquí está el hub de CFDI y facturación. Esta es justo la plomería fiscal que construyo para clientes en México en Nixbly.