← Back to all articles

Cómo generar facturas Peppol BIS 3.0 en C#

SealDoc Team · · 7 min read

Generar una factura Peppol BIS Billing 3.0 en C# no es conceptualmente difícil. La parte difícil es conseguir que las declaraciones de espacio de nombres sean correctas, poner los identificadores correctos en los campos correctos y producir una salida que supere la validación Schematron.

Este artículo recorre una implementación mínima pero completa. Asume que se ha leído la introducción a Peppol BIS 3.0 y se entiende el modelo de datos EN16931. Si no se está familiarizado con Términos de Negocio como BT-24 o BT-131, comenzar por ahí.

Espacios de nombres necesarios

UBL 2.1 usa tres espacios de nombres XML para los documentos de factura:

static readonly XNamespace Ubl = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2";
static readonly XNamespace Cac = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2";
static readonly XNamespace Cbc = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2";

Ubl es el espacio de nombres del elemento raíz. Cac contiene elementos de agregado (compuestos) como PartyName e InvoiceLine. Cbc contiene elementos básicos (escalares) como ID, Name y Amount.

Cada elemento de la factura usa uno de estos tres espacios de nombres. Equivocarse en un espacio de nombres hace que la validación del esquema falle antes de que se ejecute Schematron.

Los campos de cabecera obligatorios

Una factura Peppol BIS 3.0 mínimamente válida necesita estos elementos de cabecera, en este orden:

var invoice = new XElement(Ubl + "Invoice",
    new XAttribute(XNamespace.Xmlns + "cac", Cac.NamespaceName),
    new XAttribute(XNamespace.Xmlns + "cbc", Cbc.NamespaceName),

    // BT-24: Specification identifier — identifies the exact profile
    new XElement(Cbc + "CustomizationID",
        "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1"),

    // BT-23: Business process type
    new XElement(Cbc + "ProfileID",
        "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"),

    // BT-1: Invoice number
    new XElement(Cbc + "ID", "INV-2026-001"),

    // BT-2: Invoice issue date
    new XElement(Cbc + "IssueDate", "2026-01-14"),

    // BT-3: Invoice type code (380 = Commercial Invoice)
    new XElement(Cbc + "InvoiceTypeCode", "380"),

    // BT-5: Invoice currency code
    new XElement(Cbc + "DocumentCurrencyCode", "EUR")
);

El valor CustomizationID no es configurable. Esta cadena exacta se requiere para Peppol BIS 3.0. Usar el URI base de EN16931 en lugar del URI de Peppol BIS es un error común que causa el rechazo a nivel de perfil.

Partes del vendedor y del comprador

La estructura de partes en UBL es anidada: la parte se encuentra dentro de un elemento de rol.

// BG-4: Seller party
var seller = new XElement(Cac + "AccountingSupplierParty",
    new XElement(Cac + "Party",
        // BT-34: Seller electronic address (Peppol endpoint ID)
        new XElement(Cbc + "EndpointID",
            new XAttribute("schemeID", "0208"),
            "0468863455"),

        // BT-27: Seller name
        new XElement(Cac + "PartyName",
            new XElement(Cbc + "Name", "Acme GmbH")),

        // BG-5: Seller postal address
        new XElement(Cac + "PostalAddress",
            new XElement(Cbc + "StreetName", "Hauptstrasse 1"),
            new XElement(Cbc + "CityName", "Berlin"),
            new XElement(Cbc + "PostalZone", "10115"),
            new XElement(Cac + "Country",
                new XElement(Cbc + "IdentificationCode", "DE"))),

        // BT-31: Seller VAT identifier
        new XElement(Cac + "PartyTaxScheme",
            new XElement(Cbc + "CompanyID", "DE123456789"),
            new XElement(Cac + "TaxScheme",
                new XElement(Cbc + "ID", "VAT"))),

        // BT-30: Seller legal registration identifier
        new XElement(Cac + "PartyLegalEntity",
            new XElement(Cbc + "RegistrationName", "Acme GmbH"),
            new XElement(Cbc + "CompanyID", "HRB 12345"))
    )
);

// BG-7: Buyer party
var buyer = new XElement(Cac + "AccountingCustomerParty",
    new XElement(Cac + "Party",
        // BT-49: Buyer electronic address (Peppol endpoint ID)
        new XElement(Cbc + "EndpointID",
            new XAttribute("schemeID", "0106"),
            "123456789B01"),

        // BT-44: Buyer name
        new XElement(Cac + "PartyName",
            new XElement(Cbc + "Name", "Buyer BV")),

        new XElement(Cac + "PostalAddress",
            new XElement(Cbc + "StreetName", "Hoofdstraat 1"),
            new XElement(Cbc + "CityName", "Amsterdam"),
            new XElement(Cbc + "PostalZone", "1000 AA"),
            new XElement(Cac + "Country",
                new XElement(Cbc + "IdentificationCode", "NL"))),

        new XElement(Cac + "PartyLegalEntity",
            new XElement(Cbc + "RegistrationName", "Buyer BV"),
            new XElement(Cbc + "CompanyID", "123456789B01"))
    )
);

El atributo schemeID en EndpointID es el esquema ISO 6523. 0208 es el número empresarial belga, 0106 es el número KvK neerlandés. Usar el código de esquema incorrecto hace que la búsqueda del participante falle en la red Peppol.

Artículos de línea

Cada artículo de línea se mapea a un elemento InvoiceLine:

static XElement BuildLine(int lineId, string description, decimal quantity,
    decimal unitPrice, decimal vatRate)
{
    var lineNetAmount = Math.Round(quantity * unitPrice, 2);

    return new XElement(Cac + "InvoiceLine",
        // BT-126: Invoice line identifier
        new XElement(Cbc + "ID", lineId.ToString()),

        // BT-129: Invoiced quantity
        new XElement(Cbc + "InvoicedQuantity",
            new XAttribute("unitCode", "C62"),  // C62 = piece/unit
            quantity),

        // BT-131: Invoice line net amount
        new XElement(Cbc + "LineExtensionAmount",
            new XAttribute("currencyID", "EUR"),
            lineNetAmount),

        // BG-29: Price
        new XElement(Cac + "Price",
            new XElement(Cbc + "PriceAmount",
                new XAttribute("currencyID", "EUR"),
                unitPrice)),

        // BG-30: Line VAT
        new XElement(Cac + "Item",
            new XElement(Cbc + "Name", description),
            new XElement(Cac + "ClassifiedTaxCategory",
                new XElement(Cbc + "ID", "S"),
                new XElement(Cbc + "Percent", vatRate),
                new XElement(Cac + "TaxScheme",
                    new XElement(Cbc + "ID", "VAT"))))
    );
}

El unitCode es un código de recomendación 20 de UN/ECE. C62 significa unidad (pieza). Otros códigos comunes: HUR (hora), KGM (kilogramo), MTR (metro).

Totales de impuestos

EN16931 requiere los importes de impuestos agrupados por categoría y tipo. Para una factura simple de tipo único:

static XElement BuildTaxTotal(decimal taxableAmount, decimal vatRate, decimal vatAmount)
{
    return new XElement(Cac + "TaxTotal",
        new XElement(Cbc + "TaxAmount",
            new XAttribute("currencyID", "EUR"),
            vatAmount),
        new XElement(Cac + "TaxSubtotal",
            new XElement(Cbc + "TaxableAmount",
                new XAttribute("currencyID", "EUR"),
                taxableAmount),
            new XElement(Cbc + "TaxAmount",
                new XAttribute("currencyID", "EUR"),
                vatAmount),
            new XElement(Cac + "TaxCategory",
                new XElement(Cbc + "ID", "S"),
                new XElement(Cbc + "Percent", vatRate),
                new XElement(Cac + "TaxScheme",
                    new XElement(Cbc + "ID", "VAT"))))
    );
}

Totales del documento

El LegalMonetaryTotal debe satisfacer varias reglas de cálculo de EN16931. Redondear a nivel de agregado, no por línea:

static XElement BuildTotals(decimal lineNetTotal, decimal vatAmount, decimal grandTotal)
{
    return new XElement(Cac + "LegalMonetaryTotal",
        // BT-106: Sum of line net amounts
        new XElement(Cbc + "LineExtensionAmount",
            new XAttribute("currencyID", "EUR"), lineNetTotal),

        // BT-109: Invoice total amount without VAT
        new XElement(Cbc + "TaxExclusiveAmount",
            new XAttribute("currencyID", "EUR"), lineNetTotal),

        // BT-112: Invoice total amount with VAT
        new XElement(Cbc + "TaxInclusiveAmount",
            new XAttribute("currencyID", "EUR"), grandTotal),

        // BT-115: Amount due for payment
        new XElement(Cbc + "PayableAmount",
            new XAttribute("currencyID", "EUR"), grandTotal)
    );
}

Calcular los importes como decimal, redondear con Math.Round(value, 2, MidpointRounding.AwayFromZero) de forma consistente. Mezclar estrategias de redondeo entre líneas y totales es la causa más común de los fallos de BR-CO-10 y BR-CO-15.

Validación Schematron

La validación del esquema (XSD) detecta errores estructurales. La validación Schematron detecta violaciones de reglas de negocio. Se necesitan ambas.

Descargar los archivos XSLT precompilados de las versiones de Peppol BIS en GitHub. Luego ejecutarlos con Saxon HE:

dotnet add package SaxonHE12s9apiExtensions
using net.sf.saxon.s9api;

static IEnumerable<string> ValidateWithSchematron(string invoiceXml, string xsltPath)
{
    var proc = new Processor(licensed: false);
    var exec = proc.newXsltCompiler()
                   .compile(new StreamSource(xsltPath));

    var trans  = exec.load30();
    var result = new XdmDestination();
    trans.applyTemplates(
        new StreamSource(new java.io.StringReader(invoiceXml)),
        result);

    // Parse SVRL for failed-assert elements
    var svrl = XDocument.Parse(result.XdmNode.ToString());
    var svrlNs = XNamespace.Get("http://purl.oclc.org/dsdl/svrl");
    return svrl.Descendants(svrlNs + "failed-assert")
               .Select(e => e.Element(svrlNs + "text")?.Value?.Trim() ?? "")
               .Where(t => !string.IsNullOrEmpty(t));
}

Ejecutar esto contra peppol-en16931-ubl.xsl de los artefactos de la versión. El archivo XSLT aplica tanto las reglas base de EN16931 como las reglas de extensión de Peppol BIS 3.0.

Medios de pago

Si hay que especificar transferencia bancaria (SEPA), añadir esto antes de TaxTotal:

new XElement(Cac + "PaymentMeans",
    // BT-81: Payment means code (30 = Credit transfer)
    new XElement(Cbc + "PaymentMeansCode", "30"),
    new XElement(Cac + "PayeeFinancialAccount",
        new XElement(Cbc + "ID", "NL91ABNA0417164300"),  // IBAN
        new XElement(Cac + "FinancialInstitutionBranch",
            new XElement(Cbc + "ID", "ABNANL2A"))))  // BIC

El código BT-81 30 (transferencia de crédito) es el más común. 58 (transferencia de crédito SEPA) es la variante cuando se necesitan campos específicos de SEPA.

Qué viene después

Generar una factura XML válida es la base. La siguiente capa es la transmisión: la factura necesita llegar al comprador a través de un Punto de Acceso Peppol. Para eso hay que conocer el endpoint del comprador, que se obtiene a través del descubrimiento SMP/SML. Consultar cómo funciona el descubrimiento Peppol SMP y SML para el proceso de búsqueda.

Para uso en producción, SealDoc expone una API REST que maneja la generación de UBL, la validación Schematron y el enrutamiento en la red Peppol en una única llamada. La entrada son los datos de la factura en JSON; la salida es un documento validado y sellado listo para la entrega.


← Back to all articles