Peppol-BIS-3.0-Rechnungen in C# generieren
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.