← Back to all articles

Comment générer des factures Peppol BIS 3.0 en C#

SealDoc Team · · 7 min read

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.


← Back to all articles