DEV Community

MattGate
MattGate

Posted on

Cómo parsear un CFDI 4.0 en C# sin sufrir (y sin tropezar con los detalles del SAT)

Si trabajas con facturación electrónica en México, tarde o temprano te toca leer un XML de CFDI: para conciliar pagos, cargar facturas recibidas en un ERP o mostrarle al usuario qué le facturaron.

Parece fácil. Es un XML, ¿no? Luego te topas con los namespaces, con el timbre metido en un complemento, con facturas 3.3 que siguen circulando y con impuestos que a veces no traen importe.

Aquí va cómo hacerlo bien a mano en C# con System.Xml.Linq. Al final hay un atajo por si prefieres no mantener ese código.

1. Carga el XML de forma segura

Lo primero no tiene que ver con el SAT: nunca cargues un XML externo con la configuración por defecto sin pensarlo. Un XML con DTD puede traer ataques XXE o "bombas" de entidades. Las facturas vienen de terceros, así que trátalas como entrada no confiable.

using System.Globalization;
using System.Xml;
using System.Xml.Linq;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit, // un CFDI legítimo nunca trae DTD
    XmlResolver = null
};

using var reader = XmlReader.Create("factura.xml", settings);
var doc = XDocument.Load(reader);
Enter fullscreen mode Exit fullscreen mode

2. Los namespaces: detecta la versión antes de leer nada

El error clásico es buscar doc.Root.Element("Emisor") y recibir null. Todos los nodos del comprobante viven en un namespace, y ese namespace cambia entre versiones:

Versión Namespace
CFDI 4.0 http://www.sat.gob.mx/cfd/4
CFDI 3.3 http://www.sat.gob.mx/cfd/3

La 4.0 es la única válida para emitir desde abril de 2023, pero hay millones de XMLs 3.3 históricos que tu sistema probablemente sigue recibiendo. Soporta las dos.

XNamespace cfdi40 = "http://www.sat.gob.mx/cfd/4";
XNamespace cfdi33 = "http://www.sat.gob.mx/cfd/3";

var root = doc.Root ?? throw new InvalidDataException("XML vacío");
var ns = root.Name.Namespace;

if (root.Name.LocalName != "Comprobante" || (ns != cfdi40 && ns != cfdi33))
    throw new InvalidDataException("No es un CFDI 3.3 ni 4.0");

var version = (string?)root.Attribute("Version"); // "4.0" o "3.3"
Enter fullscreen mode Exit fullscreen mode

A partir de aquí usas ns + "Emisor", ns + "Receptor", etc., y el mismo código lee las dos versiones.

3. Datos básicos: ojo con los decimales

var emisor   = root.Element(ns + "Emisor");
var receptor = root.Element(ns + "Receptor");

var rfcEmisor   = (string?)emisor?.Attribute("Rfc");
var rfcReceptor = (string?)receptor?.Attribute("Rfc");

var total = decimal.Parse(
    (string)root.Attribute("Total")!,
    CultureInfo.InvariantCulture); // no dependas de la cultura del servidor
Enter fullscreen mode Exit fullscreen mode

El InvariantCulture no es opcional. Si tu app corre en un servidor con una cultura que usa coma decimal, decimal.Parse("13920.00") te va a dar una sorpresa en producción.

4. El UUID no está donde crees

El folio fiscal (UUID) no es un atributo del comprobante. Vive en el Timbre Fiscal Digital, que es un complemento con su propio namespace:

XNamespace tfd = "http://www.sat.gob.mx/TimbreFiscalDigital";

var timbre = root
    .Elements(ns + "Complemento")
    .Elements(tfd + "TimbreFiscalDigital")
    .FirstOrDefault();

var uuid          = (string?)timbre?.Attribute("UUID");
var fechaTimbrado = (string?)timbre?.Attribute("FechaTimbrado");
Enter fullscreen mode Exit fullscreen mode

Si timbre es null, el XML no está timbrado: es un comprobante que todavía no pasa por el PAC. Decide explícitamente qué hacer en ese caso en lugar de dejar que truene más adelante.

5. Impuestos: el caso "Exento"

En los traslados, un concepto exento de IVA no trae Importe ni TasaOCuota, solo TipoFactor="Exento". Si haces decimal.Parse sobre el importe sin revisar, truena con la primera factura exenta que te llegue.

foreach (var traslado in root.Descendants(ns + "Traslado"))
{
    var tipoFactor = (string?)traslado.Attribute("TipoFactor");
    var importeAttr = (string?)traslado.Attribute("Importe");

    decimal? importe = importeAttr is null
        ? null // Exento: no hay importe
        : decimal.Parse(importeAttr, CultureInfo.InvariantCulture);

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Otro detalle: los traslados aparecen por concepto y también a nivel global. Descendants trae ambos. Si vas a sumar, separa Conceptos/Concepto/Impuestos del nodo Impuestos que cuelga directo del comprobante, o vas a contar doble.

6. Complementos y addendas: no dejes que te rompan el parseo

Pagos, Nómina, Carta Porte, Comercio Exterior... cada uno tiene su propio namespace y estructura, y la addenda puede traer literalmente cualquier cosa que el cliente o el proveedor inventó. Mi recomendación:

  • Parsea en detalle solo los complementos que de verdad usas.
  • Del resto, registra al menos cuáles vienen (el nombre del nodo) para que no pasen desapercibidos.
  • Ignora la addenda salvo que tengas un caso de negocio concreto.
var complementos = root
    .Elements(ns + "Complemento")
    .Elements()
    .Select(e => e.Name.LocalName)
    .ToList(); // ["TimbreFiscalDigital", "Pagos", ...]
Enter fullscreen mode Exit fullscreen mode

Resumen de trampas

  1. Carga el XML sin DTD (DtdProcessing.Prohibit).
  2. Detecta el namespace: 4.0 y 3.3 son distintos.
  3. Parsea decimales con InvariantCulture.
  4. El UUID está en el complemento TimbreFiscalDigital, con su propio namespace.
  5. Los traslados exentos no traen importe.
  6. Los impuestos vienen por concepto y globales: no sumes doble.
  7. Complementos y addendas desconocidos no deben romper nada. Con esto tienes un parser que aguanta facturas reales, no solo el ejemplo bonito de la documentación.

El atajo: si no quieres mantener esto

Yo terminé escribiendo este parser tantas veces que lo convertí en un servicio: CFDI Tools API. Son tres endpoints:

  • POST /v1/cfdi/parse: XML (3.3 o 4.0, texto o base64) → JSON limpio con emisor, receptor, conceptos, impuestos, timbre y complementos detectados.
  • POST /v1/cfdi/pdf: XML timbrado → PDF de representación impresa con QR del SAT, con logo y color opcionales.
  • POST /v1/cfdi/verify: consulta el estatus ante el SAT (Vigente / Cancelado / NoEncontrado).
using System.Net.Http.Json;
using System.Text.Json;

const string host = "cfdi-tools.p.rapidapi.com";

using var client = new HttpClient { BaseAddress = new Uri($"https://{host}") };
client.DefaultRequestHeaders.Add("X-RapidAPI-Key", "TU_API_KEY");
client.DefaultRequestHeaders.Add("X-RapidAPI-Host", host);

var xml = await File.ReadAllTextAsync("factura.xml");
var response = await client.PostAsJsonAsync("/v1/cfdi/parse", new { xml });
var cfdi = await response.Content.ReadFromJsonAsync<JsonElement>();

Console.WriteLine(cfdi.GetProperty("timbre").GetProperty("uuid").GetString());
Console.WriteLine(cfdi.GetProperty("total").GetDecimal());
Enter fullscreen mode Exit fullscreen mode

Algunos detalles que me importaban al construirlo:

  • No guarda nada. El XML se procesa en memoria y se descarta; nunca se loguea su contenido.
  • Errores con códigos estables (CFDI_INVALID_XML, CFDI_UNSUPPORTED_VERSION...) y mensajes que te dicen qué revisar.
  • Documentación en español e inglés, con ejemplos en C#, JavaScript, Python y PHP.
  • Plan gratuito de 150 llamadas al mes, suficiente para integrarlo y probarlo con calma. Si lo pruebas, me encantaría saber qué te funcionó y qué no. Y si tienes una trampa del CFDI que no puse aquí, déjala en los comentarios: seguro le ahorra una tarde a alguien.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.