Comment générer des factures Peppol BIS 3.0 en C#
Générer une facture Peppol BIS Billing 3.0 en C# n’est pas conceptuellement difficile. La partie difficile consiste à obtenir les déclarations d’espaces de noms correctes, à placer les bons identifiants dans les bons champs et à produire une sortie qui passe la validation Schematron.
Cet article présente une implémentation minimale mais complète. Il suppose que vous avez lu l’introduction à Peppol BIS 3.0 et que vous comprenez le modèle de données EN16931. Si vous n’êtes pas familier avec les termes métier tels que BT-24 ou BT-131, commencez par là.
Espaces de noms requis
UBL 2.1 utilise trois espaces de noms XML pour les documents de facturation :
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 est l’espace de noms de l’élément racine. Cac contient les éléments agrégés (composites) comme PartyName et InvoiceLine. Cbc contient les éléments de base (scalaires) comme ID, Name et Amount.
Chaque élément de la facture utilise l’un de ces trois espaces de noms. Un espace de noms incorrect entraîne l’échec de la validation de schéma avant même que Schematron ne s’exécute.
Les champs d’en-tête obligatoires
Une facture Peppol BIS 3.0 minimale valide nécessite ces éléments d’en-tête, dans cet ordre :
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")
);
La valeur CustomizationID n’est pas configurable. Cette chaîne exacte est requise pour Peppol BIS 3.0. Utiliser l’URI de base EN16931 au lieu de celui de Peppol BIS est une erreur courante qui entraîne un rejet au niveau du profil.
Parties vendeur et acheteur
La structure des parties en UBL est imbriquée : la partie se trouve à l’intérieur d’un élément de rôle.
// 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"))
)
);
L’attribut schemeID sur EndpointID est le schéma ISO 6523. 0208 est le numéro d’entreprise belge, 0106 est le numéro KvK néerlandais. L’utilisation d’un code de schéma incorrect entraîne l’échec de la recherche de participant dans le réseau Peppol.
Lignes de facture
Chaque ligne se mappe sur un élément 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"))))
);
}
Le unitCode est un code de recommandation UN/ECE 20. C62 signifie unité (pièce). Autres codes courants : HUR (heure), KGM (kilogramme), MTR (mètre).
Totaux de taxes
EN16931 exige des montants de taxes regroupés par catégorie et taux. Pour une facture simple à taux unique :
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"))))
);
}
Totaux du document
Le LegalMonetaryTotal doit satisfaire plusieurs règles de calcul EN16931. Arrondir au niveau agrégé, et non par ligne :
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)
);
}
Calculez les montants en decimal, arrondissez avec Math.Round(value, 2, MidpointRounding.AwayFromZero) de manière cohérente tout au long. Mélanger les stratégies d’arrondi entre les lignes et les totaux est la cause la plus courante des échecs BR-CO-10 et BR-CO-15.
Validation Schematron
La validation de schéma (XSD) détecte les erreurs structurelles. La validation Schematron détecte les violations de règles métier. Vous avez besoin des deux.
Téléchargez les fichiers XSLT précompilés depuis les versions GitHub de Peppol BIS. Puis exécutez-les avec 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));
}
Exécutez ceci contre peppol-en16931-ubl.xsl depuis les artefacts de version. Le fichier XSLT applique à la fois les règles de base EN16931 et les règles d’extension Peppol BIS 3.0.
Moyens de paiement
Si vous devez spécifier un virement bancaire (SEPA), ajoutez ceci avant 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
Le code BT-81 30 (virement bancaire) est le plus courant. 58 (virement SEPA) est la variante lorsque des champs spécifiques au SEPA sont nécessaires.
La suite
Générer une facture XML valide est la base. La couche suivante est la transmission : la facture doit atteindre l’acheteur via un Access Point Peppol. Cela nécessite de connaître le point de terminaison de l’acheteur, que vous obtenez via la découverte SMP/SML. Voir comment fonctionne la découverte Peppol SMP et SML pour le processus de recherche.
Pour une utilisation en production, SealDoc expose une API REST qui gère la génération UBL, la validation Schematron et le routage réseau Peppol en un seul appel. L’entrée est vos données de facturation en JSON ; la sortie est un document validé et horodaté prêt pour la livraison.