Validation
The 334 EN 16931 business rules as Rust: the nine VAT category families, four tolerance regimes, severity from the authority, and reports in JSON or SVRL.
validate(&invoice) runs the core EN 16931 rule set and returns a report.
profiles::XRECHNUNG.validate(&invoice) runs core plus XRechnung. Neither ever
sees a document, and both point their findings at business terms.
What is in the registry
334 rules registered: 53 rules retired by the type system — no state of the
model can make them fire — 4 undecidable at the semantic layer (CEN binds them
to true() too), and the remaining 277 exercised by their own failing fixture.
4 of those are this crate’s own, namespaced EN-*.
The coverage gate fails if a rule is neither exercised nor declared retired or undecidable, and if a declared rule has since become exercised.
conformance corpus
registered: 334
retired by the types: 53 (no state can make them fire)
undecidable: 4 (CEN binds them to true() too)
checkable: 277
exercised by a case: 277 (100% of checkable)
declared uncovered: 0
The composition, by family:
| Family | Count | What |
|---|---|---|
BR-* and the nine VAT families | 156 | cardinality, presence, the category tables |
PEPPOL-EN16931-* | 46 | Peppol BIS Billing 3.0 |
PEPPOL-COMMON-* | 17 | Peppol’s identifier checks — GLN, national registry numbers, Dutch VAT numbers |
BR-DE-* | 27 | XRechnung |
BR-CO-* | 24 | the totals and derivation chain |
BR-CL-* | 23 | code-list membership |
BR-DEC-* | 21 | decimal places — all 21 retired by InvoiceAmount |
BR-DEX-* | 14 | the XRechnung Extension — of KoSIT’s 15; BR-DEX-15 checks a CII element |
BR-TMP-* | 2 | temporary CEN rules |
EN-* | 4 | this crate’s own, namespaced so they cannot be mistaken for CEN’s |
Out of scope: the 1 339 syntax rules (UBL-*, CII-*) belong to
en16931-formats, and most of Peppol’s national rules are
not implemented — Peppol’s national rules
says which.
This crate’s own four
Namespaced EN-* because inventing a BR- id would be indistinguishable from
CEN’s:
EN-CURRENCY-01— BT-5 isXXX, ISO 4217 for no currency.BR-CL-04accepts it because it is a real code, so an unconfigured document validates cleanly as an invoice denominated in nothing.EN-EXT-01— the §14c Abs. 1 UStG hazard: the profile in hand cannot represent extension data the invoice carries, so writing it out would lose it.EN-EXT-02— a sub-line group keyed to a BG-25 line that does not exist, which every consumer skips and no writer emits.EN-SEPA-01— BT-90 does not look like a SEPA Creditor Identifier (EPC AT-02). A warning, and only checked when the optionalsepafeature is on.
The nine VAT category families
EN 16931-1 §6.4.3 writes these as nine parallel tables with the same ten row
headings. BR-S-08 and BR-Z-08 are the same sentence with a different category
and a different answer to “may this category appear at several rates?”.
So the logic lives in one checker per row, parameterised by a table, and the rule
entries are emitted per category by a macro — each keeping its own real id,
because a report saying BR-CATEGORY-08 would be useless to look up.
| Row | Taxed (S, L, M) | Zero-tax (Z, E, AE, K, G, O) |
|---|---|---|
-01 groups | at least one — may repeat per rate | exactly one |
-05/06/07 rate | S > 0; L/M ≥ 0 | = 0, except O which must be absent |
-08 base | grouped by (category, rate) | grouped by category alone |
-09 tax | = base × rate, ±1.00 | = 0, exact |
-10 reason | forbidden | required |
Every column differs, which is exactly why the standard writes nine tables rather than one parameterised rule.
BR-*-08 is the keystone: the only rule tying invoice lines to the VAT
breakdown, and therefore the only thing that turns a mis-attributed line into a
reported error rather than a silently wrong invoice.
The four tolerance regimes
Impossible to get right from the standard’s prose, and where a hand-written engine most easily diverges from the Schematron everyone else runs:
| Regime | Rules | Tolerance | Whose | Where |
|---|---|---|---|---|
| Totals chain | BR-CO-10 … BR-CO-16 | exact | CEN | core |
| VAT derivation | BR-CO-17, BR-*-08/09 | ±1.00, on absolute values | CEN artefacts | core |
| Line & allowance derivation | R120, R040 | ±0.02 | Peppol | profile |
| …the same rules, in HUF | R120, R040 | ±0.5 | XRechnung | profile |
None of it is in the standard. §6.4.2 states BR-CO-17 as a plain equation
with no slack; the ±1.00 is an artefact decision, the ±0.02 a Peppol one, and the
±0.5 XRechnung’s — it rewrites Peppol’s constant to if($documentCurrencyCode = 'HUF') then 0.5 else 0.02 when it merges the rules in, because HUF has no minor
unit in practice. Peppol never widens it.
So the same forint invoice can be a valid XRechnung and an invalid Peppol document. That is why tolerance is a property of the rule instance a profile holds, never a crate-wide constant.
// Exact — one cent out is fatal.
BR-CO-14: BT-110 = 19.01 against Σ BT-117 = 19.00 → fires
// ±1.00 on absolute values, which is what lets credit notes pass.
BR-CO-17: BT-117 = 18.50 against 100.00 × 19% → passes
BR-CO-17: BT-117 = 17.50 → fires
// ±0.02, Peppol only.
R120: BT-131 = 100.02 against 1 × 100.00 → passes
R120: BT-131 = 100.03 → fires
R120 under the core profile → not a rule at all
R120 has no CEN counterpart: EN 16931 never ties BT-131 to quantity × price,
so under core a line whose amount does not follow from its price is perfectly
valid. And R046 is the trap — it looks like R040’s sibling and carries no
slack at all.
…and a fifth thing that is not what it looks like: round
The artefacts do not say “round to two decimals”. They say it in XPath:
round(abs(TaxableAmount) * (Percent div 100) * 10 * 10) div 100
and pick the zero-rate branch of BR-CO-17 on round(Percent) = 0. Both are
XPath’s fn:round, which is “the one closest to +∞” — and no
rust_decimal::RoundingStrategy reproduces it:
round(0.5) | round(2.5) | round(-0.5) | |
|---|---|---|---|
XPath fn:round | 1 | 3 | 0 |
Decimal::round — banker’s | 0 | 2 | 0 |
| half away from zero | 1 | 3 | -1 |
Banker’s and half-away-from-zero each get one of the two midpoint columns
wrong, so the rules use floor(x + 0.5) — the definition rather than an
approximation of it. A VAT rate of exactly 0.5 % (Spain’s recargo de
equivalencia on reduced-rate goods) rounds to 1 for the artefact and to 0
for banker’s, which would send BR-CO-17 down its zero-rate branch.
Severity is the authority’s, not ours
A rule’s consequence is not a property of the rule. It is a property of the rule in a profile, and the authorities publish it separately from their Schematron — which is why reading only the Schematron gets it wrong.
Two files publish severity, and they cover different rules. Reading either one alone is how a validator comes to reject documents Germany accepts.
1. The validator configuration re-levels CEN’s rules, once per scenario:
<!-- overwrites CEN severity level "fatal" for codelist values of BT-130 … -->
<customLevel level="warning">BR-CL-23</customLevel>
<!-- overwrites CEN severity level "fatal" to enable use of mime codes per BR-DEX-01 -->
<customLevel level="information">BR-CL-24</customLevel>
Nine CEN rules are re-levelled across the three XRechnung scenarios, and the profiles carry all nine.
2. The Schematron’s own flag carries the severity of KoSIT’s rules, and
five of the fifty-five are not fatal:
<assert test="matches(normalize-space(cbc:Telephone), $XR-TELEPHONE-REGEX)"
flag="warning" id="BR-DE-27">…</assert>| Why not fatal | |
|---|---|
BR-DE-26 | “soll … übermittelt werden” — a corrected invoice should cite the original |
BR-DE-27, BR-DE-28 | a telephone number with two digits; an address that is not quite one |
BR-DE-17, BR-DE-21 | scoping, not malformation: a lawful EN 16931 type code, or a BT-24 naming another CIUS |
A test reads scenarios.xml for the first and both Schematrons for the second,
comparing all 121 severities the three XRechnung profiles run — measured rather
than transcribed.
And every finding leads with the one it got, so the report says which of them you have to act on:
XRechnung 3.0 Extension validation (…) — 296 rule(s) checked, 3 finding(s) (1 fatal, 2 information), INVALID
fatal [BR-DEX-04] BG-7 — Any scheme identifier in cac:PartyIdentification MUST be …
information [BR-DE-TMP-32] BT-72 — Eine Rechnung sollte zur Angabe des Liefer-/…
information [BR-CL-10] BG-7 — Any identifier identification scheme identifier MUST be …
One field to fix, not three.
A third source: the CEN release each authority built against
KoSIT’s v2026-08-31 configuration and Peppol BIS Billing 3.0.21 both name CEN
1.3.16, the release this crate derives CEN’s rules from. A code list that
differs between two CEN releases turns a valid invoice fatal, so
tests/artefact_pin.rs reads the CEN release out of each authority’s own
artefact and fails when one differs from this crate’s, unless that skew is
declared with every diverging code value asserted. None is declared.
Getting either file wrong rejects invoices Germany accepts. BR-CL-21 and
BR-CL-23 are code-list rules whose CEN tables lag the registries they track
(ISO 6523 ICD, UN/ECE Rec 20/21) and KoSIT reports both at warning,
deliberately; five of KoSIT’s own rules are warnings in the Schematron. Reading
either as fatal fails an invoice the German reference validator passes — the
worst direction for a validator to be wrong in, because it stops a document
nobody else would stop.
A finding is re-levelled, never dropped. No authority removes a rule, and suppression costs the report the one line explaining why an unusual value is present and unobjected to.
Every finding can be explained
Rules are data — id, severity, provenance, the business terms they touch, and the standard’s own wording — so the registry is listable, filterable and explainable.
use en16931::validation::rules::{explain, explain_restriction};
assert_eq!(explain("BR-CO-3").map(|r| r.id.as_str()), Some("BR-CO-03")); // padding
assert!(explain("BR-IG-8").is_none()); // no family mapping
assert!(explain("PEPPOL-EN16931-R120").is_some());
assert!(explain_restriction("br-de-3").is_some()); // a restriction
touching(BtId(117)) answers which rules constrain BT-117, which is the
question you actually have when a field is in doubt.
Hints
BR-CL-25 says only “MUST belong to the CEF EAS code list”. That is the
authority’s wording, it is what makes the finding look up in CEN’s index, and
this crate does not touch it. The advice goes in its own field:
[BR-CL-25] BG-7/BT-49 — Endpoint identifier scheme identifier MUST belong to the
CEF EAS code list. [hint: 9958 was DE:LID — the Peppol Leitweg-ID scheme, and has
been withdrawn; use 0204 instead]
Present on a small minority of findings — only where the crate genuinely knows more than the rule text, never as filler.
Deviations are allowed — and loud
Real counterparties demand them. A buyer who will not send BT-10 does not care
that BR-DE-15 requires it, and refusing outright pushes people to fork the rule
set or ignore the validator, which is worse. So Check offers suppression, and
makes it impossible to hide.
use en16931::profiles::XRechnung;
use en16931::{Invoice, validation::Check};
let report = Check::of::<XRechnung>()
.without("BR-DE-15") // the buyer will not send BT-10
.run(&Invoice::default());
assert_eq!(report.suppressed(), ["BR-DE-15"]);
assert!(report.to_string().contains("suppressed and NOT checked"));XRechnung 3.0 validation (EN 16931-1:2017+A1:2019) — 281 rule(s) checked, 29 finding(s) (26 fatal, 2 warning, 1 information), INVALID
⚠ 1 rule(s) suppressed and NOT checked: BR-DE-15
The suppressed ids are on the report, printed by Display and carried in the
JSON, and rules_checked drops by the number of checks actually removed.
A receiver re-levels
A receiver that accepts documents breaking one rule lowers that rule —
KoSIT’s customLevel, on the receiving side:
use en16931::{Invoice, Severity, profiles, validation::Check};
let report = Check::new(&profiles::XRECHNUNG)
.relevel("BR-CL-23", Severity::Warning) // unit codes our ERP still sends
.run(&Invoice::default());
assert_eq!(report.relevelled(), [("BR-CL-23".to_owned(), Severity::Warning)]);
The rule still runs and its finding still appears, at the new level; the report records the re-levelling and prints it.
A proof has to be earned twice over
Check::prove hands back a Validated<P> only when the
check runs P’s rule set as published and the invoice passes it:
| Refusal | Why |
|---|---|
ProveError::Rejected | the invoice has a fatal finding; it carries the invoice and the report |
ProveError::Suppressed | a rule set with a hole may accept documents the full set rejects |
ProveError::Relevelled | a proof names a profile’s rule set as published, at its published severities |
ProveError::WrongProfile | P names a profile this Check does not run |
Check::of::<P>() makes the last one unrepresentable: the marker is the
profile. Check::new is for the runtime case — a CLI resolving --require, a
service reading BT-24.
Reports you can store, diff and ship
Two shapes: a versioned JSON one, and — behind features = ["svrl"] — SVRL,
which every Schematron tool in this field already speaks.
use en16931::{Report, Invoice, profiles};
let report = profiles::XRECHNUNG.validate(&Invoice::default());
let out = Report::of(&report);
assert_eq!(out.schema, "en16931-report/4");
assert_eq!(out.profile.as_deref(), Some("XRechnung 3.0"));
assert!(out.layers.is_empty()); // a bare model: there was no document{
"schema": "en16931-report/4",
"valid": false,
"profile": "XRechnung 3.0",
"edition": "EN 16931-1:2017+A1:2019",
"rulesChecked": 282,
"artefacts": [
{ "authority": "CEN", "repo": "ConnectingEurope/eInvoicing-EN16931",
"gitRef": "validation-1.3.16" },
{ "authority": "KoSIT", "repo": "itplr-kosit/xrechnung-schematron",
"gitRef": "v2.6.0" },
{ "authority": "KoSIT", "repo": "itplr-kosit/validator-configuration-xrechnung",
"gitRef": "v2026-08-31" },
{ "authority": "OpenPeppol", "repo": "OpenPEPPOL/peppol-bis-invoice-3",
"gitRef": "806866bd2bd91d7e9623b68f08164e8fbe9e67a0" }
],
"suppressed": [], "relevelled": [], "leftOut": [],
"source": null, "identity": null, "declaration": null,
"recommendation": null, "reasons": [], "layers": [], "required": null,
"findings": [
{ "rule": "BR-02", "severity": "fatal", "source": "standard+artefact",
"location": "BT-1", "text": "An Invoice shall have an Invoice number (BT-1)." }
]
}
A received document fills the rest. /4 adds what a document passes
through beside its findings: layers (container, syntax, schema, semantic —
each valid, invalid or skipped, with the reader’s and the container’s
entries, each with a kind, a level, a syntax path, the business term, the value
and the artefact rule it cites), the document’s identity (seller, BT-1, BT-2),
the declaration, the recommendation with its reasons, and the report a
receiver required beside the one declared. en16931_formats::receive(…) .to_interchange() fills them; en16931 validate --format json prints them.
SVRL renders every layer: a fatal or warning entry is an svrl:failed-assert
with an id in this crate’s namespace (en16931:schema:duplicate), an
information entry (an element the reader does not map) an
svrl:successful-report.
ValidationReport derives Serialize, but that shape is the crate’s
internals. Report is a separate, versioned shape: the internal layout may
change freely, this one only by bumping schema.
What travels with a report that SVRL has no room for
The provenance of each rule — CEN’s, a profile’s, or this crate’s — which profile and edition produced the report, and the authority releases those rules were verified against.
That last one is per profile, and the reason is BR-DE-15. It is KoSIT’s rule,
it moves on KoSIT’s release cadence, and a report stamped only
validation-1.3.16 beside it names CEN for something CEN never published. An
XRechnung report cites four releases because its rules come from four: CEN’s
model, KoSIT’s Schematron, KoSIT’s validator configuration — a separate
release, and the one that decides the severities — and OpenPeppol’s, 31 of whose
rules XRechnung merges in.
A stored report that cannot say what it was checked against is close to useless six months later. One that says the wrong thing is worse.
The SVRL feature adds no dependencies. SVRL is a report format, not an
invoice syntax: writing it needs escaping, not a parser. svrl:text stays
byte-identical to the authority’s wording, and this crate’s advice goes in
Schematron’s supplementary-text element, so a consumer that does not know about
it ignores it and loses nothing. location carries a business-term path rather
than an XPath, and the output says so in a comment — there is no source document
to point into.
It does not panic
A clearing platform does not choose its inputs. A property suite generates
structurally valid, semantically absurd documents — i64::MAX amounts that
overflow when summed, zero base quantities that R120 would divide by, empty
codes, thousands of lines — and asserts that validation terminates, never panics,
is deterministic and stably ordered, and never cites a rule id that
explain() cannot resolve.
The property suite runs on every commit; coverage-guided fuzzing of the readers runs in CI beside it.
Documents, unlike models, can be nested: the readers in en16931-formats
refuse nesting deeper than
MAX_DEPTH
before parsing, and any document carrying a DTD — see
syntaxes.
See also
- Profiles — XRechnung, Peppol, and the typed proof.
- Conformance — how all of the above is measured.