Expand description
ยงen16931 โ the European e-invoice, as Rust types
billing proves an invoice is arithmetically correct. This crate proves it
is legally meaningful: it holds the EN 16931 semantic data model, decides
what the standard and its national usage specifications demand, and hands a
proof of that decision to the syntax layer.
It never emits a byte of XML. UBL, CII and PDF/A-3 belong to
en16931-formats, the sibling crate in this workspace; the 1 339
syntax-binding rules belong with them. This crate owns the 223 that are
syntax-independent, which are the only ones it can meaningfully check.
ยงValidate the model, not the document
Every other implementation in this space is XML in, Schematron out, so the
loop is build โ serialise โ validate โ parse the error โ guess which field it
meant. Here a finding points at lines[3] BT-151, and you can validate an
invoice you are still assembling.
ยงDesign invariants
- No
f64. Amounts are fixed-point; rates and quantities areDecimal. - No I/O, no async, no
unsafeโ sowasm32works, and an invoice never has to leave the client. - Rounding is never implicit. An amount that does not fit two decimals is an error, not a rounding opportunity. Reduce precision at the source.
- Mandatory means non-
Option. Where EN 16931 says 1..1, the type says so. Rules exist for the cardinalities the type system cannot express, not as a substitute for it. - Invariants survive deserialisation, via
#[serde(try_from = ...)]โ asserted intests/serde_invariants.rs, not merely intended.
ยงThe ten semantic data types
EN 16931-1 ยง6.5 defines exactly ten, and every one of the 164 business terms has one. Mirroring them one-for-one is the crateโs organising principle, so โwhich Rust type does BT-n get?โ is a lookup in Table 2 rather than a judgement call.
| ยง6.5 | Semantic type | Here |
|---|---|---|
| 6.5.2 | Amount | InvoiceAmount โ i64 minor units, no third decimal |
| 6.5.3 | Unit Price Amount | UnitPriceAmount โ Decimal, no cap |
| 6.5.4 | Quantity | Quantity โ Decimal, may be negative |
| 6.5.5 | Percentage | Percentage โ per cent (19), not a fraction |
| 6.5.6 | Identifier | Identifier โ content + scheme + scheme version |
| 6.5.7 | Document Reference | DocumentReference โ deliberately no scheme |
| 6.5.8 | Code | codes โ 4 887 values, generated and re-verified |
| 6.5.9 | Date | Date โ a calendar day, no time of day |
| 6.5.10 | Text | String โ 62 of the 164 terms |
| 6.5.11 | Binary Object | Attachment โ mime and filename mandatory |
ยงStatus
The ten semantic data types, all eighteen code lists, the invoice model,
and a validation engine that registers all 223 syntax-independent
rules of the pinned CEN artefacts โ every BR-*, BR-CO-*, BR-CL-* and
all nine VAT category families. tests/codelists.rs asserts that 223 against
the artefacts on any machine that has them, so it is measured rather than
claimed.
Of the 317 rules registered across every shipped profile:
| retired by the types | 53 | no state can make them fire โ BT-112 is not an Option |
| undecidable | 4 | BR-CO-05โฆ-08; CENโs own binding is value="true()" |
| checkable | 260 | every one exercised by its own failing fixture |
And the profile rule sets are complete against their authorities too, asserted the same way:
profiles | Rules run | Artefact coverage |
|---|---|---|
| EN 16931 core | 227 | 223 / 223 CEN syntax-independent |
| XRechnung 3.0 | 282 | 55 / 55 KoSIT UBL asserts + 21 / 21 merged Peppol |
| XRechnung 3.0 CVD | 290 | + all 8 Clean Vehicles Directive rules |
| XRechnung 3.0 Extension | 296 | + 14 of the 15 BR-DEX-*; BR-DEX-15 is a CII element check |
| Peppol BIS Billing 3.0 | 273 | 46 / 46 PEPPOL-EN16931-* |
โฆand at the severities those authorities publish, which are not the severities the rules carry and are not written down in one place.
| Where a severity is published | What it re-levels |
|---|---|
validator-configuration-xrechnung/scenarios.xml <customLevel> | nine of CENโs rules across KoSITโs three scenarios โ BR-CL-23 to warning even for the plain CIUS, because CENโs unit-code table lags UN/ECEโs |
the XRechnung Schematronโs own flag | five of KoSITโs rules: BR-DE-17, -21, -26, -27, -28 are warning, and BR-DE-TMP-32 is information |
Both are read. tests/codelists.rs compares the first against
scenarios.xml and the second against all 121 severities the two
Schematrons publish, because reporting any of them as fatal โ which this
crate did โ rejects invoices the German reference validator accepts.
And the rules agree with the authoritiesโ own conformance suites, not only with their rule lists: 100 % agreement on every assertion run โ 1 013 of CENโs unit tests (11 divergences declared and explained), 381 runnable KoSIT mutations, and all 58 published example invoices.
Those totals move: two of the three suites come from repositories pinned to
a moving branch, because neither publishes releases on a cadence worth
pinning to. So tests/conformance.rs asserts 100 % agreement exactly and
coverage as a floor โ upstream growing the suite must not fail the
build, and upstream losing it must.
Each comes with the typed Validated proof, and there is a billing
adapter. Checked against the standardโs own Annex A worked examples.
Three things sit on top of the verdict, each deriving from the same tables the rules use rather than from a second reading of the standard:
reconcile | BG-23 and BG-22 as a function of the lines โ BR-CO-10โฆ-16, every category familyโs -08 and -09 |
codes::guard | a withdrawn EAS scheme rejected at the map, with its successor named, rather than at the report |
Profile::missing_terms | which fields a profile will ask for, answerable before the data is fetched |
A five-line invoice validates in about 1.5 ยตs through the core rules and
under 7 ยตs through XRechnungโs 282; proptest properties assert validation
never panics, is deterministic, and never cites an unresolvable rule id.
Out of scope, deliberately and named rather than quietly dropped: the 1 339
syntax rules (UBL-*, CII-*) belong to en16931-formats, and Peppolโs
~90 national rules (DK-R-*, SE-R-*, โฆ) are country registry-format and
check-digit checks.
ยงA whole invoice, end to end
Two lines at two VAT rates, built, reconciled and validated. Nothing is elided โ this is the complete program, and it is a doctest, so it compiles and passes on every commit.
use en16931::invoice::{Party, PostalAddress};
use en16931::{Date, Identifier, InvoiceAmount, Percentage, Quantity, prelude::*};
use rust_decimal::dec;
let seller = Party {
name: Some("Stadtwerke Musterstadt GmbH".into()),
vat_identifier: Some("DE123456789".into()),
// BT-34's scheme is an EAS code. `Identifier::eas` rejects a withdrawn
// one here rather than at validation time โ 9958 is the classic.
electronic_address: Some(Identifier::eas("4012345000009", "0088")?), // GLN
address: PostalAddress {
city: Some("Musterstadt".into()),
post_code: Some("12345".into()),
country: Some(en16931::codes::guard::country("DE")?), // BR-09
..Default::default()
},
..Default::default()
};
let buyer = Party {
name: Some("Beispiel AG".into()),
electronic_address: Some(Identifier::schemed("991-01234-56", "0204")),
address: PostalAddress {
city: Some("Beispielstadt".into()),
post_code: Some("54321".into()),
country: Some(en16931::codes::guard::country("DE")?), // BR-11
..Default::default()
},
..Default::default()
};
let invoice = Invoice::builder(
"urn:cen.eu:en16931:2017", // BT-24
"R-2026-0001", // BT-1
Date::parse("2026-07-31")?, // BT-2
"380", // BT-3 โ commercial invoice
"EUR", // BT-5
)
.seller(seller)
.buyer(buyer)
.due_in_days(14) // BT-9 โ satisfies BR-CO-25
.line(InvoiceLine::new(
"1", "Netznutzung Arbeitspreis",
Quantity::new(dec!(10000)), "KWH",
InvoiceAmount::parse("2890.00")?,
"S", Some(Percentage::new(dec!(19))),
))
.line(InvoiceLine::new(
"2", "Messstellenbetrieb",
Quantity::new(dec!(12)), "MON",
InvoiceAmount::parse("120.00")?,
"S", Some(Percentage::new(dec!(7))),
))
// BG-23 and BG-22 are a *function* of the lines. This computes it โ
// grouping, rounding and the absent-is-not-zero rules included.
.build_reconciled()?;
assert_eq!(invoice.vat_breakdown.len(), 2); // one group per rate
assert_eq!(invoice.totals.vat_total.unwrap().to_string(), "557.50");
assert_eq!(invoice.totals.gross_total.to_string(), "3567.50");
let report = validate(&invoice);
assert!(report.is_valid(), "{report}");And the other direction โ an invoice with nothing in it, so every finding points somewhere:
use en16931::{validate, prelude::*};
let invoice = Invoice::default(); // nothing filled in
let report = validate(&invoice);
assert!(!report.is_valid());
assert!(report.has("BR-02")); // no invoice number
assert!(report.has("BR-16")); // no invoice line
// Findings point at business terms, never at an XPath.
assert_eq!(report.fatal().next().unwrap().path.to_string(), "BT-1");ยงAttribution
This crate is an implementation of the semantic data model of EN 16931-1 and of the two mandatory syntaxes listed in CEN/TS 16931-2. EN 16931-1 and CEN/TS 16931-2 are made available free of charge by CEN and the European Commission under their 2018 licence agreement, which permits derivative use on condition that derivative applications carry a statement to this effect. Copyright in the standard remains with CEN.
ยงREADME
The crate README is included below, so every Rust example in it is compiled and run as a doctest. Documentation that drifts out of compiling is the most expensive kind, and this makes that class of rot impossible.
ยง๐ช๐บ en16931
The EN 16931 semantic data model and its business rules, as Rust types.
billing proves an invoice is arithmetically correct. This crate proves it is
legally meaningful โ and hands a typed proof of that to the syntax layer.
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ billing โ โ your ERP โ
โ calculations โ โ โ
โโโโโโโโฌโโโโโโโโ โโโโโโโโฌโโโโโโโ
โ adapter (feature)โ
โโโโโโโโโโโฌโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ en16931 โ โ this crate. 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
โผ
โญ โ only if you exchange documents โ โ โ โ โ โ โ โฎ
โโโโโโโโโโโโโโโโโโโโโโโ
โ โ en16931-formats โ parses inbound UBL/CII, โ
โ UBL ยท CII ยท PDF/A โ writes outbound, +1 339
โ โโโโโโโโโโโโโโโโโโโโโโโ syntax rules โ
โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โThis crate never sees a document, and most users need nothing else. If your
system already has the invoice as data โ from billing, from an ERP, from your
own types โ en16931 gives you the verdict and the typed proof, and that is the
whole job.
Parsing an inbound UBL or CII file, or producing one, is
en16931-formatsโs job. It is a
separate crate โ in the same workspace โ because it depends on this one, so
rustc forbids the reverse,
and โthe semantic rules do not depend on a syntaxโ is enforced rather than
promised. The practical payoff is the dependency line above: adding an XML
parser would end that, and adding a PDF parser takes the graph to 57 crates and
breaks wasm32 outright.
Status: complete against every rule set it claims. Not โsupports XRechnungโ โ every assertion in KoSITโs own Schematron, asserted against that Schematron by a test.
Profile Rules run Coverage of its authorityโs artefact EN 16931 core 227 223 / 223 CEN syntax-independent XRechnung 3.0 282 55 / 55 KoSIT UBL asserts + 21 / 21 merged Peppol XRechnung 3.0 CVD 290 + 8 / 8 Clean Vehicles Directive XRechnung 3.0 Extension 296 + 14 of the 15 BR-DEX-*Peppol BIS Billing 3.0 273 46 / 46 PEPPOL-EN16931-*KoSIT publishes two Schematrons. The 55 are the UBL oneโs; the CII one adds
BR-TMP-3andBR-DEX-15, both checks on the shape of a CII document that a syntax-independent model cannot express โ named intests/codelists.rsrather than quietly not counted.โฆand at the severities those authorities publish, which is a separate claim โ see severity is the authorityโs, not ours.
And the rules agree with the authoritiesโ own conformance suites, not only with their rule lists:
Suite Assertions Agreement CEN Invoice/CreditNoteunit tests1 024 run 100 % (11 declared divergences) KoSIT XRechnung mutation suite 381 run 100 % Published example invoices 58 documents 100 % valid Two of those three suites are pinned to a moving upstream branch, so the agreement is asserted exactly and the coverage as a floor.
317 rules registered; 53 retired by the type system, 4 undecidable (CEN binds them to
true()too), and every one of the remaining 260 exercised by its own failing fixture. 4 of those are this crateโs own, namespacedEN-*so they can never be mistaken for CENโs โ below.Out of scope and named rather than dropped: the 1 339 syntax rules (
UBL-*,CII-*) belong toen16931-formats, and Peppolโs ~90 national rules (DK-R-*,SE-R-*, โฆ) are country registry-format and check-digit checks.
ยงโ Checked against the authoritiesโ own conformance suites
Every other test here was written by the same person as the code it checks, from the same reading of the same documents. These two were not.
CENโs unit tests โ 277 files, ~1 130 assertions, each a minimal UBL fragment with an explicit expectation:
<test>
<assert><error>BR-01</error></assert>
<Invoice> โฆ no CustomizationID โฆ </Invoice>
</test>Published example invoices โ the 58 complete invoices CEN and OpenPeppol ship as examples of correct usage. No per-rule expectation, just a blunt one: the authority publishes this as valid, so nothing fatal may fire. That is the assertion a corpus of deliberately-broken documents structurally cannot make โ it is the only thing that catches a rule which is merely too eager.
KoSITโs mutation suite โ 224 complete, valid XRechnung invoices with mutations embedded as processing instructions:
<?xmute mutator="remove" schematron-invalid="xrubl:BR-DE-15" ?>
<cbc:BuyerReference>90000000-03083-12</cbc:BuyerReference>Remove BT-10 and BR-DE-15 must fire. identity asserts the unmutated invoice
is clean โ which catches rules that are too eager, the failure mode a corpus of
deliberately-broken documents cannot see.
Running them needs a UBL reader, which lives in tests/ubl.rs โ
test-only, so it is not in this crateโs API, dependency graph or wasm build. It
records every element it does not map, and a test asserts that set is empty.
These are the only tests here not written from the same reading of the same
documents as the code, and that is what makes them worth running. The class they
catch is a rule implemented against its English sentence rather than against
its artefact context โ BR-CL-15 names BT-80 in prose and binds to
cac:OriginCountry, which is BT-159; PEPPOL-EN16931-R003 reads as โBT-10 is
mandatoryโ and is a disjunction with BT-13; BR-CL-22 compares BT-121
case-insensitively in the released artefact and case-sensitively in the source
file. Every one of those looks right in review.
One of them settles a model question rather than a rule. CENโs credit-note cases
carry no BT-3 at all, so inferring the document kind from the type code
cannot answer what they ask โ which is why DocumentKind is an
explicit field, as both syntaxes carry it, and why BR-CL-01 is exact rather
than permissive.
ยงThe 11 divergences are declared, not ignored
Two causes, both the same shape โ UBL can write down a state this model does not have.
Nine cases: a group that is present and empty. <cac:PostalAddress/>,
<cac:BillingReference/>, <cac:InvoicePeriod/>. An address with no fields is
an absent address here; there is no third state, and adding one would put a
syntax artefact in every consumerโs way to satisfy nine test cases.
Two cases: two cac:TaxTotal elements in the same currency with different
amounts. BT-110 is one field, so the contradiction cannot be written down, and
BR-CO-15 is then satisfied by whichever value was read. Peppolโs R053 catches
it, and it is a syntax rule about element counts.
The table is asserted exactly โ by file and by rule โ so it can only shrink, and a divergence that starts agreeing fails the build as loudly as a new one.
ยงWhy another e-invoicing library
Every existing implementation โ phive, Mustangproject, the KoSIT validator โ is XML in, Schematron out. You cannot ask them anything until you have serialised a document, so the loop is build โ serialise โ validate โ parse the error โ guess which field it meant.
This crate validates the model. A finding points at BG-25[2]/BT-151, not at
an XPath, and you can check an invoice you are still assembling.
That buys four things nothing XML-first can offer:
- Whole rule families become unrepresentable. All 21
BR-DEC-*rules die to a two-decimal type, and presence and cardinality rules die to non-Optionfields and enums โ 53 rules retired by the type system, not by a predicate. They stay in the registry soexplainworks and a report can say they were checked. - A proof that survives the call boundary.
Validated<XRechnung>means a serialiser physically cannot be handed an unchecked invoice. - Cross-edition answers. โValid today, and still valid under XRechnung 4.0?โ is one call, not two pipelines.
- ยตs, not ms, and no JVM โ plus
wasm32, so the invoice never has to leave the client.
What we do not claim to beat: a Schematron-driven tool is, by construction, exactly as correct as the artefact. Our rule logic is hand-written, so it can be wrong in ways theirs cannot. That is why the conformance corpus gates every release โ and why the crate reports its own coverage rather than implying it:
conformance corpus
registered: 317
retired by the types: 53 (no state can make them fire)
undecidable: 4 (CEN binds them to true() too)
checkable: 260
exercised by a case: 260 (100% of checkable)
declared uncovered: 0Those five figures are not typed into this file. A test reads them back out of
it โ and out of every other README, lib.rs and documentation page โ and
compares each against the value the code produces. Three of them had been wrong
here for several releases, which is what the test is for.
A rule nobody has seen fire may be inverted, unreachable, or checking the wrong field โ and the suite would be green either way. So every registered rule either has a fixture that makes it fire, or is a rule the type system retires and no document can trigger. There is no third category: the gate fails if a rule is uncovered and undeclared, if a declared rule has since been covered, and if anything is declared for a reason other than being type-retired. The excuse list has no room in it.
ยงDesign invariants
- No
f64. Amounts are fixed-point; rates and quantities areDecimal. - No I/O, no async, no
unsafe.#![forbid(unsafe_code)],wasm32tested. - Rounding is never implicit. An amount that does not fit two decimals is an error, not a rounding opportunity.
- Mandatory means non-
Option. Rules exist for the cardinalities the type system cannot express, not as a substitute for it. - Types enforce representability; rules enforce validity. An invalid document must still be representable, or a parser cannot load it in order to explain what is wrong.
- Two dependencies by default:
rust_decimalandthiserror.
ยง๐๏ธ A whole invoice, end to end
Two lines at two VAT rates, built, reconciled and validated. Nothing is elided โ this is the complete program, and it is a doctest, so it compiles and passes on every commit.
use en16931::invoice::{Party, PostalAddress};
use en16931::{Date, Identifier, InvoiceAmount, Percentage, Quantity, prelude::*};
use rust_decimal::dec;
let seller = Party {
name: Some("Stadtwerke Musterstadt GmbH".into()),
vat_identifier: Some("DE123456789".into()),
// BT-34's scheme is an EAS code, checked here rather than at validation
// time โ `9958` is the one every German integrator reaches for, and it was
// withdrawn on 2023-07-31.
electronic_address: Some(Identifier::eas("4012345000009", "0088")?), // GLN
address: PostalAddress {
city: Some("Musterstadt".into()),
post_code: Some("12345".into()),
country: Some(en16931::codes::guard::country("DE")?), // BR-09
..Default::default()
},
..Default::default()
};
let buyer = Party {
name: Some("Beispiel AG".into()),
electronic_address: Some(Identifier::eas("991-01234-56", "0204")?),
address: PostalAddress {
city: Some("Beispielstadt".into()),
post_code: Some("54321".into()),
country: Some(en16931::codes::guard::country("DE")?), // BR-11
..Default::default()
},
..Default::default()
};
let invoice = Invoice::builder(
"urn:cen.eu:en16931:2017", // BT-24
"R-2026-0001", // BT-1
Date::parse("2026-07-31")?, // BT-2
"380", // BT-3 โ commercial invoice
"EUR", // BT-5
)
.seller(seller)
.buyer(buyer)
.due_in_days(14) // BT-9 โ satisfies BR-CO-25
.line(InvoiceLine::new(
"1", "Netznutzung Arbeitspreis",
Quantity::new(dec!(10000)), "KWH",
InvoiceAmount::parse("2890.00")?,
"S", Some(Percentage::new(dec!(19))),
))
.line(InvoiceLine::new(
"2", "Messstellenbetrieb",
Quantity::new(dec!(12)), "MON",
InvoiceAmount::parse("120.00")?,
"S", Some(Percentage::new(dec!(7))),
))
// BG-23 and BG-22 are a *function* of the lines. This computes it.
.build_reconciled()?;
assert_eq!(invoice.vat_breakdown.len(), 2); // one group per rate
assert_eq!(invoice.totals.gross_total.to_string(), "3567.50");
assert!(validate(&invoice).is_valid());ยง๐งฎ Reconciling โ the arithmetic every hand-mapper re-implements
BR-CO-10 โฆ BR-CO-16, the -08 and -09 rows of all nine VAT category
families, and BR-CO-18 are one function of the lines. If your engine
already produced the positions, it should not also have to know which rows form
one BG-23 group, that BT-107 is absent rather than zero when there are no
allowances, or that BT-119 is stated for category O even though BT-152 is not.
use en16931::reconcile::Reconciler;
Reconciler::new()
.exemption("AE", None, Some("VATEX-EU-AE")) // BR-AE-10 needs a reason
.paid(en16931::InvoiceAmount::parse("190.00")?) // BT-113
.apply(&mut inv)?;Grouping comes from the same table the -08 rows are checked against, not
from a second reading of the standard, and a test builds one invoice per
category and asserts the result carries no arithmetic finding. The two cannot
drift apart silently.
Three things it deliberately does not do: invent a BT-120 exemption reason
(only the seller knows it), round per line rather than per group (that is how
three 0.05 lines come out a cent wrong), or default an absent rate on a taxed
category to zero โ which balances perfectly and under-declares VAT.
ยง๐ท๏ธ Guarded code lists โ catching 9958 at the map, not at the report
contains answers yes or no. guard answers what to do:
use en16931::codes::guard;
// Withdrawn on 2023-07-31. The hint names its successor.
let err = guard::eas("9958").unwrap_err();
assert!(err.to_string().contains("use 0204 instead"));
// The single most common unit-code bug.
assert!(guard::unit("kwh").unwrap_err().to_string().contains("did you mean \"KWH\""));
assert!(guard::eas("0204").is_ok());
assert!(guard::unit("KWH").is_ok());Twelve EAS schemes have left the CEF list since CEN artefact validation-1.2.0
โ nine in 2023 alone โ and guard::WITHDRAWN names the successor of each. The
tableโs central claim, that every entry really is gone from the current list and
every named successor really is in it, is asserted against the pinned artefacts,
so a code CEN reinstates fails the build rather than producing a wrong hint.
This is a convenience, never a second source of truth: each function checks the same generated list the corresponding rule checks, and skipping the layer loses nothing but the earlier message.
ยง๐ก Hints โ the sentence that ends the investigation
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 (the Leitweg-ID itself belongs in BT-10 โฆ)]Present on a small minority of findings โ only where the crate genuinely knows
more than the rule text, never as filler. It travels in the JSON shape and in
SVRLโs own svrl:diagnostic-reference, so svrl:text stays byte-identical to
the authorityโs.
ยง๐งพ The scheme is checked; the value is not โ unless you ask
Identifier::eas(x, "0088") validates 0088. It does not check that x is
a GLN, and neither does BR-CL-25, which also only looks at the scheme code.
That is worth stating because the Result invites the opposite reading. A
downstream user put an eleven-digit BDEW Marktlokations-ID through
eas(malo, "0088"): the constructor returned Ok, validation passed, and the
document went out asserting that an eleven-digit German metering identifier was a
thirteen-digit GS1 GLN. Syntactically valid, semantically false, and
unresolvable for any receiver that believed it. No rule in the standard catches
that.
use en16931::Identifier;
use en16931::codes::guard;
assert!(Identifier::eas_checked("4012345000009", "0088").is_ok()); // a real GLN
let err = Identifier::eas_checked("51238696781", "0088").unwrap_err();
assert!(err.to_string().contains("13 digits")); // the MaLo-ID
// A scheme with no shape check behaves as `eas` โ and says which it did.
assert!(Identifier::eas_checked("991-01234-56", "0204").is_ok());
assert!(!guard::eas_value_is_checkable("0204"));guard::CHECKED_EAS_SCHEMES holds exactly one entry today, and that is the
point rather than a shortfall. The EAS list has over a hundred schemes and most
have no fixed public format, so โcheck the valueโ is not a job this crate can do
honestly across it. What it can do is the ones whose format is fixed, published
and self-verifying โ GS1โs GLN, where a wrong value is detectable without a
registry lookup. Schemes join the list when their specification is in hand;
guessing a format would produce a check that passes wrong values and is trusted
because it exists.
eas_value_is_checkable is the honest half: Ok means verified for a listed
scheme and nothing to verify for the rest, and a caller building on the first
when it has the second is the trap this closes.
ยง๐จ๏ธ Formatting never shortens a value
Every Display here used Formatter::pad, the standard helper โ which, because
that is what precision means for a string, truncates to N characters:
format!("{:.2}", InvoiceAmount::parse("1190.00")?) โ "11" โ eleven euros
format!("{:>12.4}", InvoiceAmount::parse("1190.00")?) โ " 1190"
format!("{:.4}", Date::parse("2026-07-31")?) โ "2026"A caller asking for two decimal places got a hundredth of the amount, neatly right-aligned in a column. This crate refuses to round an amount at a boundary because a plausible wrong number is worse than an error; printing one at a hundredth of its value is the same failure through the formatter.
Nothing truncates now, and precision on the numeric types is a minimum number of fraction digits:
use en16931::{InvoiceAmount, Percentage};
use rust_decimal::dec;
let a = InvoiceAmount::parse("1190.00")?;
assert_eq!(format!("{a:.4}"), "1190.0000"); // padded
assert_eq!(format!("{a:.0}"), "1190.00"); // never rounded away
assert_eq!(format!("{a:>12}"), " 1190.00"); // width, fill, alignment as before
assert_eq!(format!("{:.2}", Percentage::new(dec!(19))), "19.00");Padding is lossless and rounding is not, so only one of them happens. It is also
what a document template wants: pad to the scale the layout needs, and let the
value keep every digit it has. en16931::fmt exposes the two helpers, so a
downstream Display can make the same promise.
ยงโฉ๏ธ Stornorechnung โ the credit note that cancels an invoice
Invoice fields are public precisely so a stored invoice can be reshaped
without re-billing. The one reshape with rules attached gets a method:
let storno = original.to_credit_note("STORNO-2026-0007", Date::parse("2026-08-15")?);
assert_eq!(storno.kind, DocumentKind::CreditNote); // the UBL root element
assert_eq!(storno.type_code.unwrap().as_str(), "381");
assert_eq!(storno.preceding_invoices.len(), 1); // BG-3 โ BT-25, per BR-55It does not negate the amounts, and that is the point. Under EN 16931 a
credit note states what is credited as a positive figure โ the document type
carries the direction. Negating would state it twice and fail BR-S-08 against
the documentโs own lines.
ยง๐ซ Pre-flight โ which fields will this profile ask me for?
validate answers โis this document acceptable?โ. On a half-built invoice
that is a hundred findings, most of them about lines and totals that are not
there yet. A different question, answerable before the data is fetched:
use en16931::invoice::{Party, PartyRole};
use en16931::profiles::XRECHNUNG;
let gaps = Party::default().missing_for(&XRECHNUNG, PartyRole::Buyer);
let terms: Vec<_> = gaps.iter().map(|m| m.term.0).collect();
assert!(terms.contains(&52)); // BT-52 Buyer city โ BR-DE-8
assert!(terms.contains(&53)); // BT-53 Buyer post code โ BR-DE-9So a seller whose master data lives in a contract service fetches what XRechnung
needs in one round trip, instead of a build-validate-fetch loop.
XRECHNUNG.missing_terms(&invoice) does the same for a whole document.
Restrictions only, and deliberately: they are the ยง7.3.2 axis that is pure data,
so the answer is exact. The conditional rules cannot be answered before the
document exists โ BR-DE-23-a asks for BT-84 only if BT-81 names a credit
transfer โ and validate remains the complete check.
ยง๐ถ InvoiceAmount โ where 21 rules go to die
EN 16931-1 ยง6.5.2 does not merely restrict amounts to two decimals; it defines the semantic type that way:
EN 16931_ Amount. Type is floating up to two fraction digits.
Table 26 then lists every term it applies to, and the CEN artefacts render that
table as 21 assertions โ BR-DEC-01, -02, -05, -06, -09..-20,
-23..-25, -27, -28. A type that cannot hold a third decimal retires all
of them at compile time.
use en16931::InvoiceAmount;
let net = InvoiceAmount::parse("1000.00")?;
let vat = InvoiceAmount::parse("190.00")?;
assert_eq!(net.checked_add(vat)?.to_string(), "1190.00");
// Refused, not rounded โ neither 0.01 nor 0.00.
assert!(InvoiceAmount::parse("0.005").is_err());Every operation is checked_: an invoice total that overflows is a data error
worth surfacing, never a number worth guessing.
ยงโฆand where it must not be used
Unit Price Amount (ยง6.5.3) is a different semantic type โ based on Amount,
but with no cap. Its own example in the standard is 10000.1234.
use en16931::{InvoiceAmount, UnitPriceAmount};
use rust_decimal::dec;
let price = UnitPriceAmount::new(dec!(0.28901)); // EUR/kWh
assert_eq!(price.to_string(), "0.28901");
// The same value as an Amount would be refused outright.
assert!(InvoiceAmount::from_decimal_exact(dec!(0.28901)).is_err());ยงโ Validating
use en16931::{validate, prelude::*};
let invoice = Invoice::default(); // nothing filled in
let report = validate(&invoice);
assert!(!report.is_valid());
assert!(report.has("BR-02")); // no invoice number
assert!(report.has("BR-16")); // no invoice line
// Findings point at business terms, never at an XPath.
assert_eq!(report.fatal().next().unwrap().path.to_string(), "BT-1");A ValidationReport carries every finding, ordered stably so a CI diff
means something. report.into_result()? is there for the ergonomic path, but the
report is the product โ a rejection from a clearing platform lists every problem,
and a validator that reports one is a validator you run in a loop.
Rule ids normalise, because the standard and the artefacts spell them differently:
use en16931::validation::rules;
// EN 16931-1 writes `BR-CO-4`; the CEN artefacts write `BR-CO-04`.
assert_eq!(rules::explain("BR-CO-4").map(|r| r.id.as_str()), Some("BR-CO-04"));
assert_eq!(rules::explain("br-1").map(|r| r.id.as_str()), Some("BR-01"));ยงFour rules of our own
Namespaced EN-*, because inventing a BR- id would be indistinguishable from
CENโs and a reader could not tell which document to look it up in.
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 as an invoice denominated in nothing. |
EN-EXT-01 | the target profile cannot represent extension data the invoice carries โ ยง14c Abs. 1 UStG, below. |
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. BR-DE-30 requires it to be present, and no rule anywhere checks that it is well formed. |
ยง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::extra_rules |
| โฆthe same rules, in HUF | R120, R040 | ยฑ0.5 | XRechnung | Profile::extra_rules |
None of it is in the standard. EN 16931-1 ยง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 โ and why each rule records its Source.
All four are implemented and pinned in both directions:
// 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 Profile::core() โ not a rule at allR120 has no CEN counterpart: EN 16931 never ties BT-131 to quantity ร price,
so under the core profile 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 100and 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. It is not academic: 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 sent BR-CO-17 down its zero-rate
branch and rejected a correct invoice every deployed validator accepts.
ยง๐ From billing
billing owns the arithmetic. This crate owns what the arithmetic means. The
caller owns the parties โ a tariff engine has no business knowing a buyerโs
postal address.
let invoice = FromBilling::new(&billing_document)
.specification_id(profiles::XRECHNUNG.specification_id)
.seller(seller)
.buyer(buyer)
.build()?;
validate(&invoice).into_result()?;There is deliberately no TryFrom. A BillingDocument has no seller, no
buyer, no addresses, no country codes and no item names โ LineItem::description
is display text, not BT-153. A TryFrom would fail on every realistic input,
which makes it a trait impl whose only behaviour is Err while implying that
conversion is total.
ยงThe levy trap
The reason the adapter is not a field-for-field copy. A per-unit excise โ
Stromsteuer, a COโ levy โ is produced by a TaxLayer, so it lands in
tax_total. But EN 16931 counts it inside the taxable base: it is a BG-21
document level charge, not tax.
Map tax_total โ BT-110, the obvious thing to do, and BR-CO-14
(BT-110 = ฮฃ BT-117) fails on every levy-bearing invoice.
BT-106 ฮฃ line net amounts โ net positions
BT-107 ฮฃ allowances โ discount positions (stated positive)
BT-108 ฮฃ charges โ the levy
BT-109 = BT-106 โ BT-107 + BT-108
BT-110 = ฮฃ BT-117 โ VAT only
BT-112 = BT-109 + BT-110 โ equals billing's gross_totalnet_total appears nowhere: it is BT-106 โ BT-107, which EN 16931 has no term
for. That is the single most valuable thing the upstream work clarified.
ยงThe ยง14c hole
A final invoice deducting advance payments must, in Germany, state โdie auf sie entfallenden Steuerbetrรคgeโ โ the tax in each advance (ยง14 Abs. 5 Satz 2 UStG). Omit it and the issuer owes that tax a second time under ยง14c Abs. 1.
Core EN 16931 has nowhere to put it: BT-113 is one flat figure. So an adapter that maps itemised advances to BT-113 and drops the rest produces a document that validates perfectly and is a tax liability.
Extensions carries it โ mirroring ZUGFeRD EXTENDEDโs BG-X-45, the only
standardised home โ and EN-EXT-01 warns when the target profile cannot
represent it:
EN 16931 validation โ 227 rule(s) checked, 1 finding(s), valid
[EN-EXT-01] BT-113 โ This invoice carries extension data that the target
profile cannot represent. [โฆ] In Germany that is a ยง14c Abs. 1 UStG liability.Not fatal: the invoice is lawful. But not silent either.
ยงThe BT-20 newline
The smallest trap at this seam: one character. billing renders BT-20 including
Germanyโs Skonto micro-syntax, and the field must end with a newline:
Zahlbar innerhalb 30 Tagen ohne Abzug.
#SKONTO#TAGE=10#PROZENT=2.00#
โBR-DE-18 has two halves, and the second hides inside the same assertion as
the first:
every $line in โฆtokenize(., '(\r?\n)')[starts-with(normalize-space(.), '#')]
satisfies matches(normalize-space($line), $XR-SKONTO-REGEX)
and matches(โฆtokenize(., '#.+#')[last()], '^\s*\n')Everything after the last #โฆ# must begin with a newline. A rendering that
ends at the # makes tokenize(โฆ)[last()] the empty string, and every German
invoice carrying a Skonto fails.
billing terminates the field itself, which is the right place: the
#SKONTO#โฆ# syntax has no core EN 16931 form, so a rendering that omits the
terminator is valid nowhere at all.
What is left here is a guard, not a fix โ idempotent, and paired with
billing_renders_bt_20_with_the_terminator_br_de_18_needs, which asserts the
upstream behaviour rather than the adapterโs output. Asserting the output
alone would pass just as well against an upstream regression and an adapter
quietly papering over it, which is exactly the state these two crates were in a
release ago.
ยงFour conversions that are not copies
| Rates | billing stores 0.19 because that is what you multiply by; EN 16931 stores 19, what you print. Converted once, here. |
| Signs | billing models a return as Sign::Credit with a non-negative quantity. EN 16931 puts the sign on BT-129 and forbids a negative BT-146 (BR-27) โ Annex A.1.6. A negative unit price gets flipped onto the quantity rather than dropped. |
| Precision | Refused, never rounded โ and the error names the fix (.amount_scale(AmountScale::EN16931)) rather than the symptom. |
| The document kind | DocumentKind::is_credit_note(), not BT-3. 81 is on both UNTDID 1001 lists, and en16931-formats picks the UBL document element from the kind โ so deriving it from the code would put a credit note inside <ubl:Invoice>. |
ยงWhat crosses
BT-1, BT-2, BT-3, BT-5, BT-6, BT-9, BT-20, BT-21, BT-22, BT-25, BT-26, BT-29,
BT-46, BT-111, BG-1, BG-3, BG-14, BG-20, BG-21, BG-22, BG-23, BG-25 and
ZUGFeRDโs BG-X-45 โ plus the document kind, which is not a business term and
decides the root element.
Three of those are worth naming:
-
BT-6 and BT-111 cross together.
BR-53makes the second mandatory whenever the first is present, so mapping only the currency would manufacture a finding out of a complete document. -
BT-29 and BT-46 are merged, not overwritten. The callerโs
Partycarries master data; the document carries the party code the billing run was keyed on โ an MP-ID in the energy market, a GLN in retail. EN 16931 makes both repeatable precisely because a party has more than one identity. The scheme is compared alongside the value, because the same digits under0088and under0293are two registries saying two different things. -
BG-3 arrives filled in. A credit note that does not say what it credits is an unexplained payment, and
billingโsreversepopulates BT-25 and BT-26 from the document it reverses.BR-55is satisfied by the type: BT-25 is not anOptionupstream and its constructor refuses a blank string, so a BG-3 without a reference is not constructible.
meta.period_label and meta.labels do not cross: display text and arbitrary
key/value pairs, with no business term at all.
Neither does a late-payment penalty, for a sharper reason. BR-DE-18 gives
a Skonto a micro-syntax inside BT-20 and gives a penalty none, so rendering one
into BT-20 produces a #โฆ# line the German regex rejects, and putting it in the
prose half makes it text no system reads. Default interest is generally outside
the scope of VAT anyway (art. 63 of the VAT Directive; CJEU C-222/81 BAZ
Bausystem), so a penalty billed later is its own document.
Units are resolved from Quantity::code first, falling back to a small
UnitResolver table. An unresolvable label is an error: guessing produces an
invoice that validates and describes the wrong thing, and unlike a wrong amount
nobody notices.
ยง๐ฉ๐ช Profiles โ a CIUS is a set of restrictions
Every Schematron-based tool models a CIUS as โcore rules plus extra rulesโ, because Schematron has no other vocabulary. That is not how EN 16931 defines one.
ยง7.3.2 is a normative table of the thirteen kinds of change a CIUS may make across six axes. Only one of those axes is โadd a rule.โ The other five are restrictions on the model โ so they are data here, and the rules are derived:
use en16931::profiles;
use en16931::validation::profile::Restriction;
// Eleven of XRechnung's BR-DE rules are pure `Mandatory` restrictions, and two
// are `CodeValues`. All thirteen are data, not code; the other 28 need code.
let ids: Vec<_> = profiles::XRECHNUNG.restrictions.iter().map(Restriction::id).collect();
assert!(ids.contains(&"BR-DE-3")); // Seller city (BT-37) shall be present
assert!(ids.contains(&"BR-DE-17")); // BT-3 restricted to eight codesEach keeps its real published id, so a finding is lookup-able in KoSITโs index โ and the path still names the business term:
[BR-DE-3] BG-4/BT-37 โ Seller city (BT-37) shall be presentยงTwo properties this buys
A CIUS can be checked for conformance. ยง4.4.2 requires that โthe resulting
invoice document instance shall be fully compliant to the core invoice modelโ.
Every Restriction variant is by construction a narrowing, so a profile that
tried to loosen something cannot be expressed. Loosening is an Extension
(ยง4.3, CEN/TR 16931-5) โ a different mechanism.
Validation widens for free โ from a conformant CIUS. ยง4.4.4 says an instance complying with one โcan still be received and processed by a party who is not supporting the CIUSโ. So the proof converts, infallibly:
let proof: Validated<PeppolBis3> = Validated::new(invoice)?;
serialise_peppol(&proof); // demands the CIUS proof
accepts_core(proof.widen()); // ยง4.4.4 โ free, no re-validationPeppol BIS Billing 3.0 is the one shipped CIUS this holds for, and the reason it is not XRechnung is the next section.
ยงEnums retire rules too
BR-DE-23-b, -24-b and -25-b each forbid the two payment groups BT-81 did
not name. Because PaymentMeans is an enum over BG-17 / BG-18 / BG-19, that
combination cannot be written down โ so all three have nothing left to check.
The -a halves stay real: they tie the variant to BT-81โs value, which no
type can see.
pub enum PaymentMeans {
CreditTransfer(Vec<CreditTransfer>), // BG-17
Card(PaymentCard), // BG-18
DirectDebit(DirectDebit), // BG-19
}ยงOffline checks that are worth doing
BR-DE-19 and -20 want a correct IBAN. This crate implements ISO 7064
mod-97-10 โ no registry, no network, so it still runs on wasm32. It cannot tell
you the account exists, only that the string is not a typo, which catches the
overwhelming majority of real errors.
Both are warnings, matching KoSITโs soll: a suspicion, not a rejection.
ยงNot levels โ siblings
It is tempting to model these as an ordered scale. There is no such order, and
the crateโs tests pin it: XRechnung permits BT-3 = 389 (self-billed) and
Peppol does not; Peppol permits 386 (prepayment invoice) and XRechnung does
not. Neither is โmore restrictiveโ.
The sharpest case is BT-119. CENโs BR-48 exempts category O from stating a
VAT breakdown rate; XRechnungโs BR-DE-14 requires it unconditionally.
Suppressing BT-119 for O โ on the strength of BR-O-05, which governs BT-152,
a different term โ is the natural mistake, and it fails the KoSIT validator.
ยงXRechnung merges 31 of Peppolโs rules โ and rewrites two
The Schematron in KoSITโs repository contains only BR-DE-*, BR-DEX-* and
BR-TMP-*. That file is an input, not the artefact. The build runs
peppol-into-xr.xsl over it, splicing in every Peppol assert named in
rule-list.xml, and that is what ships:
<target name="merge-peppol-rules-with-xr-rules">
<xslt in="โฆ/XRechnung-UBL-validation.sch" style="โฆ/peppol-into-xr.xsl" โฆ/>31 of Peppolโs 46. The fifteen left out are CL001โฆCL008 (Peppolโs own
narrower code lists) and P0104โฆP0112 (the VATEX-to-category pinning, plus
the German-parties type-code rule that would be circular inside a German CIUS).
Two are rewritten on the way in, and both differences are observable:
| Peppol | XRechnung | |
|---|---|---|
R120 severity | fatal | warning |
R040 / R120 slack | 0.02 always | 0.5 for HUF, 0.02 otherwise |
HUF has no minor unit in practice, so 0.02 is tighter than the currency can express โ and Peppol never widens it. The same forint invoice can be a valid XRechnung and an invalid Peppol document, so the two profiles hold separate instances of those rules rather than sharing one.
This reverses what this crate concluded one revision earlier, from reading KoSITโs validator configuration:
<resource>โฆ/EN16931-UBL-validation.xsl</resource> <!-- CEN's -->
<resource>โฆ/xsl/XRechnung-UBL-validation.xsl</resource> <!-- its own -->Two Schematrons, no Peppol โ which is true, and does not mean what it looks like. The second one already contains Peppolโs rules by the time the validator loads it.
ยงA CIUS restricts; an Extension adds โ and KoSIT ships one of each
profiles::XRECHNUNG_EXTENSION is ยง4.3โs second mechanism in the wild. Where the
CIUS narrows, it widens:
| Core / CIUS | Extension | |
|---|---|---|
| BT-125 mime code | six codes | + application/xml |
| scheme identifiers | ISO 6523 ICD / CEF EAS | + XR01โXR03 (DiGA) |
| BT-115 | BR-CO-16 | BR-DEX-09 โ third-party payments added back |
and it adds two groups the core model has no term for: BG-DEX-01 sub-invoice
lines, for positions that decompose, and BG-DEX-09 third-party payments, for
the German digital-health case where a statutory insurer settles part of an
invoice addressed to the insured. Both live in en16931::extensions, not on
InvoiceLine โ a core line has no child, and putting one there would make every
consumer carry a field only one Extension populates.
Because it widens, ยง4.4.4โs guarantee does not run: an Extension-valid invoice
need not be core-valid, so there is deliberately no
Underlies<XRechnungExtension> for En16931.
The CVD variant is the awkward case. Its identifier says #compliant# โ
ยง4.3โs word for a CIUS โ but BR-TMP-CVD-01 checks BT-158โs scheme against
UNTDID 7143 plus CVD, and CVD is not in UNTDID 7143. So a conforming CVD
invoice violates core BR-CL-13, which a CIUS may not cause. This crate follows
the behaviour rather than the label. Reporting BR-CL-13 as fatal on every CVD
invoice would be a false positive on a document KoSIT accepts.
ยงโ ๏ธ 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. Missing either one is how a validator comes to reject documents Germany accepts.
1. The validator configuration, for 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
Profile::levels carries all nine.
2. The Schematronโs own flag, for KoSITโs rules โ where 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 |
tests/codelists.rs 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. Two consequences are worth stating outright.
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, which is
the worst direction for a validator to be wrong in: it stops a document nobody
else would have stopped.
A finding is re-levelled, never dropped. No authority removes a rule, and
dropping one costs the report the line explaining why an unusual value is
present and unobjected to. The list is transcribed from the configuration, not
reconstructed from โwhich rule does each BR-DEX-* widen?โ โ that
reconstruction names PEPPOL-EN16931-CL001, which XRechnungโs build does not
merge in, and so removes nothing while CENโs BR-CL-24 goes on rejecting
exactly the application/xml attachment BR-DEX-01 exists to permit.
And XRechnung 3.0 is therefore not a conformant CIUS. ยง4.4.2 forbids a CIUS
to accept what the core model rejects, and relaxing BR-CL-23 does exactly that.
Profile::is_conformant_cius() computes this from the data rather than asserting
it, and answers false for three of the five shipped profiles:
| Profile | Conformant CIUS? | Because |
|---|---|---|
| EN 16931 | n/a | it is the core model |
| XRechnung 3.0 | no | BR-CL-21, BR-CL-23 โ warning |
| XRechnung 3.0 CVD | no | + BR-CL-13 โ information |
| XRechnung 3.0 Extension | no | + six more, BR-CO-16 among them |
| Peppol BIS Billing 3.0 | yes | ships the flags it means, and no override file |
So Validated<XRechnung> does not widen to Validated<En16931>, for the
same reason Validated<XRechnungCvd> does not. Re-validate instead โ
Validated::<En16931>::new(invoice) is one line, and it is a line that can
honestly fail.
ยง๐
Date โ a calendar day, not an instant
ยง6.5.9 is unusually explicit, and both halves matter:
Dates shall be in accordance to the โCalendar date complete representationโ as specified by ISO 8601. Calendar dates do not include a specification for the time of the day.
use en16931::Date;
let from = Date::parse("2026-06-01")?;
let to = Date::parse("2026-06-30")?;
assert!(to >= from); // BR-29
assert!(Date::parse("2026-02-30").is_err()); // not a real day
assert!(Date::parse("2026-06-01T00:00:00").is_err()); // ยง6.5.9: no time of dayThree integers, no timezone โ there is nothing to offset, and shifting BT-2 by a
zone changes the VAT period it falls in. Enable chrono or time for
conversions; the default build carries neither.
ยง๐ Percentage โ per cent, never a fraction
Percentages are given as fractions of a hundred (per cent) e.g. the value 34,78 % in percentage terms is given as 34,78. โ ยง6.5.5
use en16931::Percentage;
use rust_decimal::dec;
let vat = Percentage::new(dec!(19)); // nineteen per cent, NOT 0.19
assert_eq!(vat.to_string(), "19");
assert_eq!(vat.as_fraction(), dec!(0.19)); // what you multiply byThis is the most common transcription bug when bridging a calculation engine:
billing stores 0.19 because that is what you multiply by; the standard stores
what you print. Convert once, at the boundary.
Trailing zeros need no special handling โ rust_decimal compares by value, so
19 and 19.00 are one VAT breakdown group in Eq, Ord and Hash. The
crate pins that with a test, because a Hash disagreeing with Eq here would be
a silent, data-dependent grouping bug.
ยง๐ข Quantity โ and why it may be negative
Annex A.1.6 (Example 5 โ Negative Invoice line) invoices 25 cases of pens and credits 10 returned ones on the same ordinary invoice:
| BT-126 | BT-129 | BT-146 | BT-131 |
|---|---|---|---|
| 1 | 25 | 8,50 | 212,50 |
| 2 | โ10 | 8,50 | โ85,00 |
The sign lives on the quantity, never on the price โ BR-27 forbids a negative item net price.
use en16931::Quantity;
use rust_decimal::dec;
let returned = Quantity::new(dec!(-10));
assert!(returned.is_negative());ยงThe proof has to be earned
let proof: Validated<PeppolBis3> = Validated::new(invoice)?;
let core: Validated<En16931> = proof.widen(); // infallible โ ยง4.4.4Widening is free only from a conformant CIUS, and Profile::is_conformant_cius()
is the runtime witness โ computed from Profile::levels, not declared. It
answers false for three shipped profiles, and each false closed a hole.
impl Underlies<XRechnungCvd> for En16931 existed once, so
Validated<XRechnungCvd>::widen::<En16931>() compiled and produced a proof of
core-validity for an invoice violating BR-CL-13. A serialiser trusting the
core proof โ the entire purpose of Validated<P> โ would have been handed a
document no core-only receiver can process.
impl Underlies<XRechnung> for En16931 was the same hole one layer up, and it
survived the first fix because the argument stopped at the CVD variant. KoSIT
relaxes BR-CL-23 for every XRechnung scenario, so a unit code outside CENโs
Rec 20 table leaves an invoice valid as an XRechnung and invalid as a core
invoice โ and the widening turned that into a proof of the opposite.
an_xrechnung_invoice_can_be_core_invalid is the witness.
All three impls are gone, and tests/robustness.rs asserts the surviving
guarantee over arbitrary generated documents rather than one fixture: if a
conformant CIUS accepts it, core accepts it.
ยงโก Measured, not asserted
validate/core/5 1.50 ยตs โ the 5-line invoice
validate/core/1000 137.4 ยตs โ linear in line count
profile/EN 16931/5 1.57 ยตs
profile/XRechnung 3.0/5 6.30 ยตs
profile/XRechnung 3.0 Extension/5 5.05 ยตscargo bench. The target was โwell under 100 ยตs for a typical 5-line invoice
through the full core rule setโ; it is about 1.5 ยตs, and profile validation โ
282 checks for XRechnung โ is a handful.
The Extension being faster than the CIUS it extends is not a mistake: it runs fourteen more rules and its documents trip fewer of them, and at this scale the findings are a larger cost than the predicates.
Writing the benchmark immediately found a defect it existed to catch:
Profile::validate was 35 ยตs on the same document the core rules took 1.5 ยตs
on, for a profile that adds no rules at all. Comparing two rule ids built up to
three Strings, and profile validation does that once per rule per document.
Making the comparison allocation-free and skipping the intermediate Vec took it
to 2.3 ยตs โ 15ร, and 35ร for the Extension profile.
A performance claim in a README is worth exactly as much as the benchmark behind it. This one had none until it had a bug.
ยงโ๏ธ 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 just pushes people to fork the
rule set or ignore the validator.
use en16931::{Invoice, profiles, validation::Check};
let report = Check::new(&profiles::XRECHNUNG)
.without("BR-DE-15") // the buyer will not send BT-10
.run(&Invoice::default());
assert_eq!(report.suppressed(), ["BR-DE-15"]);XRechnung 3.0 validation (EN 16931-1:2017+A1:2019) โ 281 rule(s) checked, 27 finding(s), INVALID
โ 1 rule(s) suppressed and NOT checked: BR-DE-15The suppressed ids are on the report, printed by Display, carried in the JSON,
and rules_checked drops from 282 to 281 โ so a stored report cannot overstate
what ran.
It drops by one, because one check was actually removed โ not by
suppressed.len(), which counts requests: asking to skip BR-DE-15 against
the bare core profile, or naming a rule that resolves to nothing, would deduct
from checks that were never going to run. A number that can be wrong in the
reassuring direction is worse than no number.
And a deviated run cannot produce a proof. Check::prove refuses:
Check::of::<XRechnung>().without("BR-DE-15").prove::<XRechnung>(inv)
// Err(ProveError::Suppressed(["BR-DE-15"]))That is the XRECHNUNG_CVD lesson at runtime. A rule set with a hole may accept
documents the full set rejects, so a Validated<P> derived from it would claim
something untrue. Validated<P> means the whole rule set passed โ if it could
also mean most of it, no consumer could rely on it and the type would be
decoration.
Nor can it prove a profile it did not run. Reading only the type parameter
would make the profile a Check was built for and the profile the proof claims
two unrelated choices, so the two are compared:
Check::new(&profiles::XRECHNUNG).prove::<En16931>(inv)
// Err(ProveError::WrongProfile { checked: "XRechnung 3.0", claimed: "EN 16931" })Check::of::<P>() is the constructor that makes the mismatch unrepresentable:
the marker is the profile, so the two cannot be chosen separately. Check::new
stays for the runtime case โ a CLI resolving --profile, a service reading
BT-24 โ where the profile is not known until it runs.
ยง๐งญ The other crates
en16931-formats carries the
syntax layer โ the UBL 2.1 and CII bindings in both directions, the 1 339 syntax
rules, the XRechnung CIUS, and ZUGFeRD / Factur-X hybrid PDFs โ behind features,
so a consumer that wants UBL does not compile a PDF parser. It is what parses an
inbound document into the Invoice these rules run against, and what turns a
Validated<P> back into bytes.
One crate there, not three. XRechnung is carried in UBL and CII, and 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.
But it is a separate crate from this one, and that boundary is load-bearing.
It depends on en16931, so rustc forbids the reverse: โthe semantic rules do not
depend on a syntaxโ is enforced rather than promised. The payoff is measurable โ
this crateโs graph is 10 crates and builds for wasm32; adding an XML parser
would end the first claim and a PDF parser takes it to 57 crates and breaks
the target outright.
The two meet at exactly two places. Validated<P> is one: ubl::write_validated
demands the proof, so an unchecked invoice cannot be serialised. The other is
ubl::to_string_for(&invoice, &XRECHNUNG), which validates on the spot and hands
back Result<String, NotValid> โ for when the profile is a runtime choice, as it
is whenever a counterpartyโs preferred CIUS comes out of a database. Neither can
produce a document whose BT-24 disagrees with the rules that were run.
And en16931-cli, if the question is about a file rather
than about a type:
$ en16931 validate rechnung.xml
$ en16931 explain BR-CO-14One binary, everything above turned on, and exit 0 / 1 / 2 for valid /
invalid / unreadable. It may enable every feature precisely because nothing
depends on it: the graph discipline in this README exists to protect a
consumerโs dependency tree, and a binary is in nobodyโs.
ยง๐ค A report 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.
<svrl:schematron-output title="EN 16931 โ XRechnung 3.0" schemaVersion="EN 16931-1:2017+A1:2019">
<svrl:failed-assert id="BR-02" flag="fatal" location="BT-1" test="en16931:BR-02">
<svrl:text>An Invoice shall have an Invoice number (BT-1).</svrl:text>
</svrl:failed-assert>
<svrl:failed-assert id="BR-CL-25" flag="fatal" location="BG-7/BT-49" test="en16931:BR-CL-25">
<svrl:diagnostic-reference diagnostic="en16931-hint">9958 was DE:LID โ the Peppol
Leitweg-ID scheme, and has been withdrawn; use 0204 instead</svrl:diagnostic-reference>
<svrl:text>Endpoint identifier scheme identifier MUST belong to the CEF EAS code list.</svrl:text>
</svrl:failed-assert>
</svrl:schematron-output>svrl:text stays byte-identical to the authorityโs wording โ that is what
makes a finding look up in CENโs or KoSITโs index. This crateโs own advice goes
in Schematronโs supplementary-text element instead, so a consumer that does not
know about it ignores it and loses nothing. See
Hints.
That feature adds no dependencies. SVRL is a report format, not an invoice syntax: writing it needs escaping, not a parser, and no UBL or CII element names. The โno XMLโ rule is about never learning a syntax binding โ this crate still could not parse an invoice if it wanted to.
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. Everything reading
SVRL for which rules failed and why works unchanged.
use en16931::{Report, Invoice, profiles};
let report = profiles::XRECHNUNG.validate(&Invoice::default());
let out = Report::of(&report);
assert_eq!(out.schema, "en16931-report/3");
assert_eq!(out.profile.as_deref(), Some("XRechnung 3.0"));{
"schema": "en16931-report/3",
"valid": false,
"profile": "XRechnung 3.0",
"edition": "EN 16931-1:2017+A1:2019",
"rulesChecked": 282,
"attribution": "implementation of the EN 16931-1 semantic data model; โฆ",
"artefacts": [
{ "authority": "CEN", "repo": "ConnectingEurope/eInvoicing-EN16931",
"gitRef": "validation-1.3.16" },
{ "authority": "KoSIT", "repo": "itplr-kosit/xrechnung-schematron",
"gitRef": "v2.5.0" },
{ "authority": "KoSIT", "repo": "itplr-kosit/validator-configuration-xrechnung",
"gitRef": "v2026-01-31" },
{ "authority": "OpenPeppol", "repo": "OpenPEPPOL/peppol-bis-invoice-3",
"gitRef": "v3.0.20" }
],
"findings": [
{ "rule": "BR-02", "severity": "fatal", "source": "standard+artefact",
"location": "BT-1", "text": "An Invoice shall have an Invoice number (BT-1)." },
{ "rule": "BR-CL-25", "severity": "fatal", "source": "artefact",
"location": "BG-7/BT-49",
"text": "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" }
]
}ValidationReport derives Serialize, but that shape is the crateโs internals
and changes when they do. Report is a separate, versioned shape โ the internal
layout may change freely, this one only by bumping schema.
It is designed against SVRL rather than invented. Every Schematron tool in
the field emits <svrl:failed-assert id flag location> with a <svrl:text>, and
each maps one-to-one, so an en16931-svrl crate is a rename rather than a
translation. One field deliberately differs: SVRLโs location is an XPath into a
serialised document, and this crateโs is a business-term path
(BG-25[2]/BT-151). A crate holding the XML can map BT โ XPath; the reverse is
lossy, so the semantic form is the one worth storing.
Three things travel with it 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 rather than per crate, 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 is naming 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 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.
ยง๐ Every finding can be explained
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_eq!(explain("BR-IG-8").map(|r| r.id.as_str()), Some("BR-AF-08")); // family alias
assert!(explain("PEPPOL-EN16931-R120").is_some());
assert!(explain_restriction("br-de-3").is_some()); // a restrictionRules are data โ id, severity, provenance, the business terms they touch, and
the standardโs own wording โ so the registry is listable, filterable and
explainable. touching(BtId(117)) answers โwhich rules constrain BT-117โ.
explain searches every rule the crate ships, not the core set alone: an
ordinary XRechnung report cites BR-DE-16 and PEPPOL-EN16931-R120, and a
registry that cannot explain its own findings is not a registry.
explain_restriction covers the profile restrictions, which are data rather
than predicates and so have no Rule to hand back.
ยง๐ Invariants survive serde
serde_json::from_str::<InvoiceAmount>(r#""1.234""#).is_err() // three decimals
serde_json::from_str::<Date>(r#""2026-06-30T12:00:00Z""#).is_err() // an instantA derived Deserialize rebuilds private fields without calling the
constructor, which would make โtypes enforce representabilityโ true everywhere
except the one boundary where untrusted data arrives. The types with invariants
use #[serde(try_from = โฆ)] so deserialisation re-runs the check.
That was advertised in Cargo.toml and untested. Writing the test found that
Attachment enforced nothing at all: the docs said โmime code and filename
mandatoryโ per ยง6.5.11 and Attachment::new happily accepted "" for both.
Nothing else caught it either โ the only rule requiring a filename is
UBL-DT-07, a syntax rule this crate deliberately does not implement. It is
a Result now.
ยง๐ก๏ธ It does not panic
proptest! {
#[test]
fn validate_never_panics(inv in any_invoice()) { โฆ }
}A clearing platform does not choose its inputs. tests/robustness.rs 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.
proptest rather than cargo-fuzz on purpose: it runs in the ordinary suite on
every commit. A property that only runs when someone remembers is a property that
regresses.
ยง๐ Editions are values, not crate versions
use en16931::{profiles, Edition};
assert_eq!(profiles::XRECHNUNG.edition, Edition::En2017A1);
assert_eq!(Edition::En2017A1.designation(), "EN 16931-1:2017+A1:2019");EN 16931-1:2026 is published and 2017 formally withdrawn โ but every deployed validator (XRechnung 3.0.2, Peppol BIS 3.0, ZUGFeRD 2.x) is a usage specification of 2017+A1:2019. Leading with :2026 would produce a crate that fails all of them.
So the edition is a property of the profile, and a document declares its
profile in BT-24 โ which means for_specification_id recovers the edition from
the document itself. When XRechnung 4.0 arrives it is a new Profile, a minor
release, not a new crate.
Edition::En2026 exists as a classification and no profile declares it: a
test fails the build if one does without a rule set to go with it. It carries no
term assignments either โ a map from business term to introducing edition can
only be built from the :2026 normative text, and writing one from memory is how
you ship a validator that is confidently wrong.
ยง๐ Optional: stronger payment identifiers via sepa
BR-DE-19 and BR-DE-20 say BT-84 and BT-91 โshould contain a valid IBANโ.
By default that check is ISO 7064 mod-97-10 โ correct, and blind to length:
a 21-character German IBAN with consistent check digits passes, and no German
bank will take it.
en16931 = { version = "0.1", features = ["sepa"] }turns it into the full ISO 13616 registry โ 89 countries, each with its own
length and BBAN structure โ and adds EN-SEPA-01, this crateโs own warning that
BT-90 is a well-formed EPC AT-02 creditor identifier. No rule anywhere checks
that, yet a direct debit quoting a malformed creditor identifier is rejected by
the bank long after the invoice was accepted.
Off by default, because the default build is rust_decimal + thiserror and
nothing else, and sepa brings quick-xml โ which this crate otherwise goes
to some lengths not to have.
ยง๐ท๏ธ Code lists โ 4 887 values, generated and re-verified
Eighteen lists, from UNCL 5305โs ten VAT categories to UN/ECE Rec 20โs 2 162
unit codes. All generated from the pinned CEN artefacts by cargo xtask codegen, all
re-checked against them by tests/codelists.rs, and all re-derived in CI by
cargo xtask check so they cannot drift from the artefacts they came from.
The generator refuses to guess. BR-CL-01โs test is a disjunction carrying two
different lists, so the table declares which branch it wants and the generator
fails if the shape changes. BR-CL-08โs UNCL 4451 is bound differently by each
of CENโs three syntaxes โ EDIFACT 381 codes โ UBL 383 โ CII 401, three frozen
UNTDID directory revisions โ so the generator checks that they still form a
chain, takes the union, and stops outright if one binding ever gains a code
another dropped.
use en16931::codes::{contains, generated::UNIT_CODES};
use en16931::VatCategory;
assert!(contains(UNIT_CODES, "KWH"));
assert!(!contains(UNIT_CODES, "kwh")); // ยง6.5.8: codes are entered exactly
assert_eq!(VatCategory::from_code("AE"), Some(VatCategory::ReverseCharge));
assert_eq!(VatCategory::from_code("ae"), None);VatCategory carries the semantics the rules branch on:
use en16931::VatCategory;
// Both carry zero tax, but Z FORBIDS an exemption reason and E REQUIRES one.
assert!(VatCategory::ZeroRated.forbids_exemption_reason()); // BR-Z-10
assert!(VatCategory::Exempt.requires_exemption_reason()); // BR-E-10
// B is the only category with neither rule โ and unlike AE, it is taxed.
assert!(VatCategory::SplitPayment.carries_tax());
// Only O suppresses the LINE rate (BT-152). BT-119 is a different term.
assert!(!VatCategory::OutOfScope.states_rate());ยงWhy the generator is paranoid
A Schematron test is a program, not a data structure. Three of the eighteen
tables cannot be read off it directly, for three different reasons:
BR-CL-01is a disjunction overself::โ 50 codes forcbc:InvoiceTypeCode, 13 forcbc:CreditNoteTypeCode. They overlap in exactly one code (81) and are disjoint on380/381.BR-CL-10is the ISO 6523 list plus a contextual literal:SEPAis admissible only on a party identification undercac:AccountingSupplierPartyorcac:PayeeParty. It therefore belongs in the rule, not in a flat table.BR-CL-08is not in the code-list file at all โ UBL embeds BT-21 in the note text (#AAI#the text), so the list lives in the preprocessed binding. And the three syntaxes disagree: EDIFACT 381 โ UBL 383 โ CII 401, three frozen UNTDID directory revisions. A syntax-independent crate cannot know which syntax an invoice will be written to, so it takes the union โ rejecting a code a CEN binding accepts would be a false positive on a lawful invoice. The generator verifies the chain is still nested and stops if it ever breaks.
An extractor that reads the first contains(โฆ) and stops is confidently,
precisely wrong on the first two. That mistake was made during this crateโs
design and produced an incorrect bug report against an upstream project โ so the
defence is a test, not a promise to be careful. The UNCL 4451 list was very
nearly written from memory during the last pass of this crateโs development;
it would have been wrong in both directions.
ยง๐ Examples
cargo run --example validate_an_invoice # build one, validate, read the report
cargo run --example build_and_reconcile # lines in, BG-23 and BG-22 derived
cargo run --example profiles_and_proofs # every profile, and the typed proof
cargo run --example report_formats --features serde,svrl # JSON and SVRL outputยง๐งฐ Development
just is the task runner; just on its own lists every
recipe. There are no shell scripts โ fetching and generating are cargo xtask subcommands, so they are compiled, type-checked and linted like the rest
of the crate and behave the same on every platform CI uses.
cargo xtask fetch # โ spec/ (gitignored, ~136 MB); the suites become live
cargo xtask codegen # regenerate src/codes/generated.rs; review the diff
cargo xtask check # fail if the committed file no longer matches
just ci # everything CI runs, locallyยง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.
ยงThe artefacts
spec/ is not committed: the CEN artefacts are EUPL-1.2, a reciprocal
licence, and keeping them out is what keeps this crate MIT OR Apache-2.0 โ
which is also why deny.tomlโs allow-list does not mention EUPL.
The fetch pulls four repositories and nothing else: the CEN validation
artefacts, Peppol BIS Billing 3.0, and KoSITโs XRechnung Schematron and
validator configuration. It does not fetch specification PDFs โ including
EN 16931-1 itself, whose full English text รNMS SR publishes openly. Reading the
standard is a research task, not a build step; spec/README.md lists the routes.
| Pinned at | |
|---|---|
ConnectingEurope/eInvoicing-EN16931 | validation-1.3.16 |
itplr-kosit/xrechnung-schematron | v2.5.0 โ its changelog says โcompatible with XRechnung 3.0.xโ |
itplr-kosit/validator-configuration-xrechnung | v2026-01-31 |
OpenPEPPOL/peppol-bis-invoice-3 | v3.0.20 |
All four are release tags, and three of them used not to be. Tracking
master is not merely irreproducible; an authorityโs master is its next
release. When this was fixed, KoSITโs validator-configuration branch carried two
customLevel overrides โ CII-SR-465, CII-SR-466 โ that appear in no
published release. A crate whose central claim is that it reports rules at the
severities the authorities publish was reading severities nobody had published.
Each profile declares which of these its rules were checked against, and that
list travels in every report โ see above.
tests/artefact_pin.rs asserts every declared ref is one xtask actually
fetches, so a profile cannot cite a release the suites never ran on.
Pins are fully-qualified refs, and that is not pedantry: 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 code lists.
ยงThe code lists are generated, not written
src/codes/generated.rs holds 4 887 values across 18 tables. The generator
fails rather than guesses: a Schematron test is a program, not a data
structure, and BR-CL-01 alone carries two different lists in one disjunctive
expression โ 50 invoice type codes and 13 credit-note ones. An extractor that
reads the first and stops is confidently wrong. So every table declares how its
list is selected, and a rule that changes shape stops the build.
It also proves claims made in comments elsewhere: that Peppolโs UNCL5189
really is identical to the CEN table, and that the three CEN bindingsโ UNCL 4451
lists (381 / 383 / 401 codes) really are nested โ so taking their union is
directory drift and not a divergence being papered over. cargo xtask check
runs in CI, so none of it can quietly rot.
ยงโ๏ธ Licence
MIT OR Apache-2.0, at your option.
ยงAttribution
implementation of the EN 16931-1 semantic data model; ยฉ CEN, used under the 2018 CENโEC licence agreement
This crate is an implementation of the semantic data model of EN 16931-1 and of the two mandatory syntaxes listed in CEN/TS 16931-2. EN 16931-1 and CEN/TS 16931-2 are made available free of charge by CEN and the European Commission under their 2018 licence agreement, which permits derivative use on condition that derivative applications carry a statement to this effect. Copyright in the standard remains with CEN.
The notice above is en16931::ATTRIBUTION, and every ValidationReport carries
it verbatim. tests/attribution.rs asserts all three copies still agree โ
because losing a licence condition by reformatting is the kind of mistake
nothing else in a build would catch.
Re-exportsยง
pub use amount::InvoiceAmount;pub use amount::UnitPriceAmount;pub use attachment::Attachment;pub use attachment::AttachmentError;pub use bt::BtId;pub use bt::Group;pub use bt::Path;pub use codes::VatCategory;pub use date::Date;pub use edition::Edition;pub use error::AmountError;pub use error::ParseAmountError;pub use error::ParseDateError;pub use extensions::AdvancePayment;pub use extensions::Extensions;pub use extensions::SubInvoiceLine;pub use extensions::ThirdPartyPayment;pub use identifier::DocumentReference;pub use identifier::Identifier;pub use invoice::DocumentKind;pub use invoice::Invoice;pub use invoice::InvoiceLine;pub use invoice::InvoiceNote;pub use numeric::Percentage;pub use numeric::Quantity;pub use profiles::En16931;pub use profiles::PeppolBis3;pub use profiles::XRechnung;pub use profiles::XRechnungCvd;pub use profiles::XRechnungExtension;pub use reconcile::ReconcileError;pub use reconcile::Reconciler;pub use reconcile::reconcile;pub use report::Report;pub use validation::profile::Profile;pub use validation::profile::Validated;pub use validation::Check;pub use validation::Finding;pub use validation::ProveError;pub use validation::Severity;pub use validation::ValidationReport;pub use validation::validate;
Modulesยง
- amount
InvoiceAmountโ EN 16931Amount. Type, and the reason 21 rules cannot fire.- attachment
Attachmentโ EN 16931 ยง6.5.11Binary Object. Type.- billing_
adapter billing - Conversion from
billing::BillingDocumentโ behind thebillingfeature. - bt
BtId,BgIdandPathโ how a finding says where.- codes
- Code lists โ EN 16931-1 ยง6.5.8
Code. Type. - date
Dateโ EN 16931Date. Type, a calendar day with no time of day.- edition
- Which edition of EN 16931-1 a profile is a usage specification of.
- error
- Error types.
- extensions
- Data that has no core business term โ EN 16931โs second extension mechanism.
- fmt
- Formatting that cannot print a value that is not the value.
- identifier
IdentifierandDocumentReferenceโ EN 16931 ยง6.5.6 and ยง6.5.7.- invoice
- The EN 16931 core invoice model โ Table 2, as structs.
- numeric
PercentageandQuantityโ EN 16931 ยง6.5.5 and ยง6.5.4.- prelude
- Convenience glob import.
- profiles
- The profiles this crate ships.
- reconcile
- Deriving BG-23 and BG-22 from the lines โ the arithmetic every hand-mapper otherwise re-implements.
- report
- A stable interchange shape for a
ValidationReport. - svrl
svrl - SVRL output โ the format every other validator in this field speaks.
- validation
- The validation engine โ rules as data, findings as a report.
Constantsยง
- ARTEFACT_
VERSION - The CEN validation-artefacts release this crateโs rule metadata and code lists are generated from.
- ATTRIBUTION
- The notice the CENโEC licence agreement requires this crate to carry.
- DEFAULT_
EDITION - The edition of EN 16931-1 this crateโs core rule set targets by default.