en16931

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:

FamilyCountWhat
BR-* and the nine VAT families156cardinality, presence, the category tables
PEPPOL-EN16931-*46Peppol BIS Billing 3.0
PEPPOL-COMMON-*17Peppol’s identifier checks — GLN, national registry numbers, Dutch VAT numbers
BR-DE-*27XRechnung
BR-CO-*24the totals and derivation chain
BR-CL-*23code-list membership
BR-DEC-*21decimal places — all 21 retired by InvoiceAmount
BR-DEX-*14the XRechnung Extension — of KoSIT’s 15; BR-DEX-15 checks a CII element
BR-TMP-*2temporary CEN rules
EN-*4this 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 is XXX, ISO 4217 for no currency. BR-CL-04 accepts 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 optional sepa feature 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.

RowTaxed (S, L, M)Zero-tax (Z, E, AE, K, G, O)
-01 groupsat least one — may repeat per rateexactly one
-05/06/07 rateS > 0; L/M ≥ 0= 0, except O which must be absent
-08 basegrouped by (category, rate)grouped by category alone
-09 tax= base × rate, ±1.00= 0, exact
-10 reasonforbiddenrequired

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:

RegimeRulesToleranceWhoseWhere
Totals chainBR-CO-10 … BR-CO-16exactCENcore
VAT derivationBR-CO-17, BR-*-08/09±1.00, on absolute valuesCEN artefactscore
Line & allowance derivationR120, R040±0.02Peppolprofile
…the same rules, in HUFR120, R040±0.5XRechnungprofile

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:round130
Decimal::round — banker’s020
half away from zero13-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-28a telephone number with two digits; an address that is not quite one
BR-DE-17, BR-DE-21scoping, 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:

RefusalWhy
ProveError::Rejectedthe invoice has a fatal finding; it carries the invoice and the report
ProveError::Suppresseda rule set with a hole may accept documents the full set rejects
ProveError::Relevelleda proof names a profile’s rule set as published, at its published severities
ProveError::WrongProfileP 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.