← Back to all articles

Peppol-BIS-3.0-Rechnungen in C# generieren

SealDoc Team · · 6 min read

Das Generieren einer Peppol-BIS-Billing-3.0-Rechnung in C# ist konzeptionell nicht schwierig. Der schwierige Teil liegt darin, die Namespace-Deklarationen korrekt hinzubekommen, die richtigen Bezeichner in die richtigen Felder zu schreiben und eine Ausgabe zu erzeugen, die die Schematron-Validierung besteht.

Dieser Artikel führt durch eine minimale, aber vollständige Implementierung. Er setzt voraus, dass Sie die Peppol-BIS-3.0-Einführung gelesen haben und das EN16931-Datenmodell kennen. Wenn Ihnen Business Terms wie BT-24 oder BT-131 nicht vertraut sind, beginnen Sie dort.

Erforderliche Namespaces

UBL 2.1 verwendet drei XML-Namespaces für Rechnungsdokumente:

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 ist der Namespace des Wurzelelements. Cac enthält aggregierte (zusammengesetzte) Elemente wie PartyName und InvoiceLine. Cbc enthält einfache (skalare) Elemente wie ID, Name und Amount.

Jedes Element in der Rechnung verwendet einen dieser drei Namespaces. Ein falscher Namespace lässt die Schema-Validierung scheitern, bevor Schematron überhaupt läuft.

Die Pflicht-Header-Felder

Eine minimal gültige Peppol-BIS-3.0-Rechnung benötigt diese Header-Elemente, in dieser Reihenfolge:

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")
);

Der CustomizationID-Wert ist nicht konfigurierbar. Dieser genaue String ist für Peppol BIS 3.0 erforderlich. Die EN16931-Basis-URI statt der Peppol-BIS-URI zu verwenden ist ein häufiger Fehler, der zur Ablehnung auf Profilebene führt.

Lieferanten- und Käuferparteien

Die Parteienstruktur in UBL ist verschachtelt: Die Partei sitzt in einem Rollenelement.

// 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"))
    )
);

Das schemeID-Attribut bei EndpointID ist das ISO-6523-Schema. 0208 ist die belgische Unternehmensnummer, 0106 ist die niederländische KvK-Nummer. Die Verwendung des falschen Schema-Codes führt dazu, dass die Teilnehmersuche im Peppol-Netzwerk scheitert.

Positionen

Jede Position bildet ein InvoiceLine-Element:

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"))))
    );
}

Der unitCode ist ein UN/ECE-Recommendation-20-Code. C62 bedeutet Stück (Einheit). Weitere häufige Codes: HUR (Stunde), KGM (Kilogramm), MTR (Meter).

Steuergesamtbeträge

EN16931 verlangt, dass Steuerbeträge nach Kategorie und Satz gruppiert werden. Für eine einfache Rechnung mit einem Steuersatz:

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"))))
    );
}

Dokumentengesamtbeträge

Das LegalMonetaryTotal muss mehrere EN16931-Berechnungsregeln erfüllen. Auf Aggregatebene runden, nicht pro Position:

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)
    );
}

Beträge als decimal berechnen, konsistent mit Math.Round(value, 2, MidpointRounding.AwayFromZero) runden. Das Mischen von Rundungsstrategien zwischen Positionen und Gesamtbeträgen ist die häufigste Ursache für BR-CO-10- und BR-CO-15-Fehler.

Schematron-Validierung

Schema-Validierung (XSD) erkennt Strukturfehler. Schematron-Validierung erkennt Business-Rule-Verletzungen. Beides wird benötigt.

Die vorkompilierten XSLT-Dateien von den Peppol-BIS-GitHub-Releases herunterladen. Dann mit Saxon HE ausführen:

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));
}

Das gegen peppol-en16931-ubl.xsl aus den Release-Artefakten ausführen. Die XSLT-Datei wendet sowohl die EN16931-Basisregeln als auch die Peppol-BIS-3.0-Erweiterungsregeln an.

Zahlungsmittel

Wenn eine Banküberweisung (SEPA) angegeben werden muss, vor TaxTotal folgendes hinzufügen:

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

BT-81-Code 30 (Kreditüberweisung) ist am gebräuchlichsten. 58 (SEPA-Kreditüberweisung) ist die Variante, wenn SEPA-spezifische Felder benötigt werden.

Was als Nächstes kommt

Ein gültiges XML-Rechnungsdokument zu generieren ist das Fundament. Die nächste Schicht ist die Übertragung: Die Rechnung muss den Käufer über einen Peppol-Access-Point erreichen. Dafür ist der Endpunkt des Käufers bekannt, den man durch SMP/SML-Discovery erhält. Einzelheiten zum Suchprozess finden Sie in Wie Peppol-SMP- und SML-Discovery funktioniert.

Für den Produktionseinsatz stellt SealDoc eine REST-API bereit, die UBL-Generierung, Schematron-Validierung und Peppol-Netzwerk-Routing in einem einzigen Aufruf übernimmt. Der Input sind Ihre Rechnungsdaten als JSON; der Output ist ein validiertes, zeitgestempeltes Dokument, das zustellbereit ist.


← Back to all articles