einvoice / EN 16931 / XRechnung rule reference / Walkthrough

From failing CI to a fixed invoice

A five-minute worked example. We take a real German XRechnung (EN 16931 UBL) invoice with two required things removed, run the einvoice conformance checker exactly as a CI gate would, read the actual report it prints, and apply the fix until the invoice passes. Every finding below is produced by the real engine — the report is regenerated from the tool and a test fails the build if this page ever drifts from live output.

The engine behind this walkthrough asserts 297 EN 16931 / XRechnung business rules — every official EN 16931 BR-* rule that can actually fire in either CEN syntax universe (UBL and CII) — every fireable code-list check now included, the complete German KoSIT layer including the Clean-Vehicle-Directive family, and the 21 PEPPOL-EN16931-R* rules KoSIT vendors (that subset only, not Peppol BIS Billing 3.0) — each rule differentially proven against the official Schematron artifacts at 0 divergences. The per-rule inventory and its honest limits live in COVERAGE.md in the repository.

You can reproduce every step yourself. One install puts the einvoice console script on your PATH — Python 3.10+, zero dependencies, no network at validation time:

python3 -m pip install verifyhash-einvoice
einvoice validate --profile xrechnung invoice.xml

Every command below is that same console script. The two invoice files it is pointed at (examples/01-missing-fields/broken.xml and examples/01-missing-fields/fixed.xml) live in the repository checkout — the PyPI wheel deliberately ships only the einvoice package, not the examples — so clone the repository if you want to run these exact paths, and run the commands from its einvoice/ directory. Against an invoice of your own, every command works unchanged straight from the pip install.

You do not have to clone anything to get as far as a real failure report, though. Step 1 below prints the broken invoice in full: save that block to a file of your own and run step 2 against your own filename.

1. The broken invoice

A supplier exported this UBL invoice, but two mandatory items are missing: the Buyer reference (BT-10, the Leitweg-ID routing id a German public buyer requires) and the SELLER CONTACT group (BG-6, a cac:Contact under the supplier party). Everything else is a byte-for-byte copy of a valid KoSIT test document, so these two omissions are the only reason it fails. The full file is examples/01-missing-fields/broken.xml in the repository checkout:

<?xml version="1.0" encoding="UTF-8"?>
<!--
  DELIBERATELY NON-CONFORMANT onboarding example (broken invoice).

  Provenance: this file is a minimal mutation of the real, valid XRechnung
  corpus invoice corpus/vendored/valid/xr-01.01a_ubl.xml (a KoSIT test
  document, valid under profile=xrechnung). Two required things were removed
  so the engine fires real, catalog-backed FATAL findings:

    1. The 'Buyer reference' (BT-10) element was deleted        -> BR-DE-15
    2. The 'SELLER CONTACT' group (BG-6, cac:Contact) was deleted -> BR-DE-2

  Everything else is byte-for-byte the valid corpus document, so the ONLY
  reason it fails is the two removals. The corrected version lives in
  fixed.xml (the same file with both elements restored).
-->
<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
             xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
             xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
    <cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID>
    <cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
    <cbc:ID>123456XX</cbc:ID>
    <cbc:IssueDate>2016-04-04</cbc:IssueDate>
    <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
    <cbc:Note>#ADU#Es gelten unsere Allgem. Geschäftsbedingungen, die Sie unter […] finden.</cbc:Note>
    <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
    <cac:AccountingSupplierParty>
        <cac:Party>
            <cbc:EndpointID schemeID="EM">seller@email.de</cbc:EndpointID>
            <cac:PartyName>
                <cbc:Name>[Seller trading name]</cbc:Name>
            </cac:PartyName>
            <cac:PostalAddress>
                <cbc:StreetName>[Seller address line 1]</cbc:StreetName>
                <cbc:CityName>[Seller city]</cbc:CityName>
                <cbc:PostalZone>12345</cbc:PostalZone>
                <cac:Country>
                    <cbc:IdentificationCode>DE</cbc:IdentificationCode>
                </cac:Country>
            </cac:PostalAddress>
            <cac:PartyTaxScheme>
                <cbc:CompanyID>DE 123456789</cbc:CompanyID>
                <cac:TaxScheme>
                    <cbc:ID>VAT</cbc:ID>
                </cac:TaxScheme>
            </cac:PartyTaxScheme>
            <cac:PartyLegalEntity>
                <cbc:RegistrationName>[Seller name]</cbc:RegistrationName>
                <cbc:CompanyID>[HRA-Eintrag]</cbc:CompanyID>
                <cbc:CompanyLegalForm>123/456/7890, HRA-Eintrag in […]</cbc:CompanyLegalForm>
            </cac:PartyLegalEntity>
        </cac:Party>
    </cac:AccountingSupplierParty>
    <cac:AccountingCustomerParty>
        <cac:Party>
            <cbc:EndpointID schemeID="EM">buyer@info.de</cbc:EndpointID>
            <cac:PartyIdentification>
                <cbc:ID>[Buyer identifier]</cbc:ID>
            </cac:PartyIdentification>
            <cac:PostalAddress>
                <cbc:StreetName>[Buyer address line 1]</cbc:StreetName>
                <cbc:CityName>[Buyer city]</cbc:CityName>
                <cbc:PostalZone>12345</cbc:PostalZone>
                <cac:Country>
                    <cbc:IdentificationCode>DE</cbc:IdentificationCode>
                </cac:Country>
            </cac:PostalAddress>
            <cac:PartyLegalEntity>
                <cbc:RegistrationName>[Buyer name]</cbc:RegistrationName>
            </cac:PartyLegalEntity>
        </cac:Party>
    </cac:AccountingCustomerParty>
    <cac:PaymentMeans>
        <cbc:PaymentMeansCode>58</cbc:PaymentMeansCode>
        <cac:PayeeFinancialAccount>
            <!-- dies ist eine nicht existerende aber valide IBAN als test dummy -->
            <cbc:ID>DE79000000001234567890</cbc:ID>
        </cac:PayeeFinancialAccount>
    </cac:PaymentMeans>
    <cac:PaymentTerms>
        <cbc:Note>Zahlbar sofort ohne Abzug.</cbc:Note>
    </cac:PaymentTerms>
    <cac:TaxTotal>
        <cbc:TaxAmount currencyID="EUR">22.04</cbc:TaxAmount>
        <cac:TaxSubtotal>
            <cbc:TaxableAmount currencyID="EUR">314.86</cbc:TaxableAmount>
            <cbc:TaxAmount currencyID="EUR">22.04</cbc:TaxAmount>
            <cac:TaxCategory>
                <cbc:ID>S</cbc:ID>
                <cbc:Percent>7</cbc:Percent>
                <cac:TaxScheme>
                    <cbc:ID>VAT</cbc:ID>
                </cac:TaxScheme>
            </cac:TaxCategory>
        </cac:TaxSubtotal>
    </cac:TaxTotal>
    <cac:LegalMonetaryTotal>
        <cbc:LineExtensionAmount currencyID="EUR">314.86</cbc:LineExtensionAmount>
        <cbc:TaxExclusiveAmount currencyID="EUR">314.86</cbc:TaxExclusiveAmount>
        <cbc:TaxInclusiveAmount currencyID="EUR">336.9</cbc:TaxInclusiveAmount>
        <cbc:PayableAmount currencyID="EUR">336.9</cbc:PayableAmount>
    </cac:LegalMonetaryTotal>
    <cac:InvoiceLine>
        <cbc:ID>Zeitschrift [...]</cbc:ID>
        <cbc:Note>Die letzte Lieferung im Rahmen des abgerechneten Abonnements erfolgt in 12/2016 Lieferung erfolgt / erfolgte direkt vom Verlag</cbc:Note>
        <cbc:InvoicedQuantity unitCode="XPP">1</cbc:InvoicedQuantity>
        <cbc:LineExtensionAmount currencyID="EUR">288.79</cbc:LineExtensionAmount>
        <cac:InvoicePeriod>
            <cbc:StartDate>2016-01-01</cbc:StartDate>
            <cbc:EndDate>2016-12-31</cbc:EndDate>
        </cac:InvoicePeriod>
        <cac:OrderLineReference>
            <cbc:LineID>6171175.1</cbc:LineID>
        </cac:OrderLineReference>
        <cac:Item>
            <cbc:Description>Zeitschrift Inland</cbc:Description>
            <cbc:Name>Zeitschrift [...]</cbc:Name>
            <cac:SellersItemIdentification>
                <cbc:ID>246</cbc:ID>
            </cac:SellersItemIdentification>
            <cac:CommodityClassification>
                <cbc:ItemClassificationCode listID="IB">0721-880X</cbc:ItemClassificationCode>
            </cac:CommodityClassification>
            <cac:ClassifiedTaxCategory>
                <cbc:ID>S</cbc:ID>
                <cbc:Percent>7</cbc:Percent>
                <cac:TaxScheme>
                    <cbc:ID>VAT</cbc:ID>
                </cac:TaxScheme>
            </cac:ClassifiedTaxCategory>
        </cac:Item>
        <cac:Price>
            <cbc:PriceAmount currencyID="EUR">288.79</cbc:PriceAmount>
        </cac:Price>
    </cac:InvoiceLine>
    <cac:InvoiceLine>
        <cbc:ID>Porto + Versandkosten</cbc:ID>
        <cbc:InvoicedQuantity unitCode="XPP">1</cbc:InvoicedQuantity>
        <cbc:LineExtensionAmount currencyID="EUR">26.07</cbc:LineExtensionAmount>
        <cac:Item>
            <cbc:Name>Porto + Versandkosten</cbc:Name>
            <cac:ClassifiedTaxCategory>
                <cbc:ID>S</cbc:ID>
                <cbc:Percent>7</cbc:Percent>
                <cac:TaxScheme>
                    <cbc:ID>VAT</cbc:ID>
                </cac:TaxScheme>
            </cac:ClassifiedTaxCategory>
        </cac:Item>
        <cac:Price>
            <cbc:PriceAmount currencyID="EUR">26.07</cbc:PriceAmount>
        </cac:Price>
    </cac:InvoiceLine>
</ubl:Invoice>

Working from the pip install alone? Select that whole block, save it as a file of your own — call it whatever you like, say broken-invoice.xml, in any directory — and use that filename wherever step 2 says examples/01-missing-fields/broken.xml. Neither the directory nor the filename is part of any rule: the checker reads the document, not where it is kept, so you get the same report shown in step 3.

2. Run the checker (this is your CI gate)

Point the console script at the invoice (paths relative to the einvoice/ directory of the repository checkout). In a CI pipeline this is the command whose non-zero exit fails the build:

$ einvoice validate --profile xrechnung examples/01-missing-fields/broken.xml

Saved the block from step 1 under a name of your own instead? Then point the same command at that name — everything after the flags is just a path, so this is the whole command a pip-install reader types:

$ einvoice validate --profile xrechnung broken-invoice.xml

--profile xrechnung is not optional here: the console script defaults to --profile en16931 (the European core rule set), and under that profile this file passes — both missing fields are German BR-DE-* requirements. The command exits 1 and prints a human verdict: FAIL: with the first fatal rule id, its message and its offending element, then two lookup lines — how to fix: einvoice --explain BR-DE-2 and the rule page: URL for that same rule on this site. Both are built from the rule the run actually hit, and the page line appears only for rules the shipped remediation catalogue covers. For the whole finding list — what CI wants to archive — ask for JSON:

$ einvoice validate --profile xrechnung --format json examples/01-missing-fields/broken.xml

Same exit code, every finding in one array. Only fatal findings make an invoice invalid (mirroring the official Schematron flag semantics); warning and information findings are advisory and do not fail the build.

The older module form, python3 -m einvoice.report examples/01-missing-fields/broken.xml --format json, is fully supported and does the same work — it is the entry point the shipped CI gate script ci/validate-invoices.sh and the GitHub Action drive, it already defaults to --profile xrechnung (the console script defaults to en16931), and its JSON wraps the same findings in a versioned envelope with report_version, schema, profile, fatal_count and warning_count. That envelope is what the summary and the finding cards below are rendered from.

3. Read the report

The engine reports valid: false for examples/01-missing-fields/broken.xml under profile xrechnung: 3 findings in total, 2 fatal and 0 warning. Each finding names the violated rule, the EN 16931 business terms it touches, and a concrete fix hint. The rule id links to its full reference page.

BR-DE-2 fatal BG-6

SELLER CONTACT (BG-6) must be transmitted.

Add the required element at `/ubl:Invoice/cac:AccountingSupplierParty`: SELLER CONTACT (BG-6) must be transmitted.

BR-DE-15 fatal BT-10

Buyer reference (BT-10) must be transmitted (non-empty).

Add the required element at `cbc:BuyerReference`: Buyer reference (BT-10) must be transmitted (non-empty).

BR-DE-TMP-32 information BG-14 BG-26 BT-72

An invoice should state the delivery/service date via BT-72 (Actual delivery date), BG-14 (Invoicing period) or a BG-26 (Invoice line period) on EVERY line.

Correct `cac:Delivery/cbc:ActualDeliveryDate` so that an invoice should state the delivery/service date via BT-72 (Actual delivery date), BG-14 (Invoicing period) or a BG-26 (Invoice line period) on EVERY line.

The two fatal findings (BR-DE-15 and BR-DE-2) are why the invoice is rejected. The information finding is advisory — we leave it as-is so this stays a minimal two-field fix. For a full remediation write-up of any rule id you see in a failure, run einvoice --explain BR-DE-15 — it reads no invoice and prints what the rule requires, its BT/BG terms, the XML location, the one-line fix and the verbatim official Schematron assert (--lang de for the German text).

4. Apply the fix

Restore the two missing elements. This is the exact diff from broken.xml to the corrected fixed.xml (the provenance comment headers are omitted; the invoice bodies differ by nothing else):

--- broken.xml
+++ fixed.xml
@@ -9,6 +9,7 @@
     <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
     <cbc:Note>#ADU#Es gelten unsere Allgem. Geschäftsbedingungen, die Sie unter […] finden.</cbc:Note>
     <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
+    <cbc:BuyerReference>04011000-12345-03</cbc:BuyerReference>
     <cac:AccountingSupplierParty>
         <cac:Party>
             <cbc:EndpointID schemeID="EM">seller@email.de</cbc:EndpointID>
@@ -34,6 +35,11 @@
                 <cbc:CompanyID>[HRA-Eintrag]</cbc:CompanyID>
                 <cbc:CompanyLegalForm>123/456/7890, HRA-Eintrag in […]</cbc:CompanyLegalForm>
             </cac:PartyLegalEntity>
+            <cac:Contact>
+                <cbc:Name>nicht vorhanden</cbc:Name>
+                <cbc:Telephone>+49 1234-5678</cbc:Telephone>
+                <cbc:ElectronicMail>seller@email.de</cbc:ElectronicMail>
+            </cac:Contact>
         </cac:Party>
     </cac:AccountingSupplierParty>
     <cac:AccountingCustomerParty>

A cac:Contact needs at least a name, telephone and/or e-mail; the buyer reference is the routing id your buyer gives you.

5. The corrected invoice passes

Re-run the same command on the corrected file (examples/01-missing-fields/fixed.xml, also from the repository checkout):

$ einvoice validate --profile xrechnung --format json examples/01-missing-fields/fixed.xml

It now exits 0 and reports "valid": true with no fatal finding left — the advisory BR-DE-TMP-32 is still listed, which is exactly why the verdict keys on severity rather than on the finding count. Both BR-DE-* fatals are gone and the invoice would pass this pre-flight. (This page’s test re-runs the live engine on fixed.xml and fails the build unless it really passes with zero fatal findings.)

Honest limit: a green result means “no implemented fatal rule fired” — advisory findings may still be listed, as above — not “certified legally conformant”. This is a fast pre-flight that catches the mistakes which trip up most first submissions — still run your buyer’s official validator before you file.

Next

Browse every rule the engine checks in the rule index, or start from the overview for install and the CI-gate recipe.