en16931

Syntaxes: UBL and CII

Read, write and convert UBL 2.1 and UN/CEFACT CII D16B through the EN 16931 semantic model — one call per task, generated element order, 1 218 prohibitions, and nothing dropped silently.

CEN/TS 16931-2 names exactly two mandatory syntaxes, and a receiver must accept both:

  • UBL 2.1 — OASIS, <Invoice> and <CreditNote> as two different document elements.
  • UN/CEFACT CII D16B — <rsm:CrossIndustryInvoice>, one document element for both, with the kind in BT-3.

en16931-formats binds the semantic model to both, in both directions, and re-implements not one of the 334 business rules. en16931 decides whether an invoice is correct; this crate decides what it looks like on the wire.

Features, and what each one costs

FeatureDefaultGraphWhat
ubl✅13 cratesUBL 2.1 Invoice / CreditNote, both directions
cii—13 cratesUN/CEFACT CII D16B, both directions
zugferd—57 cratesZUGFeRD / Factur-X hybrid PDFs — reading, and embedding into PDF/A-3
serde—+ serdeSerialize / Deserialize on this crate’s own types

zugferd is off by default and that matters: the PDF layer 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.

Reading

One call, from bytes, whatever the document is:

let read = en16931_formats::read(&bytes)?;

read.invoice;          // the semantic model
read.syntax;           // Syntax::Ubl or Syntax::Cii — the document's own
read.entries;          // everything the reader had to say, typed
read.pdf;              // for a ZUGFeRD / Factur-X PDF: profile, XMP, divergences

The syntax comes from the bytes — a PDF signature, else the document element (by name and namespace) — so there is nothing to choose and nothing to get wrong. The bytes may be UTF-8, UTF-8 with a byte-order mark or UTF-16 with one, the encodings XML 1.0 obliges a reader to accept; any other, declared or detected, is refused by name rather than guessed.

Every entry has a kind, the syntax path it is about, the business term where the binding knows it, the value, the artefact rule it cites and, where the remedy is known, a hint:

KindMeans
Unmappedan element outside the EN 16931 subset — another CIUS’s addition, an extension
Malformedpresent, and not representable — a third decimal, a timestamp where a date belongs
Duplicatea second occurrence of an element the schema (or a CEN syntax rule) allows once; the first is read
Repeatedthe same, where CEN publishes the rule as a warning
Schemaa spelling the schema does not admit, read anyway; writing, a required element the model lacks
Unreadablethe bytes are not an invoice at all — not well-formed, an encoding refused, nested too deep, a PDF refused
Containera hybrid PDF’s metadata disagrees with its payload without changing what it declares
Ambiguousthe PDF’s XMP profile and the payload’s BT-24 disagree
Droppedwriting: a term the target document has no place for
Prohibitedwriting: an element CEN’s syntax rules fence out of the EN 16931 subset
Derivedwriting: a value the syntax requires and the model lacked, derived

Unmapped and Malformed are not errors on their own, and neither is silent. Display on an entry is the one text rendering:

malformed    …/ChargeIndicator/Indicator (BG-20) = "yes" — not an xs:boolean [hint: the value must be `true` or `false`]

To validate as well, and get a decision, read with receive instead.

The document is hostile until it parses

entity expansion (billion laughs)needs a DTD; the parser rejects every document carrying one
external entities (XXE, file disclosure)the same DTD refusal, for the same reason
nestingrefused past MAX_DEPTH before parsing

The XML parser recurses once per level, and a stack overflow is not a panic: Rust can neither unwind nor catch it, so the process would abort. Nesting is therefore measured in one linear scan of the bytes first, and a document past the limit is Error::TooDeep. The limit is 64; the deepest of the 501 published instances is 9, and the corpus suite fails if an artefact release ships anything within three times the limit.

Input size and time are deliberately not bounded here. A caller reading from a socket owns that decision, and a library that quietly capped it would be wrong for the batch job and useless for the endpoint.

The schema is wider than the model, and the reader reads the schema

An inbound document is valid against UBL’s XSD, not against this crate’s idea of a value. cbc:IssueDate is xs:date, whose lexical space ends with an optional time zone:

<cbc:IssueDate>2026-07-31+02:00</cbc:IssueDate>

Schema-valid, objected to by no Schematron rule, and written by default by Java’s XMLGregorianCalendar. Date holds a calendar day and nothing else, because EN 16931-1 §6.5.9 has no term for a time zone, so the zone is dropped, not applied: 2026-07-31+02:00 is the day 2026-07-31.

cbc:ChargeIndicator is xs:boolean, whose lexical space is {true, false, 1, 0}, and it decides whether an amount is added to or subtracted from the invoice. Both readers take all four forms; an indicator that is not a boolean is reported as Malformed, never read as an allowance.

One business term, two code lists: BT-8

The VAT point date code (BT-8) has three meanings in EN 16931 — invoice issue date, actual delivery date, paid to date — and CEN’s two syntax bindings spell them from different code lists:

MeaningModel and UBL (UNTDID 2005)CII ram:DueDateTypeCode (UNTDID 2475)
invoice issue date35
actual delivery date3529
paid to date43272

The model always holds the UNTDID 2005 code, so BR-CL-06 is one rule whichever syntax a document came from. The CII reader translates on the way in and the CII writer on the way out; a CII value outside 5 29 72 is reported as Malformed and left out of the model — a 3 copied across would pass BR-CL-06 in a document CEN’s CII rules reject — and a model value with no CII spelling is not written and is reported as Dropped. CII repeats BT-8 in every VAT breakdown group, and CEN’s CII-SR-462 requires the repetitions to agree: a group that disagrees is reported as a Duplicate citing that rule, and the first is read.

Writing

use en16931::profiles::XRechnung;
use en16931::validation::profile::Validated;
use en16931_formats::Syntax;

let proof: Validated<XRechnung> = Validated::new(invoice)?;
let out = en16931_formats::write(&proof, Syntax::Ubl);

out.xml;
out.entries;          // e.g. a `Dropped` BT-9 on a credit note

write takes a proof, and 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. Every written document comes back with its entries: UBL’s <CreditNote> has no cbc:DueDate and no cac:ProjectReference, and dropping them is correct; dropping them quietly means a payment due date vanishing between two systems with nothing in any log.

A model nobody validated — a document you did not author, on its way to another syntax — goes through write_unchecked:

match en16931_formats::write_unchecked(&invoice, Syntax::Cii) {
    Ok(out) => submit(&out.xml),
    // The target schema has no form for this model — no BT-1, say. Nothing
    // is emitted that the schema would reject.
    Err(e) => eprintln!("{e}"),
}

It is the one way to write without a proof, and it refuses a model the target syntax’s schema cannot express, listing the Schema entries — so no public function emits a document its schema rejects, and none returns one without its report.

The 91 % that costs a writer nothing

CEN’s artefacts carry 1 339 syntax rules. 1 218 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 applyWhy
Writer~119It has no way to express cbc:UUID — the model has no term for it. The prohibitions are unreachable, not cheaply satisfied.
Readerall 1 339The document came from somewhere else.

Unreachability is a claim, so the serialiser enforces it against prohibitions extracted from CEN’s own Schematron, and a test asserts the writer never needs that safety net.

1 111 of the 1 218 are represented — 664 of UBL’s 696 and 447 of CII’s 522. The rest have a test an XPath engine is needed to evaluate (a predicate, a wildcard, ends-with(name(), 'Amount')), and they are counted rather than quietly omitted, so a test can report “1 111 of 1 218 checked” instead of implying a clean sweep.

The prohibitions are context-relative. CII-DT-076 is not(ram:ID), and it means the element that rule’s context selects may not have one — not that no document may contain ram:ID. Each entry carries (rule, context, relative path), taken from the preprocessed artefacts, where contexts are fully resolved rather than left as $Variable references.

The binding is data, not code

Six tables are generated from the authorities’ artefacts, never transcribed:

TableSourceSize
UBL element order325 published UBL instances36 parents
CII element orderCEN’s D16B subset schema, over the parents and children of 175 published CII instances38 parents
UBL prohibitionspreprocessed EN16931-UBL-validation1 548 paths + 21 attributes
CII prohibitionspreprocessed EN16931-CII-validation447 paths
UBL single occurrencethe OASIS UBL 2.1 schemas and CEN’s count(…) <= 1 rules239 rows
CII single occurrenceCEN’s D16B subset schema and CEN’s count(…) <= 1 rules177 rows

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. CII’s order is read from the D16B schema CEN validates against, which the reference corpus carries. UBL’s is derived by topologically sorting the pairwise precedences observed across the corpus, taking the majority direction where instances disagree (much of that corpus is deliberately invalid). Derivation reports no unresolved conflicts, and the generator exits non-zero if it ever does. Every document the test suites write is then validated with xmllint: CII against the D16B schema, UBL against the OASIS UBL 2.1 schemas.

The writers hand-sequence nothing: they emit in whatever order reads best, and the serialiser sorts by the table — which also handles UBL’s two document elements disagreeing about where cbc:TaxPointDate goes.

Converting between the two

Conversion goes through the model, never element by element — one call:

use en16931_formats::{Loss, Syntax};

let converted = en16931_formats::convert(&bytes, Syntax::Cii, Loss::Refuse)?;
converted.document;   // the CII
converted.report;     // the source, received as it declares itself
converted.written;    // what the writer said — a `Derived` BT-110, say
converted.lost;       // empty under Loss::Refuse

Anything that would not reach the target — an element the reader did not map, a value it could not hold, a duplicate it did not read, a term the target cannot or may not carry — refuses the conversion with ConvertError::Lossy, naming each item, unless the caller passes Loss::Allow. Invalidity under the business rules is not a refusal: the report travels with the result and says so.

en16931 convert rechnung.xml --to cii                # refuses a lossy conversion
en16931 convert rechnung.xml --to cii --allow-loss   # converts, and lists the loss

An element-by-element transform has to know 164 × 2 bindings and cannot say what it lost. Going through the model means the same reader and writer everything else uses, and the same typed entries.

What crossing the syntaxes proves

A same-syntax round trip cannot see a binding that is consistently wrong; crossing the syntaxes catches:

a term written to one syntax and not the otherUBL carries BT-90 on the seller as schemeID="SEPA", the one place BR-CL-10 admits a non-ISO-6523 scheme, and CII does not. A reader that hops it into BG-19 and a writer that never hops it back lose it on every direct-debit invoice, and the counterparty reports BR-DE-30.
a value one binding normalises and the other does notBR-DE-18 needs BT-20’s Skonto block to end with a newline, and XRechnung is carried in CII as well as UBL — a reader that trims the field breaks 36 documents.
a signal read from too narrow a setdetecting a CII credit note from 381 alone reads 396, 532 and 83 back as invoices, and BR-CL-01 then reports a violation that is not one. Every published CII credit note uses 381, so no corpus shows it.
an element that is two terms at onceBT-111 and BT-110 are one element when BT-6 = BT-5 — which one reader can know and the other not.

Both readers read an empty element (<cbc:BuyerReference/>) as an absent term.

See also