← Back to all articles

Jak generować faktury Peppol BIS 3.0 w C#

SealDoc Team · · 6 min read

Generowanie faktury Peppol BIS Billing 3.0 w C# nie jest konceptualnie trudne. Trudną częścią jest właściwe skonfigurowanie deklaracji przestrzeni nazw, umieszczenie właściwych identyfikatorów w właściwych polach i produkowanie danych wyjściowych przechodzących walidację Schematron.

Ten artykuł przeprowadza Cię przez minimalną, ale kompletną implementację. Zakłada, że przeczytałeś wprowadzenie do Peppol BIS 3.0 i rozumiesz model danych EN16931. Jeśli nie jesteś zaznajomiony z Terminami Biznesowymi jak BT-24 lub BT-131, zacznij od tamtych artykułów.

Wymagane przestrzenie nazw

UBL 2.1 używa trzech przestrzeni nazw XML dla dokumentów faktur:

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 to przestrzeń nazw elementu głównego. Cac przechowuje elementy agregujące (złożone) jak PartyName i InvoiceLine. Cbc przechowuje elementy podstawowe (skalarne) jak ID, Name i Amount.

Każdy element w fakturze używa jednej z tych trzech przestrzeni nazw. Błędna przestrzeń nazw powoduje niepowodzenie walidacji schematu zanim jeszcze uruchomi się Schematron.

Obowiązkowe pola nagłówka

Minimalna prawidłowa faktura Peppol BIS 3.0 potrzebuje tych elementów nagłówka, w tej kolejności:

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

Wartość CustomizationID nie jest konfigurowalna. Ten dokładny ciąg jest wymagany dla Peppol BIS 3.0. Użycie bazowego URI EN16931 zamiast Peppol BIS jest częstym błędem powodującym odrzucenie na poziomie profilu.

Strony sprzedawcy i nabywcy

Struktura stron w UBL jest zagnieżdżona: strona siedzi wewnątrz elementu roli.

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

Atrybut schemeID na EndpointID to schemat ISO 6523. 0208 to belgijski numer przedsiębiorstwa, 0106 to holenderski numer KvK. Użycie błędnego kodu schematu powoduje niepowodzenie wyszukiwania uczestnika w sieci Peppol.

Pozycje

Każda pozycja mapuje na element 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"))))
    );
}

unitCode to kod zalecenia 20 ONZ/EKG. C62 oznacza sztukę. Inne popularne kody: HUR (godzina), KGM (kilogram), MTR (metr).

Sumy podatkowe

EN16931 wymaga kwot podatku zgrupowanych według kategorii i stawki. Dla prostej faktury z jedną stawką:

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

Sumy dokumentu

LegalMonetaryTotal musi spełniać kilka reguł obliczeniowych EN16931. Zaokrąglaj na poziomie agregatu, a nie per pozycja:

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

Obliczaj kwoty jako decimal, zaokrąglaj spójnie za pomocą Math.Round(value, 2, MidpointRounding.AwayFromZero) w całym kodzie. Mieszanie strategii zaokrąglania między pozycjami a sumami to najczęstsza przyczyna błędów BR-CO-10 i BR-CO-15.

Walidacja Schematron

Walidacja schematu (XSD) wychwytuje błędy strukturalne. Walidacja Schematron wychwytuje naruszenia reguł biznesowych. Potrzebujesz obu.

Pobierz wstępnie skompilowane pliki XSLT z wydań GitHub Peppol BIS. Następnie uruchom je z 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));
}

Uruchom to względem peppol-en16931-ubl.xsl z artefaktów wydania. Plik XSLT stosuje zarówno bazowe reguły EN16931, jak i reguły rozszerzeń Peppol BIS 3.0.

Środki płatności

Jeśli musisz określić przelew bankowy (SEPA), dodaj to przed 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

Kod BT-81 30 (przelew kredytowy) jest najczęstszy. 58 (przelew kredytowy SEPA) jest wariantem gdy potrzebne są pola specyficzne dla SEPA.

Co dalej

Generowanie prawidłowego XML faktury to fundament. Następna warstwa to transmisja: faktura musi dotrzeć do nabywcy przez Access Point Peppol. Wymaga to znajomości punktu końcowego nabywcy, który uzyskujesz przez odkrywanie SMP/SML. Szczegóły procesu wyszukiwania znajdziesz w artykule jak działa odkrywanie Peppol SMP i SML.

Do użytku produkcyjnego SealDoc udostępnia REST API obsługujące generowanie UBL, walidację Schematron i routing sieciowy Peppol w jednym wywołaniu. Wejściem są dane Twojej faktury w JSON; wyjściem jest zwalidowany, opatrzony znacznikiem czasu dokument gotowy do dostarczenia.


← Back to all articles