Skip to main content

einvoice

A zero-dependency, embeddable, self-hostable conformance validator for EN 16931 electronic invoices, targeting the German XRechnung CIUS (UBL 2.1 Invoice and UN/CEFACT CII syntaxes, including Factur-X/ZUGFeRD PDF/A-3 containers — einvoice validate invoice.pdf extracts and grades the embedded XML directly, with the standard library only).

Published reference site: verifyhash.com/einvoice. Four pages there settle most evaluation questions before you clone anything. Paste an invoice and grade it in your browser runs this same engine client-side via Pyodide/WebAssembly — the invoice is never uploaded, and the cost is a one-time runtime download, so the first grade waits on that download and every later one starts straight away. Why not the official KoSIT validator? is a comparison that starts by conceding the awkward parts: KoSIT's validator is free and is the reference implementation, the correctness claim below is derived from their Schematron artifact, and it carries a "when to prefer them" section (XSD schema validation, full Peppol BIS ecosystems, writing ZUGFeRD PDFs — none of which this engine does). The rule index gives one page per rule with the official rule text, the syntaxes it is proven on and a concrete fix hint; the 5-minute worked walkthrough reads one failing invoice end to end instead.

  • Zero dependency. Python 3 (>=3.8) standard library only. No lxml, no Java, no Schematron toolchain, no network calls. pip install verifyhash-einvoice gets you the released package and its einvoice console script from PyPI; from a checkout, python3 einvoice.py validate x.xml needs no install at all and pip install . installs that exact tree (pyproject.toml pins dependencies = [] — a tested contract, see test_packaging.py).
  • Embeddable. The validator is a small pure-Python package (einvoice/parser.py, einvoice/rules.py, einvoice/validate.py, einvoice/cli.py); rules are plain functions over a parsed model, so an ERP or billing system can import it in-process instead of shelling out to a validator service — copying the bare einvoice/ package directory into your tree is a supported (and tested) install method.
  • Self-hostable. Runs fully offline — zero network egress, enforced at the socket layer by test_network_egress.py (see SECURITY.md). The rule corpus and test fixtures are vendored in-repo (corpus/), so the thing you validate against is auditable and pinned — no dependency on a third-party validation API. The supply-chain posture (zero runtime deps, offline, vendored corpus) is written up for evaluators in SECURITY.md, backed by a committed CycloneDX 1.5 SBOM at sbom/bom.json.
  • CI-gateable. ci/ ships a copy-paste build gate (POSIX sh + GitHub Actions / GitLab CI recipes) that fails a build on any non-conformant invoice, naming the violated rule ID. See §4.
  • Deutsche Anleitung. A German-language quickstart whose shell commands are kept byte-identical to the English docs by an automated parity check (test_install_command_drift.py): QUICKSTART.de.md, next to this file in a source checkout. It is named rather than linked on purpose — that file is not on the published main branch yet, so any URL for it would 404 today. The German pages that ARE published are the German landing page and its worked walkthrough.

Safe on untrusted input

Supplier XML is untrusted input, so every production parse entry point is hardened against the classic xml.etree/expat attack classes — using only the standard library (xml.etree.ElementTree + xml.parsers.expat, no lxml, no defusedxml), so the zero-runtime-dependency contract above is unchanged and still proven by test_packaging.py. Concretely, the parser:

  • rejects any <!DOCTYPE>/DTD — internal or external subset — at the expat StartDoctypeDeclHandler, before any entity can be defined;
  • therefore defines and expands no custom entity (a billion-laughs or quadratic-blowup payload aborts in constant time and memory, never materialising the expanded string), and resolves no external entity or external DTD — expat opens no file:///http:// URL, so an XXE such as <!ENTITY xxe SYSTEM 'file:///etc/passwd'> reads nothing (no file-read, no SSRF);
  • turns a refused payload into a bounded, actionable failure — the engine's ordinary not-well-formed report finding and CLI exit code 3, identical to any ill-formed invoice — never a crash, a hang, or a silent pass.

Full guarantee (including the byte-length / element-count / nesting-depth resource ceilings on well-formed-but-hostile input) is written up in SECURITY.md under "Untrusted input / XML entity handling", and is proven end-to-end by test_security.py (billion-laughs, quadratic-blowup, file:// external-entity read with a secret-canary leak check, /etc/passwd XXE, external-DTD SYSTEM) and test_robustness.py, which also assert that a benign XRechnung invoice still parses and validates unchanged.

Read §2 before trusting it with anything. The engine asserts 297 business rules in total — the exact set the code fires, enumerated per rule in COVERAGE.md / coverage_matrix.json and drift-gated by test_coverage_matrix.py against the live rule registries. That total breaks down as: 219 of the 223 official EN 16931 BR-* rule ids in each CEN syntax universe (UBL and CII) — every official rule that can actually fire (every fireable BR-CL-* code-list check is now implemented in both syntaxes) — plus, with --profile=xrechnung, the German XRechnung CIUS + extension layer (all 55 BR-DE-*/BR-DE-CVD-*/BR-TMP-*/BR-DEX-* asserts the UBL artifact carries, and all 49 the CII artifact carries — including the CII-only BR-TMP-3 and BR-DEX-15) and the 21 PEPPOL-EN16931-R* rules KoSIT ships inside the official XRechnung Schematron artifact — the KoSIT-vendored subset only, not Peppol BIS Billing 3.0 support (see §2). The machine-checked gap of official rules neither implemented nor documented as a deliberate exclusion is zero for both universes (fireable_missing == 0): test_coverage_gap.py recomputes it from the vendored official Schematron on every run and fails if the fireable-missing count is ever nonzero. The last admitted gap in the KoSIT XRechnung artifact — the Clean-Vehicle-Directive / temporary family (BR-DE-CVD-*, BR-TMP-*) — is closed with differential proof in both bindings as of 2026-07-11; the machine-checked KoSIT worklists (cvd_tmp_family and peppol_kosit_family in coverage_matrix.json) are empty.

The caveat that keeps that claim honest, stated adjacent to it rather than in a footnote: 4 official ids (BR-CO-05–BR-CO-08) are shipped as literal test="true()" tautologies in the CEN artifacts — asserts that can never fire, in either universe, so implementing them with a differential proof is impossible by construction. Those 4 tautologies are documented with verbatim artifact evidence in COVERAGE.md, the generated per-rule matrix that is the authoritative inventory (it supersedes the partial family-orientation tables in §2, which name only about half the core rules, wherever they disagree). A related asymmetry worth knowing: the total-VAT decimal pair BR-DEC-13/BR-DEC-15 is implemented and differential-proven on the CII leg (that artifact ships real numeric round2 tests), while the UBL artifact's asserts for the same pair are vacuous-by-defect (a predicate-context bug means they can never fire there) — the engine asserts the stated ≤2-decimals intent on UBL anyway, as documented deliberate strictness. This is deliberately not an uncaveated "100%" claim.

How correctness is proven: every implemented rule is differential-tested against the official, normative compiled Schematron artifacts (the legal rulesets). The 2026-07-11 run, all four legs at zero divergences: EN 16931 core on UBL — 209 rules × 1145 real invoices = 239,305 comparisons; XRechnung + KoSIT-vendored Peppol on UBL — 76 graded ids × 1067 invoices = 81,092 comparisons; EN 16931 core on CII — the 96-rule graded subset × 111 invoices = 10,656 comparisons; XRechnung + Peppol on CII — 52 graded ids × 117 invoices = 6,084 comparisons. See CORRECTNESS.md for the method, corpora, and the honest limits of those claims.

CII proof parity — the worklist is now CLOSED. The two bindings share one rule registry, but a rule only earns syntax = UBL + CII in the matrix once it is differentially proven on the CII artifact too, and which rules have that proof is machine-tracked: test_cii_parity.py recomputes the worklist (cii_parity.json) from the live coverage matrix plus a real XML parse of the vendored CII Schematron, and fails on any drift, so the parity gap can neither be hand-edited nor go stale. As of 2026-07-23 the arc is terminal: 281 of the 297 asserted rules are differential-proven on both the UBL and CII bindings, 12 are officially UBL-only, and 4 are CII-only (BR-TMP-3 and BR-DEX-15, whose asserts exist only in the CII artifact, plus BR-DEC-13/BR-DEC-15, whose UBL asserts are artifact-vacuous — see the caveat in §1). The German KoSIT layer is complete on BOTH bindings: all 55 UBL and all 49 CII German-family asserts (BR-DE-*/BR-DE-CVD-*/BR-TMP-*/BR-DEX-*) the vendored artifacts carry are implemented and differentially graded — including the extension-profile scheme-id and attachment checks BR-DEX-01/04/05/06/07/08 on CII. Zero rules remain on the cii-fireable worklist — every one of the 12 UBL-only rules the vendored CII artifacts were measured against is resolved with evidence: 4 are cii-artifact-defective (the CII artifact ships them as a test="true()" tautology or bound to a row whose every $rate in () is vacuously true — they can never fire) and 8 are binding-inapplicable (BR-DEX-02/03/09/10/11/12/13/14: carried by no vendored CII artifact at all, so a CII proof is impossible by construction). Every one of those reasons is re-verified live against the vendored artifacts on each run; the committed worklist and COVERAGE.md are the authoritative state.


1. Why this exists: the legal forcing function

Structured e-invoicing is stopping being optional in the EU. If you issue or receive B2B invoices there, a conformance validator moves from "nice tooling" to "the thing that decides whether your invoice legally exists."

  • Germany (2025–2028). Since 1 January 2025 every German business must be able to receive EN 16931-conformant e-invoices (XRechnung or ZUGFeRD); the obligation to issue them phases in through 2027–2028 depending on turnover, as legislated in the Wachstumschancengesetz. Public-sector suppliers have been required to send XRechnung since 2020.
  • France (2026–2027). Reception of structured e-invoices becomes mandatory for all VAT-registered businesses in September 2026; the obligation to issue phases in September 2026 (large/mid-size) through September 2027 (SMEs), via the PDP/e-reporting reform. The French formats (Factur-X, UBL, CII) are all EN 16931 profiles.
  • EU ViDA. The "VAT in the Digital Age" package (adopted 2025) makes structured e-invoicing the default for intra-EU B2B and adds digital reporting requirements on a ~2030 horizon — again on the EN 16931 core.

Dates above reflect the legislation as understood at time of writing (mid-2026); phase-ins shift, so verify against current law before relying on them. The direction, however, is one-way: every invoice will need to pass machine-checkable conformance rules, and the party that fails them eats the rejection, the payment delay, or the VAT problem. Buyers on a deadline need a validator they can run themselves, embed in their pipeline, and audit — not a black-box web form.


2. HONEST coverage — read this before using it

Profile: XRechnung 3.x (the German CIUS of EN 16931-1:2017), UBL 2.1 Invoice and UN/CEFACT CII syntaxes.

Live scope, stated before anything else. The engine asserts 297 business rules. That is 219 of the 223 official EN 16931 BR-* assert ids in each CEN syntax universe — the four it leaves are BR-CO-05, BR-CO-06, BR-CO-07 and BR-CO-08, which CEN ships as literal test="true()" tautologies that can never fire for anyone — plus 55 German national asserts on UBL and 49 on CII (55 of 55 and 49 of 49 of what the official KoSIT XRechnung 3.0.2 Schematrons carry: the BR-DE-* CIUS core, the BR-DEX-* extension profile and the BR-DE-CVD-*/BR-TMP-* families), plus the 21 KoSIT-vendored PEPPOL-EN16931-R* asserts. Verify it in one command without reading further: python3 -m einvoice info --json prints "rule_count": 297, and python3 -c "from einvoice import rules, rules_xrechnung; print(len(rules.ALL_RULES), len(rules_xrechnung.ALL_RULES), len(rules_xrechnung.CII_DE_RULES))" prints 219 55 49.

The two tables below are NOT the implemented set — do not size the engine from them. They are a partial, family-by-family orientation view: the core table names 108 concrete ids, about half of the 219 the engine asserts, and it is kept only because reading "is this family covered at all?" off a short table is faster than scanning 297 rows. COVERAGE.md / coverage_matrix.json — regenerated from the live rule registries by gen_coverage.py and drift-gated against them by test_coverage_matrix.py — is the authoritative per-rule inventory, with the official rule text and per-syntax proof state for every one of the 297 ids. Where it and these tables disagree, it wins.

Implemented — EN 16931 core (family orientation; full inventory in COVERAGE.md)

Family Rule IDs
Header existence/cardinality BR-01, BR-02, BR-03, BR-04, BR-05, BR-06, BR-07, BR-08
Seller/Buyer postal address BR-09 (seller country code), BR-10 (buyer postal address), BR-11 (buyer country code)
Payee & Seller tax representative BR-17 (payee name when payee differs from seller), BR-18 (tax representative name), BR-19 (tax rep postal address), BR-20 (tax rep country code)
Payment instructions BR-49 (payment means type code), BR-50/BR-61 (credit-transfer account id), BR-51 (card PAN truncation — official warning flag, non-blocking)
References & addresses BR-55 (preceding invoice reference), BR-57 (deliver-to country code), BR-62/BR-63 (seller/buyer electronic-address scheme id)
Document totals presence BR-12 (Σ line net), BR-13 (total w/o VAT), BR-14 (total with VAT), BR-15 (amount due)
Invoice-line cardinality BR-16, BR-21, BR-22, BR-24, BR-26
Invoice-line content BR-25 (item name), BR-27 (net price not negative), BR-28 (gross price not negative), BR-29/BR-30 (invoicing / line period end >= start), BR-CO-04 (line VAT category code)
Allowance/charge existence BR-31, BR-32, BR-33, BR-36, BR-37, BR-38, BR-41, BR-42, BR-43, BR-44
Code list BR-CL-01 (UNTDID 1001 invoice type code)
Arithmetic co-constraints BR-CO-10, BR-CO-13, BR-CO-14, BR-CO-15, BR-CO-16, BR-CO-17
VAT breakdown presence BR-CO-18
VAT breakdown group (BG-23) BR-45 (taxable amount), BR-46 (tax amount), BR-47 (category code), BR-48 (category rate)
VAT-category consistency BR-S-01, BR-Z-01, BR-AE-01, BR-E-01, BR-G-01, BR-IC-01, BR-O-01
Standard-rated (S) category BR-S-02/03/04 (Seller VAT id for S line/allowance/charge), BR-S-05/06/07 (S rate > 0), BR-S-09 (tax = taxable × rate), BR-S-10 (no exemption reason on S)
Zero-rated (Z) category BR-Z-02/03/04 (Seller VAT id for Z line/allowance/charge), BR-Z-05/06/07 (Z rate = 0), BR-Z-08 (taxable = Σ Z line nets − allowances + charges), BR-Z-09 (tax = 0), BR-Z-10 (no exemption reason on Z)
Exempt (E) category BR-E-02/03/04 (Seller VAT id for E line/allowance/charge), BR-E-05/06/07 (E rate = 0), BR-E-08 (taxable = Σ E line nets − allowances + charges), BR-E-09 (tax = 0), BR-E-10 (exemption reason text/code REQUIRED on E)
Decimal precision (max 2 places) BR-DEC-01, BR-DEC-02, BR-DEC-05, BR-DEC-06, BR-DEC-09, BR-DEC-10, BR-DEC-11, BR-DEC-12, BR-DEC-14, BR-DEC-16, BR-DEC-17, BR-DEC-18, BR-DEC-19, BR-DEC-20, BR-DEC-23

Plus two structural checks: S-WF (well-formed XML) and S-ROOT (the root is a gradeable one — UBL Invoice, UBL CreditNote, or CII CrossIndustryInvoice). Rule wording follows the vendored EN 16931 Schematron (corpus/cen-en16931/ubl/schematron/abstract/EN16931-model.sch) verbatim.

Implemented — XRechnung CIUS layer (--profile=xrechnung; family orientation, full inventory in COVERAGE.md)

Family Rule IDs
Mandatory German fields BR-DE-1 (payment instructions), BR-DE-15 (BuyerReference), BR-DE-2/5/6/7 (seller contact + name/phone/email), BR-DE-3/4 (seller city/post code), BR-DE-8/9 (buyer city/post code), BR-DE-10/11 (deliver-to city/post code), BR-DE-14 (VAT rate per breakdown)
Seller VAT identification BR-DE-16
Type-code / spec-id restrictions BR-DE-17, BR-DE-21, BR-DE-26
Payment-means grouping BR-DE-23-a/-b (credit transfer), BR-DE-24-a/-b (card), BR-DE-25-a/-b (direct debit), BR-DE-30, BR-DE-31 (SEPA mandate fields)
Content quality (warnings) BR-DE-19, BR-DE-20 (IBAN mod-97), BR-DE-27 (phone), BR-DE-28 (email), BR-DE-18 (Skonto grammar, fatal)
Delivery-date recommendation BR-DE-TMP-32 (information)

That is every BR-DE-* assert in the official KoSIT XRechnung 3.0.2 UBL Schematron (the numbering has official gaps: no BR-DE-12/13/29 exist there). Those 32 CIUS-core asserts are only part of the German layer, though: counting the extension and CVD/TMP families described in the next paragraph, the engine asserts 55 German ids on UBL and 49 on CII — every assert either official artifact carries. Severities mirror the official flags — only fatal rules affect the exit code; warnings/information are reported in --json. The 15 BR-DEX-* extension-profile rules have since been implemented as well (14 UBL asserts plus the CII-only BR-DEX-15 — sub invoice lines unsupported, warning; the seven extension asserts the CII artifact carries — BR-DEX-15 plus the scheme-id/attachment group BR-DEX-01/04/05/06/07/08 — are all graded on the xrechnung-cii differential leg), and so has the complete CVD/TMP family the same artifacts carry: the Clean-Vehicle-Directive profile (BR-DE-CVD-01–05, BR-DE-CVD-06-a/-b, BR-TMP-CVD-01 — gated on the CVD CustomizationID …xrechnung:cvd_0.9, inert on plain invoices), BR-TMP-2 (BT-124 must be an absolute URL; warning), and BR-TMP-3 — which exists only in the CII binding (the vendored UBL artifact carries no such assert), so it is implemented and proven on CII only (syntax = CII in COVERAGE.md). Every family rule is differential-proven in each binding whose artifact carries it, with dedicated FIRE and PASS fixtures; the family enumeration is machine-checked (cvd_tmp_family in coverage_matrix.json, recomputed live by test_coverage_gap.py). All 21 PEPPOL-EN16931-R* rules the same KoSIT artifact vendors are implemented in both bindings (differential-proven; the family enumeration stays machine-checked in COVERAGE.md) — that is the KoSIT-vendored subset only, not Peppol BIS Billing 3.0 support.

Differential result vs. the OFFICIAL Schematron (this run)

The strongest correctness evidence: differential.py runs each invoice through the official, normative compiled Schematron artifacts (Saxon → SVRL) and through our validator, then compares — for every invoice and every graded rule ID — whether each engine fires. The Schematron is the legal artifact; any disagreement is our bug. The 2026-07-11 run:

LEG 1  EN 16931 core, UBL ............... 1145 invoices x 209 rules = 239,305
LEG 2  XRechnung + Peppol batch, UBL .... 1067 invoices x  76 rules =  81,092
LEG 3  EN 16931 core, CII ............... 111 invoices x 96 graded  =  10,656
LEG 4  XRechnung + Peppol batch, CII .... 117 invoices x 52 graded  =   6,084
TOTAL AGREEMENT ...... 100.0000% on every leg
divergences .......... 0 false-positives + 0 misses, all legs

All implemented rules agree with their official Schematron on every graded invoice. Reproduce it (needs saxonche importable): python3 differential.py (or ... en / ... xrechnung for one leg). Method, corpus breakdown, the divergences that were found and fixed, and the honest scope limits are documented in CORRECTNESS.md. This proves faithfulness only for the graded rule × syntax pairs — the per-rule graded sets, and the reasons some implemented rules are not graded on CII, are enumerated in COVERAGE.md — not EN 16931 or XRechnung as a whole (see §2 "NOT covered").

Conformance result (this run)

conformance.py drives the real CLI as a subprocess over every vector in corpus/vendored/ (14 valid + 52 invalid). The invalid vectors are Difi <testSet> files, so the harness extracts every embedded <Invoice> fragment and checks each <error>/<success> assertion individually — 284 embedded assertions in total.

total vendored vectors ............. 66  (14 valid + 52 invalid)

VALID-vector pass rate ............. 14/14   100.0%   (miss = FALSE POSITIVE)
COVERED-INVALID detection rate ..... 52/52   100.0%   (correct rule id fired)
OUT-OF-SCOPE invalid vectors ....... 0

embedded-block detail (Difi assertions):
  <error>   fragments: 140 total -> 140 detected, 0 missed, 0 wrong-id, 0 oos
  <success> fragments: 144 total -> 144 clean,  0 FALSE POSITIVE,   0 oos

HARD FAILS: 0   -> RESULT: PASS

Every covered invalid vector is detected with the correct labeled rule ID across all 140 error fragments; every valid vector and all 144 must-pass fragments come back clean — zero false positives on this corpus.

The harness itself was mutation-tested (then the code restored byte-identical): neutering br_06 produced 4 WRONG RULE ID hard fails; forcing br_01 to always fire produced 13 FALSE POSITIVE hard fails; removing BR-Z-01 was correctly reported as out-of-scope, not silently passed. A green run means something because the harness demonstrably goes red.

Reproduce it: cd einvoice && python3 conformance.py (exit 0 = pass; exit 1 prints the offending file, block, and expected vs. actual rule IDs).

KoSIT test-suite conformance headline

End-to-end, on KoSIT's own official documents: 39 of 39 in-scope XRechnung test-suite documents in UBL syntax (the plain xrechnung_3.0 CIUS) are classified exactly as the suite labels them — accepted as valid. The same holds for the CII (UN/CEFACT) binding: 39 of 39 in-scope *_uncefact.xml documents, routed through the shipped CII engine (parser_cii + rules.ALL_RULES + rules_xrechnung.evaluate_cii), are classified exactly as the suite labels them. The 8 out-of-scope documents (the extension and CVD guidelines, in both syntaxes) are machine-listed with their exact reason in testsuite_conformance.json; see CORRECTNESS.md §4a for provenance and scope.

NOT covered yet (deliberate scope limits — do not rely on these)

  • BR-TMP-3 and BR-DEX-15 are CII-only by artifact design — the BR-DE-* CIUS core, the BR-DEX-* extension layer AND the Clean-Vehicle-Directive/temporary family (BR-DE-CVD-*, BR-TMP-CVD-01, BR-TMP-2, BR-TMP-3) are all implemented (see COVERAGE.md), but the vendored UBL artifact carries no BR-TMP-3 or BR-DEX-15 assert, so those two rules are asserted and proven on the CII syntax only — a UBL differential proof is impossible by construction.
  • No Peppol BIS Billing 3.0 support. All 21 PEPPOL-EN16931-R* rules KoSIT ships inside the official XRechnung Schematron artifact ARE implemented (both bindings, differential-proven; the enumeration stays machine-checked in COVERAGE.md), but the OpenPeppol ruleset proper (its own Schematron + test corpus) is a separate, not-vendored artifact — nothing beyond the KoSIT-vendored asserts is claimed.
  • French national CIUS not yet checked (the EN 16931 core for French invoices IS). This is a technical scope statement distinct from the French legislative timeline in §1 (reception mandatory Sept 2026, issuance phasing to Sept 2027). A French B2B invoice — Factur-X (a CII payload in a PDF/A-3 container), UBL, or CII — is an EN 16931 profile, so the full EN 16931 core (BR-* semantic model) already applies to it here, and the Factur-X path is first-class: the shipped PDF container extractor (einvoice.pdf_container) pulls the embedded CrossIndustryInvoice XML and validates it to EN 16931 depth via the exact same core engine (einvoice.rules.ALL_RULES) — measured end-to-end by test_facturx_profile_scope.py, and detailed in COVERAGE.md under "French CIUS scope". What a French CIUS adds on top of EN 16931 — the FNFE-MPE / Chorus Pro Factur-X French national business rules and code lists — is not checked: that official Schematron is not vendored in corpus/, so those French-CIUS-specific rules are out of scope until it is (nothing French-specific is fabricated or approximated from prose). So "validates the EN 16931 core for a French invoice" is TRUE and useful, while full French CIUS conformance is NOT claimed — the same honesty label this README applies to Peppol BIS Billing 3.0 above.
  • 4 official BR-* ids per CEN universe are documented deliberate exclusions, NOT coverage: the 4 official tautologies BR-CO-05–BR-CO-08, shipped as literal test="true()" asserts in both CEN universes, so no implementation could ever be differentially proven for them. The BR-CL-* code-list class carries no deferrals — the engine asserts every fireable code-list check in both syntaxes. Per-rule reasons and verbatim artifact evidence: COVERAGE.md §Exclusions.
  • No XSD (structural schema) validation. Layer S-XSD is deferred; only well-formedness and the root element are checked structurally.
  • UBL CreditNote IS validated; no signatures. A UBL 2.1 CreditNote (root CreditNote-2:CreditNote) is routed through the SAME EN 16931 core engine as an Invoice (the official Schematron binds both roots symmetrically), differentially proven at 0 divergences over the vendored CreditNote corpus — see COVERAGE.md §"UBL CreditNote scope". CII (CrossIndustryInvoice) and Factur-X/ZUGFeRD PDF containers ARE also validated — since 0.2.7 by einvoice validate <invoice.pdf> itself as well as by einvoice.report / validate-batch — with the graded CII rule subsets in COVERAGE.md. The container reader handles classic-layout PDFs; encrypted, cross-reference-stream (PDF 1.5+) and truncated files are refused as unsupported-container (exit 3), never guessed at. XML signatures are not checked.
  • The 100% figures are agreement/pass rates for the implemented, graded rule × syntax pairs only (the differential.py legs and the conformance.py vendored vectors quoted above). They are 100% of a limited, honest scope, not 100% of the standard (structural XSD validation is not performed, and the national CIUS layers beyond German XRechnung are out of scope).

See SPEC.md §6 for the full deferred list.


3. Install / embed / usage

New here? QUICKSTART.md is a tested 5-minute copy-paste path: install, validate a passing and a failing sample invoice, and read the exit code + --json — every command in it is run against the live engine by test_quickstart.py.

Three ways in, one code path (einvoice/cli.py — proven identical by test_packaging.py). Mind the labels in the block below: the (a) and (b2) forms name einvoice.py or a local source directory, so they need a repository checkout (or a vendored copy of one); the (b) forms are what the published wheel gives you and need nothing from this repo. test_doc_commands_from_wheel.py enforces that split.

# a) straight from a checkout — nothing to install
python3 einvoice.py validate <invoice.xml> [--json] [--profile=en16931|xrechnung]
python3 -m einvoice   validate <invoice.xml> [--json] [--profile=en16931|xrechnung]

# validate a whole folder or glob in one run (reuses the same rule engine):
python3 -m einvoice   validate-batch <dir|glob> [--json] [--quiet] [--profile=…]

# b) pip-install the released package from PyPI — the normal route
python3 -m pip install verifyhash-einvoice
einvoice validate <invoice.xml> [--json] [--profile=en16931|xrechnung]
einvoice validate-batch <dir|glob> [--json] [--quiet] [--profile=en16931|xrechnung]

# b2) …or install a checkout / vendored copy instead — same code, no package
#     index needed: the right answer for air-gapped CI or an exact pin
python3 -m pip install /path/to/einvoice     # zero runtime dependencies

c) embed in-process — vendor the bare einvoice/ package directory (the pure-Python package alone, no corpus needed at runtime) or pip-install it, then:

from einvoice import validate_file, NotWellFormed

result = validate_file("invoice.xml", profile="xrechnung")
if not result.ok:
    for v in result.violations:          # each: rule_id, message, element
        print(v.rule_id, v.message)      # e.g. "BR-DE-15 The element ..."

--profile=xrechnung layers the 55 German BR-DE-*/BR-DE-CVD-*/ BR-TMP-*/BR-DEX-* rules on top of the core (default profile: core only).

Exit codes (stable contract):

Code Meaning
0 passes every implemented fatal rule (warnings may still be reported)
1 at least one implemented fatal rule failed
2 usage error (bad args, missing file, unknown profile)
3 input is not well-formed XML

Default output on failure is the first fatal violated rule, human message, and offending element, followed by two indented lookup lines — how to fix: einvoice --explain <RULE-ID> and, when the shipped remediation catalogue covers that rule, its rule page: URL under https://verifyhash.com/einvoice/rules/. On a pass with non-fatal findings, the same summary lists them ([<severity>] <RULE-ID>: <message>, first 10, then a count and a pointer to --format json); the verdict line still reads PASS: and, at the default --fail-on fatal, the exit code is still 0. --json emits the full machine-readable result:

{
  "source": "invoice.xml",
  "valid": false,
  "violation_count": 2,
  "violations": [
    {
      "rule": "BR-06",
      "message": "An Invoice shall contain the Seller name (BT-27).",
      "element": "cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName",
      "severity": "fatal",
      "field": "cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName",
      "title": "An Invoice shall contain the Seller name (BT-27).",
      "fix_hint": "Add the required element at `cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName`: An Invoice shall contain the Seller name (BT-27).",
      "terms": ["BT-27"],
      "location": "cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName"
    }
  ]
}

The first four keys are the identity of the finding; field repeats element under the name python3 -m einvoice.report uses, and title / fix_hint / terms / location are relayed from the committed remediation_catalog.json so a CI job can print what to change, not just a rule id. All five are always present: a rule with no catalog entry emits null / [] rather than dropping the keys. A violation that could be attributed to a concrete source position additionally carries source_line (that one key really is conditional).

A valid: true result means "no implemented fatal rule fired" — given §2, it does not yet mean "legally conformant XRechnung."

CLI contract

The command line is a small, stable surface. Everything below is pinned by test_cli.py (ergonomics) and test_cli_sb.py / test_packaging.py (the exit-code and packaging invariants).

Exit codes — the whole point of the tool is to gate a build, so the exit code is the contract, not the prose. The receipt subcommand collapses code 3 into a FAIL receipt (exit 1) because a receipt must always emit a document; codes 2 and 3 below therefore describe validate. A receipt's tamper-evidence is a recompute-and-compare of content_sha256 over the canonical body. einvoice receipt --verify <receipt.json> runs exactly that check for you in one command (details under receipt --verify below); the same check is reproducible in any language without our binary — see RECEIPT-VERIFICATION.md in a source checkout (named, not linked: it is not on the published main branch yet, so a URL for it would 404) for the exact recipe and its honest limit (the outer hash is a body digest, not self-covering).

Code Meaning
0 passes every implemented fatal rule (advisory warnings may still be reported)
1 at least one implemented fatal rule failed
2 usage error — bad/missing arguments, no such file, or an unknown --profile
3 input is not well-formed XML (or a rejected DTD/entity/XXE payload, folded to the same not-well-formed outcome) — validate only

Syntax-binding warnings and XRechnung warning/information findings are advisory: they are reported but never move the exit code off 0/1.

Flags

  • --version — prints the packaged einvoice.__version__ (read from the installed package, not a hardcoded string) and exits 0. It takes precedence over everything: no subcommand or file is required (einvoice --version / python3 -m einvoice --version).
  • --help / -h — print the usage synopsis plus a one-line description of every command (validate, validate-batch, receipt, info, --show-config, --version) to stdout and exit 0. Like --version it is an informational precedence flag — handled before any subcommand or file, so einvoice --help is a valid first command; -h is a byte-identical alias. It changes no validation behaviour and reads no input file. The two-word set of commands it lists is bound to the VALID_SUBCOMMANDS registry by test_cli_help.py, so a new subcommand cannot go silently undocumented.
  • --json — emit the full machine-readable result instead of the human summary (shape below and in REPORT-SCHEMA.md).
  • --format <fmt> (also --format=<fmt>) — select the output shape on validate and validate-batch. The vocabulary is the same nine names einvoice info advertises under formats, read from the one registry (einvoice.report.REPORT_FORMATS) rather than a second list: json, junit, sarif, gitlab, github, azure, html, badge, text. --format json is an exact alias for --json (same code path, byte-identical output) and --format text is the default human summary, so the flag is purely additive. The other seven are rendered by einvoice.report.render_report — the single emitter dispatch python3 -m einvoice.report --format <fmt> itself uses — so the report bodies are identical on both surfaces for the same invoice and profile. The common CI line is einvoice validate --format sarif invoice.xml > results.sarif, which GitHub code scanning ingests directly. The verdict is graded by this subcommand's rules: its en16931 default profile (the sibling entry point defaults to xrechnung — pass --profile explicitly when comparing them), its --fail-on threshold, and the same non-blocking treatment of syntax-binding findings the text/JSON forms use. validate-batch accepts the aggregate-capable subset json/junit/text; the other six describe one invoice, so a directory under those is a usage error (2) naming the per-file command. An unknown format, --format with no value, --format twice with conflicting values, or --format together with --json are all clean usage errors (2) — never a silent last-wins. --quiet is a no-op for a machine format, exactly as with --json — the document is the output, so there is no summary to silence. --lang reaches the two human formats, html and text: --lang de --format html renders a German document (official KoSIT rule text where the rule has it, German chrome, <html lang="de">). The seven machine bodies — json, junit, sarif, gitlab, github, azure, badge — are byte-identical with and without --lang de: their consumers key on the rule id, and the json document is what --baseline diffs on a key that includes the message, so localising it would score every finding as resolved-plus-new the first time a pipeline changed its locale. See EXIT-CODES.md for the full contract.
  • --quiet — suppress the human PASS/FAIL/Syntax-binding warnings summary on stdout. The exit code is unchanged, and --quiet does not suppress --json: validate --quiet --json still prints the JSON (quiet only silences the human summary). It has no effect on receipt, whose canonical JSON is its output.
  • --profile=en16931|xrechnung — select the rule set (default en16931).
  • --lang=en|de — language of human-facing text only (default en), accepted on validate, validate-batch and --explain. Under de a rule that has an official German assert renders it; all other rules keep their English message. It changes nothing else — same rules fire, same offending element, byte-identical --json, same exit code. See §German-language messages for the exact coverage.
  • --explain <RULE-ID> [--lang=en|de] — look up one rule id from a failure. Prints the remediation_catalog.json entry — what the rule requires, the BT/BG business terms, the XML location hint, the one-line fix, the severity, and the official Schematron assert it is derived from — then exits. It reads no invoice, resolves no config and produces no verdict, so it is safe to run anywhere; lookup is case-insensitive. Exit 0 when the id is catalogued, 1 when it is not (a lookup miss, not a verdict — stdout stays empty so the block is safe to capture), 2 if the rule id is missing from the command line. This is the same code path as python3 -m einvoice.report --explain <RULE-ID> and prints byte-identical output; the flag exists on einvoice too because that is where you already are when a rule id appears in a FAIL: line. --lang de reads the block in German — the catalog's title_de / fix_de, the official KoSIT message on the rules that have one, plus a german line naming which of those two provenances you got; anything without a German string stays English. Both entry points accept it and stay byte-identical. Exact per-field coverage: EXIT-CODES.md.
  • --show-config — read-only observability: resolve the effective format / fail-on / lang exactly as a real validate run would (explicit flag > config file > built-in default) and print each with its source — flag, the config filename (.einvoice.toml / pyproject.toml), or default — then exit 0. It reads no input file and runs no validation; like info it writes only stdout, nothing on stderr on success. A misconfigured config file still errors (2) with the same message a real run gives — the resolution and vocabulary checks are shared, never re-implemented. Purely additive: omitting the flag leaves every validate run byte-identical to today. Pinned by test_show_config.py; the config-file layer it reports is documented in QUICKSTART.md §6.

Input — validate <invoice.xml> reads a file; validate - reads the invoice XML from stdin (e.g. curl -s … | einvoice validate -). The stdin bytes are staged to a temporary file and validated through the identical DTD/XXE/resource-hardened parser used for on-disk files — piping in does not get a relaxed parse path (see SECURITY.md). receipt reads a file only.

receipt --issued-at <value> (also --issued-at=<value>) — record when the grading happened, using a value you supply. The tool never invents one and never reads a clock: an unstamped receipt is deliberate (a receipt that timestamped itself proves nothing to the recipient — the tool could write any date it liked), whereas a value your CI job took from its own start-time record is a claim the recipient can cross-check against your job log. Pass whatever string that record gives you; the CLI stores it verbatim and imposes no date format, because the receipt shape never defined one:

$ einvoice receipt --issued-at 2026-01-02T03:04:05Z invoice.xml
{"content_sha256":"44b7f354…","receipt":{…,"issued_at":"2026-01-02T03:04:05Z",…}}

issued_at is an ordinary field of the hashed body, so supplying it changes content_sha256 — the same invoice stamped 2026-01-02T03:04:05Z and stamped 2026-01-03T… are two different, each internally consistent, receipts, and receipt --verify rejects any later edit to the stamp. Omit the flag and nothing moves: the receipt is byte-identical to what this tool has always emitted and carries no issued_at key at all. The flag is a usage error (exit 2) on validate / validate-batch / info and alongside receipt --verify, which only re-hashes an existing document and writes no field. Honest limit, unchanged by the stamp: this is tamper-evident, not tamper-proof — the date is only as trustworthy as whoever ran the command, and an attacker who rewrites the body and recomputes the hash produces a forged receipt that still verifies (see RECEIPT-VERIFICATION.md). What the stamp buys you is that the date cannot be changed afterwards without breaking the hash.

receipt --verify <receipt.json> — the one-command integrity check for a receipt einvoice receipt previously emitted. It re-hashes the receipt's canonical body with the exact same canonicalizer that built it and compares to the stored content_sha256, then reports the result. It validates nothing and changes no verdict — it only re-hashes bytes already in the receipt:

$ einvoice receipt invoice.xml > receipt.json
$ einvoice receipt --verify receipt.json
VERIFIED: receipt.json
  content_sha256 = 6459697e0a75de9454eeac449a0c79f5a172945470fa6e1db4dbf49e6699b391

Exit codes reuse the existing taxonomy (no new code): 0 + VERIFIED when the hash matches; 1 + TAMPERED (with the recomputed vs stored hash) when the body no longer matches its hash — whether a field was altered or content_sha256 itself was corrupted; 2 + an error: line on stderr (no traceback) when the file is not a readable receipt (non-JSON / garbage / truncated, valid JSON that is not a receipt, or a nonexistent path). --verify is valid only for receipt. This is a convenience over the manual recipe, not a stronger guarantee: it shares the recompute-and-compare limit documented in the checkout-only RECEIPT-VERIFICATION.md, which also gives the zero-trust recipe for consumers who won't run our binary.

Batch — validate-batch <dir|glob> validates a whole set of invoices in one run. The argument is either a directory (every *.xml/*.pdf invoice file under it, recursively; dotfiles and dot-directories skipped) or a shell-style glob (invoices/*.xml, or 'invoices/**/*.xml' for a recursive match — quote it so your shell doesn't pre-expand it). Every file goes through the same hardened parser and rule engine as validate, so a hostile DOCTYPE/entity file is reported as an ERROR (never parsed, never aborts the batch), and the directory and glob forms produce byte-identical aggregate counts over the same file set. It reuses the batch engine in einvoice/report.py (build_batch_report / build_batch_report_from_files / batch_exit_code / build_batch_text) verbatim — no aggregation or rule logic is re-implemented, and each per-file report is byte-identical to validating that file on its own. That means the per-file violations[] under --json carry the same nine keys (rule, message, element, severity, field, title, fix_hint, terms, location) as einvoice validate --json, so a CI job can run validate on a pre-commit hook and validate-batch on a nightly sweep with one parser — element was the one key the batch records were missing before 0.2.7, and test_json_surface_parity.py now compares the two surfaces so it cannot recur. Output is a per-file PASS/FAIL/ERROR line plus the rule ids behind its counts, an aggregate tally, a "most violated rules" ranking and one einvoice --explain <RULE-ID> pointer — or the aggregate einvoice-conformance-batch/v1 dict with --json; --quiet suppresses the human summary but keeps the exit code (and still emits JSON under --json). Concretely, einvoice validate-batch --profile xrechnung examples/01-missing-fields prints

FAIL  examples/01-missing-fields/broken.xml  2 fatal, 0 warning
  3 finding(s) total: 2 fatal, 1 non-fatal (--format json carries every field of each)
    [fatal] BR-DE-2: The group 'SELLER CONTACT' (BG-6) must be transmitted.
    [fatal] BR-DE-15: The element 'Buyer reference' (BT-10) must be transmitted.
    [information] BR-DE-TMP-32: The invoice should state the delivery/service date: …
PASS  examples/01-missing-fields/fixed.xml  conformant
  1 finding(s) total: 0 fatal, 1 non-fatal (--format json carries every field of each)
    [information] BR-DE-TMP-32: The invoice should state the delivery/service date: …

2 files: 1 passed, 1 failed  (2 fatal, 0 warning across all files)

Most violated rules (rule id, files affected):
  BR-DE-TMP-32  2 files
  BR-DE-2       1 file
  BR-DE-15      1 file

Explain any rule above: einvoice --explain BR-DE-2

Two honest limits on that listing. Each file lists at most 11 findings (einvoice.report._BATCH_RULE_LIST_CAP, the batch twin of the single-file report's 10-plus-headline budget) and then says ... N more not shown — use --format json for all N; the "most violated rules" block is bounded by the same number and discloses the same way. And note a file can PASS and still list findings — fixed.xml above is conformant, but the advisory BR-DE-TMP-32 is still worth seeing. An empty directory or zero-match glob is reported honestly as file_count: 0 with a note, exit 0 — never a traceback. The exit code follows the documented report precedence (fatal outranks parse): 0 when every file passes, 1 if any file has a fatal violation, 3 if some file only errored (not-well-formed / unsupported container) and none had a fatal. Pinned by test_cli_batch.py.

Build introspection — einvoice info reports what this build contains, read-only: no input file, nothing validated, exit 0 (extra arguments are a usage error, exit 2). Human form is stable key: value lines; einvoice info --json emits the same payload as one sorted-keys JSON object with exactly the keys version, profiles, formats, rule_count, coverage and attestation_sha256. Every value is read or recomputed at runtime from the package and its committed artifacts — the packaged __version__, the PROFILES tuple, the einvoice.report.REPORT_FORMATS constant, the committed coverage matrix, the live syntax-binding evaluator plus its catalog totals, and attestation.json's content_sha256 — never retyped literals, so the output can't silently drift from the build (pinned by test_info.py). Example:

python3 -m einvoice info --json | python3 -m json.tool

A tested fail-fast CI recipe built on this output — assert a required profile/format in one line before your validate step — is in QUICKSTART.md §5 (pinned by test_ci_capability_recipe.py).

--json shape — the exact field-by-field schema of the --json result (including the syntax_bindings array and its two count fields) is documented in REPORT-SCHEMA.md. A machine-readable JSON Schema (report.schema.json) for this shape is planned; once published it will live alongside REPORT-SCHEMA.md, and this contract is written to stay compatible with it — the CLI does not depend on that file existing.

German-language messages (--lang de)

--lang de swaps the language of the human-facing failure message only. Which rules fire, the offending element, the --json payload and the exit code are all unchanged — the JSON output is byte-identical with or without the flag, as are the other six machine report bodies (junit, sarif, gitlab, github, azure, badge); all seven are pinned byte-for-byte by test_lang.py. It reaches the terminal summary and the two human report formats: --format html produces a German document (German title, heading, banner and labels, and <html lang="de"> so the declaration matches the content) and --format text swaps the message on each finding line. The coverage is deliberately narrow, and stated here without spin:

  • Exactly 50 rules carry an official German message, surfaced by --lang de. These are precisely the remediation_catalog.json entries tagged de_source == "kosit" — the rules whose vendored KoSIT XRechnung <sch:assert> text is itself German (the BR-DE-* / BR-DE-CVD-* / BR-TMP-* / BR-DEX German-authored family). test_lang.py pins that count and re-extracts each German string from the vendored .sch on disk to prove it is genuinely present, not fabricated. The number here is the count of message_de fields in the catalog; a build gate fails if the prose and the catalog ever disagree, so it cannot silently drift.
  • That German text is lifted VERBATIM from the vendored KoSIT XRechnung Schematron (corpus/xrechnung-schematron/schematron/{ubl,cii}/XRechnung-*-validation.sch), byte-for-byte, each message_de tagged with the {artifact, assert_id} it came from. We do not machine-translate and do not hand-translate any error text: a message_de is only ever the official KoSIT assert as KoSIT wrote it.
  • Every other rule is English-only by design. The EN 16931 core (BR-*, BR-CO-*, BR-CL-*) and the other non-German-sourced rules (e.g. the English-authored PEPPOL asserts) have no official German assert text to quote, so under --lang de they fall back to their English message unchanged. This is silence-with-reason, not a coverage gap: showing the authoritative English assert is more honest than inventing a German one. There is no machine translation anywhere in this surface. In the HTML report such a finding is prefixed with a visible [en], its paragraph is tagged lang="en", and one note in the document explains the marker — an English sentence sitting silently inside a lang="de" page is how a reader comes to believe they are holding the German legal wording.
  • Rule titles and fix hints are German on all 297 catalogued rules. The fallback above is about the assert message only. title_de and fix_de are populated on every catalog entry, and they are what --lang de actually renders — in the HTML report the headline and the Behebung instruction are German even on a finding whose message fell back to [en], and --explain --lang de prints the same two fields. Of the 297 German titles, 50 are the KoSIT <sch:assert> wording verbatim and the other 247 are a project-authored translation of our own English catalog line; all 297 fix hints are project-authored, the 50 KoSIT rules included. The artifact marks which is which per rule — the catalog's de_source field is kosit or translation, and both the report note and the --explain german line print that value — so project wording is never passed off as the standard's.

This official-German message is a CLI / report human-message feature: it is not injected into --json, and this task added no field to the generated www/ rule pages. (Those bilingual rule pages already render the catalog's German title/fix verbatim from the same source, each honestly labelled by its de_source as either the official KoSIT assert or a clearly-marked translation — pre-existing behaviour, not part of the --lang de message surface.)

Syntax-binding findings (syntax_bindings)

Alongside the EN 16931 / XRechnung BR-* business rules, the two vendored CEN preprocessed Schematron artifacts also carry syntax-binding asserts (UBL-CR-*/UBL-DT-*/UBL-SR-* for UBL, CII-DT-*/CII-SR-* for CII) — pure syntax-layer restrictions such as "this element MUST NOT appear", "at most one of X", or a decimal-place cap, not semantic business rules. A restricted, data-driven evaluator (einvoice/syntax_binding_eval.py — a closed XPath subset, not a general processor) mirrors 735 of 756 UBL + 506 of 583 CII of them, each differential-proven equivalent to the official CEN Schematron at 0 divergences over the corpus; the remaining 98 (21 UBL + 77 CII) are left machine-listed as known-open in COVERAGE.md — never guessed, never silently dropped. These counts are kept strictly separate from the 297 business rules and are recomputed live by test_syntax_binding.py.

einvoice validate --json surfaces the UBL findings under a distinct syntax_bindings array (each with id, category "syntax-binding", severity, flag, message, element), plus syntax_binding_fatal_count and syntax_binding_warning_count. All implemented ids carry the official flag=warning, and this category is advisory only: a syntax-binding finding never changes valid and never changes the exit code — the 0/1 verdict above stays driven solely by fatal BR-* violations. The human summary adds one line, Syntax-binding warnings: N. This array carries UBL ids only — not because CII is refused (the CLI grades a raw CrossIndustryInvoice on its business rules like any other document, see EXIT-CODES.md) but because report.syntax_binding_section runs only the implemented UBL entries, so a CII document validates normally and reports syntax_bindings: []. The CII binding's own asserts are evaluated by einvoice.syntax_binding_eval.cii_fired_ids and differentially proven by the differential.py sbcii leg. Full field docs: REPORT-SCHEMA.md.

Reproduce this yourself

The whole "machine-proven-coverage" claim rebuilds from the vendored official artifacts with one committed command — from a repository checkout, not from the wheel (prove.py, differential.py and the corpus are repo-only, by design):

PYTHONPATH=$HOME/.local/lib/python3.10/site-packages python3 prove.py

prove.py runs, end to end and exiting non-zero on any failure:

  1. differential.py over all six legs — for every one of our implemented rule and syntax-binding ids it asks the same yes/no question ("does rule R fire on this document?") of the OFFICIAL vendored CEN / KoSIT Schematron (via Saxon) and of our engine, across the whole corpus, and counts divergences (a false positive or a miss). prove.py asserts and echoes that this count is 0.

  2. conformance.py — the targeted invalid/valid fragment corpus — and asserts 0 hard fails (no false positives; every covered invalid vector detected with the correct rule id).

  3. the coverage headline, recomputed live this run and printed in the shape:

    N business rules / 0 divergences across all differential legs / U of Ut UBL + C of Ct CII syntax-binding asserts differential-proven per binding

prove.py reads no headline number from a string literal. What each field means and where it comes from:

  • business rules — coverage_matrix.json['rule_count'], the EN 16931 + XRechnung/Peppol BR-* rules our engine implements. Independently re-checked by test_coverage_matrix.py (the JSON is a live re-render of what the engine fires, and COVERAGE.md is a byte-identical render of the JSON).
  • divergences — the step-1 total; 0 means our verdict never departs from the official Schematron over the corpus. This is the differential harness's own OVERALL DIVERGENCES ACROSS LEGS line.
  • U of Ut UBL / C of Ct CII — the per-binding syntax-binding asserts our restricted evaluator proves equivalent (differential.py LEG 5 sb / LEG 6 sbcii). The totals come from einvoice.syntax_binding.accounting() and the proven counts from einvoice.syntax_binding_eval.implemented_ids() / cii_implemented_ids() — the same recomputation test_syntax_binding.py asserts.

Because it re-runs the full differential (all legs) plus the conformance corpus, prove.py takes a few minutes. It always pays that full cost: prove.py sets DIFF_NO_CACHE=1 for every child it spawns, bypassing the local proof cache that speeds up ordinary differential.py gate runs, so a reproduce run is always a fully live Saxon re-proof — even on a warm development machine. test_prove.py runs it and asserts it exits 0, prints 0 divergences, and that the UBL / CII / rule numbers it printed equal a fresh independent recompute — so the entrypoint is verified to report live truth, never a frozen string.

Independently verify our conformance claim

prove.py above rebuilds the claim from scratch (a few minutes, needs Saxon). If you instead want a fast, single-command check that the published claim has not drifted from the committed source, run this — again from a repository checkout, since it re-derives the numbers from the source tree (the wheel carries the resulting attestation.json, not the verifier script):

python3 verify_attestation.py

This reads attestation.json — a byte-reproducible record that pins the exact numbers we publish — and confirms they still match the live source tree. It exits 0 only if nothing has moved. attestation.json pins:

  • 297 business rules asserted by the engine (the frozen rules.count);
  • the frozen syntax-binding coverage headline: 741 of 756 UBL + 554 of 583 CII syntax-binding asserts differential-proven per binding;
  • the in-scope KoSIT test-suite pass rates: 39 of 39 UBL and 39 of 39 CII official documents accepted;
  • the SHA-256 of each of the 3 vendored official corpora (CEN EN 16931 1.3.16, XRechnung Schematron 2.5.0, XRechnung test-suite).

verify_attestation.py runs two independent checks and exits non-zero on any drift: (1) it regenerates the attestation fresh from the four live source artifacts (export/rules.json, export/coverage.json, testsuite_conformance.json, sbom/bom.json) and requires it byte-for-byte equal to the committed attestation.json — so changing any recorded number (a rule count, a coverage figure, a pass rate, or a recorded corpus hash) fails the byte compare; (2) it re-walks each vendored corpus tree and requires its digest to equal the pinned SHA-256 — so editing a corpus byte without regenerating the attestation is caught too. A skeptical ERP or procurement evaluator can run this against the source and confirm the conformance claim for themselves. test_attestation.py asserts this command exits 0, that every number stated here equals the value in attestation.json, and that the tamper paths fail closed.


4. CI conformance gate

The distribution artifact an ERP/billing vendor actually wants: a build gate that makes "a non-conformant invoice reached the repo" a red build, with the violated rule ID named in the job log. Everything lives in ci/:

  • ci/validate-invoices.sh — the gate. POSIX sh, zero deps beyond python3. Exit 0 = all conformant; exit 1 = at least one invoice failed (rule IDs printed); exit 2 = the gate itself is misconfigured — including finding no invoices at all (an empty gate is a broken gate, opt out with EINVOICE_ALLOW_EMPTY=1).
  • ci/github-actions.yml — copy to .github/workflows/invoice-conformance.yml.
  • ci/gitlab-ci.yml — merge into your .gitlab-ci.yml.

Zero-install alternative (GitHub only): the committed composite Action. Instead of copying a workflow and pip-installing a vendored copy, pin the Action that ships in this repo at action/ — the zero-dependency validator travels inside the pinned ref, so there is nothing to install, and it additionally merges per-file SARIF for github/codeql-action/upload-sarif → inline PR annotations:

- uses: verifyhash/verifyhash/einvoice/action@<ref>   # pin a full commit SHA you trust
  with:
    path: invoices/
    fail-on: fatal

It is a thin wrapper around the same python3 -m einvoice.report entrypoint (no second engine, no new rules) and is gated by test_action.py. Full inputs (path, format, fail-on, sarif-file, profile), the fail-on → exit-code mapping, and the SARIF-upload step live in action/README.md. It is referenced by in-repo path as shown — it is not listed on the GitHub Marketplace.

The 60-second version (any CI system). This one vendors a repository checkout into third_party/ — ci/validate-invoices.sh is a repo file, not part of the wheel; if you would rather install from PyPI, use python3 -m pip install verifyhash-einvoice and the recipes in ci/README.md:

python3 -m pip install ./third_party/einvoice        # vendored copy; zero deps
sh third_party/einvoice/ci/validate-invoices.sh invoices/

and a failure looks like:

FAIL: invoices/2026-04-017.xml
  BR-DE-15
  JUnit: einvoice-junit/3_invoices_2026-04-017.xml.junit.xml
conformance gate: 1/12 invoice(s) NON-CONFORMANT (profile=xrechnung) — FAIL

(The gate script lists rule ids only and points at the per-invoice JUnit file, which carries the message and the offending XPath in its <failure>. The richer human summary — message, offending element, --explain and rule-page lines — is what einvoice validate <file> prints; see §3.)

The next question is always what is BR-DE-15 and what do I change? — so ask the same tool, no invoice and no repository checkout required:

einvoice --explain BR-DE-15

It prints the rule's requires sentence, the BT/BG business terms, the XML location to look at (cbc:BuyerReference here), the one-line fix, the severity, and the original Schematron assert. Exit 0 when the id is catalogued, 1 when it is not. Identical output to python3 -m einvoice.report --explain BR-DE-15 — one implementation, two entry points — and REPORT-FORMATS.md documents the mode in full.

Same honest scope as §2: the gate proves your invoices pass the implemented rule set (219 core + 55 German-layer rules — the authoritative list is COVERAGE.md), not the full standard. The gate's behaviour (fails naming the rule ID, passes conformant sets, refuses empty input) is itself under test in test_packaging.py.

Machine-readable report (python3 -m einvoice.report)

When a CI step needs the outcome as structured data (to archive, diff, or feed a dashboard) rather than a log line, einvoice.report emits a single versioned JSON document to stdout and mirrors the same exit-code contract (0 clean, 1 fatal violation, 3 not-well-formed XML):

python3 -m einvoice.report --profile xrechnung invoices/2026-04-017.xml
# {"report_version":1,"schema":"einvoice-conformance-report/v1",...,
#  "valid":false,"fatal_count":1,"violations":[{"rule":"BR-DE-15",...}]}

Every violation record carries exactly rule, severity, message, field; add --pretty for indented output. It re-uses einvoice.validate verbatim (no rule logic of its own, zero deps). The full field-by-field contract and its report_version/schema versioning semantics are documented in REPORT-SCHEMA.md (and mirrored in the REPORT_SCHEMA constant of einvoice/report.py).

Batch / folder validation

Point the same command at a directory (or pass --recurse) to validate a whole folder of invoices in one run. Every *.xml / *.pdf file under the directory is walked recursively (dotfiles and dot-directories such as .git are skipped), the file list is sorted for deterministic output, and each file is validated through the exact same build_report — this is a wrapper, not a second engine.

python3 -m einvoice.report --profile xrechnung invoices/
# {"report_version":1,"schema":"einvoice-conformance-batch/v1","root":"invoices/",
#  "file_count":42,"fatal_count":3,"warning_count":11,"failed_file_count":2,
#  "files":[{...single-file report...}, ...]}

The aggregate document uses its own independently-versioned schema id (einvoice-conformance-batch/v1) and wraps the per-file reports unchanged — each entry in files is byte-for-byte identical to validating that file on its own. It carries the summed fatal_count / warning_count / violation_count plus failed_file_count (files that errored or hold a fatal).

Aggregate exit code (documented precedence — fatal outranks parse):

  • 0 — every file passed (each fatal_count == 0, no error), or the directory held no invoice files (reported honestly as file_count: 0 with a "no invoice files found" note — never a fake pass, never a traceback);
  • 1 — at least one file has a fatal violation;
  • 3 — at least one file errored (not-well-formed XML / unsupported PDF container) and no file had a fatal violation.

Batch mode supports --format json (default, --pretty for indented), --format junit (an aggregate <testsuites> with one <testsuite> per file) and --format text (a status line per file, the rule ids behind its counts, a "most violated rules" ranking and an --explain pointer). --format sarif/html/badge validate a single file and are rejected on a directory with a clear error. Single-file invocation is completely unchanged.

This entry point is no longer the only route to those formats. The einvoice console script accepts --format <fmt> on validate and validate-batch too (see the flag list above), and for the seven delegated formats — junit, sarif, gitlab, github, azure, html, badge — both surfaces call the same emitter, so those bodies are byte-identical for the same invoice and profile (measured). The two json documents and the two text summaries are not interchangeable: each surface keeps its own long-standing shape (einvoice validate --json is the CLI result object; einvoice.report emits the versioned einvoice-conformance-report/v1 document), and --format deliberately changes neither. Reach for python3 -m einvoice.report when you want what only it offers: --pretty (indented JSON) and --recurse (an explicit directory walk; the console script spells that validate-batch). Two capabilities this list used to name have since moved onto the console script and are no longer reasons to leave it: the PDF-container dispatch that extracts the XML from a Factur-X/ZUGFeRD file, and --baseline — einvoice validate --baseline prev.json invoice.xml fails on a new fatal versus a stored report, delegating to this module's diff so both entry points emit the same einvoice-conformance-diff/v1 document. Mind the one deliberate difference: this module defaults to --profile xrechnung, the console script to en16931.

Use as a pre-commit hook

The CI gate above catches a bad invoice after it is pushed. To catch it one step earlier — at the commit itself — this repository ships a provider-side hook manifest at the repo ROOT (.pre-commit-hooks.yaml) that pre-commit resolves remotely. Any repo can adopt the check with a repos: reference; nothing is copied or vendored in.

Add this to your repo's .pre-commit-config.yaml:

repos:
  - repo: https://github.com/verifyhash/verifyhash
    rev: main              # no release tags exist yet; for a reproducible
                           # build pin a full 40-char commit SHA from
                           # github.com/verifyhash/verifyhash/commits instead
    hooks:
      - id: einvoice

then run pre-commit install once. On every commit, pre-commit clones this repo at rev, reads the root manifest, and runs the einvoice hook over the *.xml you staged. A fatal EN 16931 / XRechnung violation blocks the commit and prints the offending rule id(s) — the same outcome CI would produce, because the hook drives the same python3 -m einvoice.report entrypoint (no second validator, no new format). It stays inert on any commit that stages no XML.

Prerequisite — the one thing you must install. The manifest deliberately declares language: script, not language: python. The validator is the einvoice package under this repo's einvoice/ subdirectory (its pyproject.toml sits at einvoice/pyproject.toml, not the repo root), so language: python would make pre-commit pip-install the repo ROOT — the Node/Hardhat project, which is not the package — and fail. language: script runs the committed wrapper (einvoice/ci/pre-commit-einvoice.sh) directly and installs nothing, which means the einvoice validator must already be importable in the environment pre-commit runs in. Install it once:

python3 -m pip install verifyhash-einvoice
# or, from a checkout / vendored copy:
#  python3 -m pip install /path/to/einvoice

(or export EINVOICE_CMD / PYTHONPATH to point the wrapper at a source checkout). If the validator is not importable the hook exits 2 with an install hint rather than passing silently — a broken hook fails loud, never quiet. Honest limit: because the hook reuses the validator, its scope is the validator's scope (§2) — a passing commit means "no implemented rule fired", not full legal EN 16931 conformance.

5. Intended revenue model

If this continues past the first slice, the model is boring on purpose: a small, flat, self-serve commercial license for vendors (ERP, billing, e-invoicing platforms) who embed the validator in their product. Two prices, one-time, no contract and no sales call:

  • $29 — single developer: email support, rule-corpus update notices, and a ready-made vendor key.
  • $290 — whole vendor team: the same, for an entire company, with priority on support and update notices.

The license sells convenience, not permission: Apache-2.0 already lets anyone embed the engine in a closed-source product at no cost, so this is never a usage fee, never a compliance requirement, and never metered by request volume.

Explicitly ruled out, permanently: no token, no coin, no on-chain payment instrument of any kind. Nothing about invoice validation needs one, and this project will never fund itself by selling one.

No customer today, and nothing has been sold. This section exists so the incentive structure is on the record before the first conversation with a vendor, not after.


6. KILL / CONTINUE metric

A first slice earns further investment or it doesn't. The signal, timeboxed:

CONTINUE if, within 90 days of this README, at least one of:

  1. One ERP/billing/e-invoicing vendor agrees in writing to pilot or embed the validator (even unpaid) — evidence that "embeddable, zero-dependency, auditable" is a real wedge against the incumbent Java/Schematron stacks; or
  2. The validator reaches 60+ implemented rules including the full BR-DE-* set, passing the full KoSIT XRechnung test suite (not just the curated 32-vector corpus) with zero false positives — evidence the rule engine scales to real coverage without an architecture rewrite. → MET. (The threshold was written for a 20-rule first slice; it is kept here verbatim as the record of what was actually promised. The engine now asserts 297 business rules, ~5x the bar, with the full BR-DE-* set differential-proven against the official KoSIT artifact — see the status paragraph below.)

KILL if neither happens: write up what was learned, archive the repo, and stop. The corpus and harness remain useful artifacts either way.

Current status against this metric: 297 business rules asserted by the engine (python3 -m einvoice info --json → rule_count; the per-rule inventory is COVERAGE.md). That covers the EN 16931 core — 219 implemented + 4 excluded + 0 missing = 223 official BR-* asserts against each vendored CEN artifact, the same split in the UBL and the CII universe — plus the XRechnung CIUS/CVD/extension layer and the 21 KoSIT-vendored PEPPOL-EN16931-R* rules. Each batch is differential-proven at 100% against its official Schematron on both the UBL and the CII leg; fireable-missing = 0 in both CEN universes, with the 4 official test="true()" tautologies BR-CO-05–08 documented as the only never-provable ids. Vendors contacted: 0. Metric #2 is therefore MET with room to spare; metric #1 (a vendor) is now the whole game.


Licensing

Everything in einvoice/ is open source under Apache-2.0 — free for everyone, including commercial use and integration into closed-source products, subject to the usual Apache-2.0 conditions: keep the license text and the attribution in NOTICE with redistributions, and mark changed files.

Because Apache-2.0 already permits closed-source embedding for free, the optional commercial license does not sell permission or "compliance" — it adds support, prioritised rule-corpus update notices, and vendor-key convenience at a flat, self-serve price:

  • $29 — single developer.
  • $290 — whole vendor team (any number of developers).

Both are one-time, with no contract to negotiate and no sales call. Buy or ask via hello@verifyhash.com (the private commercial contact); the self-serve page is the licensing page (generated by gen_site.py into www/licensing/index.html). The checkout link is bound at deploy from a single CHECKOUT_URL placeholder in gen_site.py; until it is set the page shows an honest "checkout opening shortly — email hello@verifyhash.com" line rather than a dead link. You never need the commercial license to use, embed or redistribute the engine.


Sources / licenses

  • EN 16931 corpus & Schematron: github.com/ConnectingEurope/eInvoicing-EN16931 (EUPL-1.2), vendored under corpus/cen-en16931/.
  • XRechnung test suite: github.com/itplr-kosit/xrechnung-testsuite (Apache-2.0), vendored under corpus/xrechnung-testsuite/.
  • XRechnung Schematron v2.5.0 (XRechnung 3.0.2): github.com/itplr-kosit/xrechnung-schematron (Apache-2.0), vendored under corpus/xrechnung-schematron/ (see its VENDORED.md).
  • Rule text quoted verbatim from the vendored EN16931-model.sch / EN16931-syntax.sch / XRechnung-UBL-validation.sch.

Metadata

Release files for verifyhash-einvoice 0.2.9

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for verifyhash-einvoice 0.2.9
File Size Uploaded
verifyhash_einvoice-0.2.9.tar.gz 471.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for verifyhash-einvoice 0.2.9
File Interpreter ABI Platform
verifyhash_einvoice-0.2.9-py3-none-any.whl Python 3 none any Details

Total release size: 893.6 kB

Release files / verifyhash_einvoice-0.2.9.tar.gz

Download URL verifyhash_einvoice-0.2.9.tar.gz
Size 471.0 kB
Tags Source
SHA-256 checksum
How to use checksums
eb391db9deaaab5e4404e7dc2f22852c183dc9a348d29dd733b1481eb7e3dcf4
BLAKE2b-256 checksum
How to use checksums
2f2d11adc154acf0aee20d2b5a3641eb312224711a32dcc55b3a18750654512b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.12

Release files / verifyhash_einvoice-0.2.9-py3-none-any.whl

Download URL verifyhash_einvoice-0.2.9-py3-none-any.whl
Size 422.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7ee6a05ee38bec18c8a64ad63631d1e87d098aa8bd0591f6537857e7ab265767
BLAKE2b-256 checksum
How to use checksums
7732e1e6320a345cb55f8b699da70ae1c48ca0fd1fe593e5f46f01f3443d4659
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.12

Release history Release notifications | RSS feed

This release

0.2.9 This release

2 release files

0.2.7

2 release files

0.2.6

2 release files

0.2.2

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page