en16931-formats
European e-invoicing formats, on top of the
en16931 semantic model — UBL 2.1,
UN/CEFACT CII, XRechnung, and ZUGFeRD / Factur-X.
┌──────────────┐ ┌─────────────┐
│ billing │ │ your ERP │
│ calculations │ │ │
└──────┬───────┘ └──────┬──────┘
│ adapter (feature)│
└─────────┬────────┘
▼
┌─────────────────────┐
│ en16931 │ the semantic model. Complete on its own:
│ semantic model │ build an Invoice, get a verdict.
│ validation engine │ 10 deps, no XML, no I/O, wasm32.
│ proof of validity │
└─────────┬───────────┘
│ Validated<P> — the typed proof
▼
┌─────────────────────┐
│ en16931-formats │ ← this crate. Only if you exchange
│ UBL · CII · PDF/A │ documents: parses inbound, writes
│ 1 339 syntax rules │ outbound, +1 339 syntax rules.
└─────────┬───────────┘
│ ▲
▼ │
┌──────────────────────────┐
│ documents in / out │
│ UBL · CII · ZUGFeRD │
└──────────────────────────┘
en16931 decides whether an invoice is correct. This crate decides what it
looks like on the wire, and re-implements not one of the 316 rules.
🧩 Why one crate, not one per format
XRechnung is carried in UBL and CII; every ZUGFeRD payload is CII. A crate per
format would need the CII binding twice, and two bindings drift. Cargo features
already express which syntax a consumer wants, so a crate boundary here would be
solving with a package what --no-default-features solves for free.
What is a separate crate is en16931, and that boundary is
load-bearing: this crate depends on it, so rustc forbids the reverse. "The
semantic rules do not depend on a syntax" is enforced rather than asked for, and
en16931 keeps a 10-crate graph that builds for wasm32.
📦 Features, and what each one costs
| Feature | Default | Graph | What |
|---|---|---|---|
ubl |
✅ | 13 crates | UBL 2.1 Invoice / CreditNote, both directions |
cii |
— | 13 crates | UN/CEFACT CII D16B, both directions |
zugferd |
— | 57 crates | ZUGFeRD / Factur-X hybrid PDFs |
render |
— | + a typesetting engine | Corporate design — not yet implemented |
zugferd is off by default and that matters: lopdf brings AES, ChaCha20,
SHA-2, getrandom and libc, and the result does not build for
wasm32-unknown-unknown. Nobody reading a UBL invoice should pay for that.
The writers have no dependency at all — writing XML is escaping and ordering. Only reading pulls in a parser.
🎯 The 91 % that costs a writer nothing
CEN's artefacts carry 1 339 syntax rules. 1 220 of them (91 %) say some element "shall not be used" — they fence off the parts of UBL 2.1 and CII D16B that EN 16931 does not use. That inverts the usual expectation:
| Rules that apply | Why | |
|---|---|---|
| Writer | ~119 | It has no way to express cbc:UUID — the model has no term for it. The prohibitions are unreachable, not cheaply satisfied. |
| Reader | all 1 339 | The document came from somewhere else. |
Unreachability is a claim, so the serialiser enforces it against prohibitions
extracted from CEN's own Schematron, and tests/subset.rs asserts the writer
never needs that safety net.
📐 The binding is data, not code
Two tables are generated from the authorities' artefacts, never transcribed:
| Table | Source | Size |
|---|---|---|
| UBL element order | 320 published UBL instances — CEN unit tests, KoSIT mutation instances, OpenPeppol examples | 36 parents |
| CII element order | 170 published CII instances | 38 parents |
| UBL prohibitions | preprocessed EN16931-UBL-validation |
1 024 paths + 21 attributes |
| CII prohibitions | preprocessed EN16931-CII-validation |
447 paths |
Both syntaxes' content models are XSD sequences, so a document with exactly
the right elements in the wrong order is invalid — and no Schematron rule
says so, because ordering is the schema's job. The order is derived by topologically
sorting the pairwise precedences observed across all 320 documents, taking the
majority direction where they disagree (much of that corpus is deliberately
invalid). Derivation reports no unresolved conflicts, and the generator
exits non-zero if it ever does.
The writers hand-sequence nothing: they emit in whatever order reads best and
the serialiser sorts by the table. That made a class of bug structurally
impossible — UBL's two document elements disagree about where cbc:TaxPointDate
goes, and a hand-sequenced writer got it wrong.
The prohibitions are context-relative, and that is half the rule.
CII-DT-076 is not(ram:ID), and it does not mean "no document may contain
ram:ID" — it means the element that rule's context selects may not have one.
An earlier table dropped the context, and the writer duly discarded every
ram:ID in the document. Each entry now carries (rule, context, relative path), taken from the preprocessed artefacts where contexts are fully
resolved rather than $Variable references.
Regenerate with cargo xtask codegen, cargo xtask codegen and
cargo xtask codegen. Each exits non-zero rather than emitting a table it
could not derive cleanly.
🔒 Nothing is dropped silently
let out = write;
assert!; // e.g. BT-9 on a credit note
let read = from_str?;
assert!; // elements outside the EN 16931 subset
assert!; // present, but not representable
UBL's <CreditNote> has no cbc:DueDate and no cac:ProjectReference. Dropping
them is correct; dropping them quietly means a payment due date vanishing
between two systems with nothing in any log.
✅ Writing a proof, not a hope
use XRechnung;
use Validated;
let proof: = new?;
let out = write_validated;
write_validated stamps BT-24 from the profile that was actually proved. Two
things become impossible rather than discouraged: serialising an unvalidated
invoice, and a document claiming XRechnung 3.0 that was only checked against the
bare core model — the most common way an invoice passes local validation and is
rejected on receipt.
When the profile is a runtime choice
Validated<P> is a compile-time answer, and it is the right one when the proof
travels — across a function boundary, into a queue, through a trait. It is the
wrong shape when the counterparty's preferred CIUS comes out of a database, which
is most of the time. So the same guarantee is available as a Result:
use XRECHNUNG;
match to_string_for
It validates, stamps BT-24 from the profile, and only then writes — in that
order, because a document validated carrying the caller's BT-24 and shipped
carrying the profile's was checked as something other than what it claims to be.
cii::to_string_for is the same in the other syntax, and write_for on either
keeps the dropped report.
Why to_string returns String and not Result
Serialisation cannot fail, and that is a property of the model rather than an
omission. Every field of Invoice already holds a value the syntax can carry:
InvoiceAmount cannot hold a third decimal, Date cannot hold something that is
not a calendar day — which is exactly what udt:DateTimeString format="102"
accepts, and nothing more. There is no state a writer could be handed that it
would have to refuse, and writing into a String does no I/O.
Validity is a separate question, and it is the caller's. An invoice with no
seller serialises perfectly into a document no counterparty will accept. Run
en16931::validate first — or use to_string_for, which will not hand you a
document until you have.
📄 ZUGFeRD / Factur-X
let got = extract?;
got.xml; // the payload, byte-identical to what was sent
got.profile; // what the payload's BT-24 claims
got.xmp; // what the PDF's own metadata declares
got.divergence; // where those two disagree
Extraction is the common direction — receiving is more common than sending — and the one with no PDF/A risk.
The XMP is not decoration. It is how a receiver discovers an invoice is there
and which profile it claims, before parsing anything. A PDF whose metadata says
BASIC while the payload says EN 16931 validates, opens, and is wrong in a way no
schema notices: a receiver routing on the XMP and one routing on BT-24 process
the same file differently, and both behave correctly. So both are read and
compared, and Divergence::NoXmp says when a counterparty scanning metadata
first will not see an e-invoice at all.
🪤 The trap in the profile matrix
Not every ZUGFeRD profile is an EN 16931 invoice. MINIMUM and BASIC WL carry no lines, so they cannot satisfy BR-16.
match got.profile.is_en16931_invoice
en16931 shipped and fixed exactly that bug once: an Underlies impl that let
an invoice validated against one profile be widened into a proof for another it
had never been checked against. A type system that says MINIMUM is an EN 16931
invoice is worse than no type system. An unrecognised profile is Unknown, never
quietly the core model.
⚠️ Provenance
en16931's design was written against artefacts fetched into spec/ and
verified there. The ZUGFeRD and Factur-X specifications are not among them.
Everything ⚠-marked — profile names, attachment filenames, the XMP structure — is
stated from knowledge, not a fetched specification. Milestone 0.0 is: fetch the
specification.
Writing PDFs: not implemented, and the reason is not effort
There is no embed(pdf_bytes, &invoice) -> Vec<u8>, and asking for one is
entirely reasonable — so here is what stands in the way, because "not yet"
without a reason is the least useful thing a crate can say.
A ZUGFeRD file is not "a PDF with an attachment". It is a PDF/A-3 document, and the conformance is normative: a file that is no longer valid PDF/A is no longer a valid ZUGFeRD invoice. Embedding correctly means all of
- rewriting the cross-reference table and trailer without disturbing the original's object numbering;
- an
/AFassociated-files array on the catalogue and an/AFRelationshipon the file specification — PDF/A-3's own requirement, the part most implementations omit, and the one value this crate will not guess (below); - an XMP packet carrying the ZUGFeRD extension schema whose
DocumentFileName,VersionandConformanceLevelagree with the payload's BT-24 — the divergence this crate already detects on the way in, and would have to be incapable of creating on the way out; - preserving whatever conformance the input had,
/OutputIntent, embedded fonts and metadata included.
Most of that is checkable only against veraPDF, not against a Rust test. A writer producing files that open happily in a viewer and fail a recipient's conformance check would be worse than no writer: the failure arrives at the counterparty, months later, on documents already sent.
And one field this crate refuses to invent. /AFRelationship decides whether the
XML is the invoice or merely accompanies one — legally load-bearing, and the
published guidance disagrees:
| Profile | Guidance |
|---|---|
| MINIMUM, BASIC WL | Data — no lines; the pages are the invoice |
| BASIC, EN 16931, EXTENDED | German sources say Alternative; PDFlib documents Source for Factur-X to non-German recipients |
So the reader reports the value on Extracted::relationship and raises
Divergence::Relationship for the one case every source agrees is wrong —
Data on a profile that carries lines. Where they disagree it takes no
position.
What composes today, and it is most of the way there: render the PDF/A-3
with a toolchain that already guarantees conformance, take the payload from
cii::to_string_for — which will not hand you XML until it has validated the
model against the profile you name — and have that toolchain embed it. The half
this crate can guarantee is the half it does. render, the visible-invoice
feature, is downstream of the same problem and likewise unimplemented.
🧪 What is tested
104 tests.
| Suite | What it establishes |
|---|---|
roundtrip |
Invoice → syntax → Invoice is the identity in both syntaxes, reported per field — plus a test that UBL and CII agree with each other |
order |
Every element either writer emits is in schema sequence |
subset |
Neither writer emits a forbidden element or attribute |
corpus |
All 320 UBL and 170 CII published instances read; every unmapped element named |
zugferd |
Extraction, XMP, /AFRelationship, divergence, and the payload as a model — against PDFs built in the test |
profile_scoped |
to_string_for refuses an invalid invoice in both syntaxes, and stamps BT-24 before the rules run rather than after |
The corpus suite skips when spec/ is absent, and CI sets
EN16931_REQUIRE_SPEC=1 so that a skip there fails the build. A corpus test
that silently passes on an empty corpus is worse than none — and a println!
in a green run is not the warning it looks like. Run cargo xtask fetch, or
just test-artefacts to hold yourself to CI's standard.
ZUGFeRD's PDFs are built in the tests rather than checked in: a binary fixture is opaque, and pins one producer's output rather than the structure the specification describes.
Four real bugs came out of writing these rather than assuming: BT-158's
scheme is @listID, not @schemeID (well-formed, schema-valid, silently wrong);
the reader handed back base64 text instead of decoded attachment bytes; BT-9 on a
credit note was dropped without saying so; and UBL-CR-244 forbids BT-33 on the
customer.
🚀 Examples
🧰 Development
just is the task runner; just alone lists everything.
All commands run from the workspace root, one level up:
There are no shell scripts. Fetching and generating are both cargo xtask
subcommands, so they are compiled, type-checked and linted like the rest of the
workspace.
en16931 is a path dependency in the same workspace, so a breaking change
to the model and its use here land in one commit and one PR. It carries a
version as well, so cargo publish resolves it from crates.io — there is no
[patch.crates-io] block to remember to remove, and no publish-ordering hazard
that spans two repositories.
spec/ is not committed — the CEN artefacts are EUPL-1.2, a reciprocal
licence. The fetch pulls only what the generators and the suites read, pinned by
fully-qualified ref, once for both crates: eInvoicing-EN16931 publishes
validation-1.3.16 as
both a tag and a branch pointing at different commits, and git clone --branch prefers the branch — so two clones of the same "pin" produced
different trees and different tables.
cargo xtask check runs in CI and re-derives every table from the artefacts,
failing if the committed result differs. A table cannot drift away from the
documents it was derived from, and the generators exit non-zero rather than
emitting something they could not derive cleanly — an ordering cycle or a tied
pair fails the build instead of becoming a guess written to disk.
Minimum supported Rust version
1.88 — the rule code uses let-chains. Measured, not declared: 1.87 fails,
and CI reads the number from Cargo.toml rather than repeating it.
⚖️ Licence
MIT OR Apache-2.0.
The bindings are derived from CEN's EUPL-1.2 validation artefacts and the element
order from the authorities' published instances — facts about element placement
rather than copied expressions. The CEN attribution notice is a licence
condition, is re-exported from en16931 rather than restated, and has a test.