Cómo generar facturas Peppol BIS 3.0 en C#
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.