en16931

The semantic model

EN 16931's business terms and groups as Rust types: fixed-point amounts, calendar dates, per-cent percentages, signed quantities, and eighteen generated code lists.

EN 16931-1 defines an invoice as 164 business terms (BT-1 … BT-165) arranged into business groups (BG-1 … BG-25). That is the whole standard: the two XML syntaxes are bindings of it, and the business rules are constraints on it.

This crate makes the model the primary artefact. Invoice is a plain Rust struct whose fields are business terms, and everything else — validation, reconciliation, both syntaxes, the PDF container — is built on top of it.

The design invariants

These four decisions explain most of the API.

No f64. Amounts are fixed-point; rates and quantities are Decimal. A binary float cannot represent 0.10, and an invoice is a legal statement about money.

Rounding is never implicit. An amount that does not fit two decimals is an error, not a rounding opportunity. Whoever produced the third decimal knows what should happen to it; the type does not.

Types enforce representability; rules enforce validity. An invalid invoice must still be representable — otherwise a parser cannot load one in order to explain what is wrong with it. So the terms the standard makes mandatory — BT-1, BT-2, BT-3, BT-5, BT-24 — are Option in Invoice, and BR-01 … BR-05 report their absence; Invoice::builder takes them as arguments, so code that builds an invoice cannot forget them. Types carry what no valid or invalid document can violate: a third decimal on an amount, a time of day on a date, two payment groups at once.

No I/O, no async, no unsafe. #![forbid(unsafe_code)], and wasm32 is tested in CI.

The ten semantic data types

§6.5 of the standard defines ten data types, and each is a distinct Rust type here rather than a String or a Decimal with a comment.

§6.5RustWhy it is its own type
AmountInvoiceAmounttwo decimals, hard
Unit Price AmountUnitPriceAmountnot capped — see below
QuantityQuantitymay be negative
PercentagePercentageper cent, not a fraction
DateDatea calendar day, with no time of day
IdentifierIdentifiervalue plus optional scheme
Document referenceDocumentReferencean identifier with no scheme
CodeCodea value from a named list
TextString
Binary objectAttachmentbytes plus MIME type plus filename

InvoiceAmount — where 21 rules go to die

§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 lists every term it applies to, and the CEN artefacts render that table as 21 Schematron assertions — BR-DEC-01, -02, -05, -06, -09…-20, -23…-25, -27, -28. A type that cannot hold a third decimal retires all 21 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. The standard’s own example 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());

Metering, freight and commodity prices need the fourth and fifth decimal, so BT-146 is not capped.

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 day

Three integers, no timezone. There is nothing to offset, and shifting BT-2 by a zone changes the VAT period it falls in. Enable the chrono or time feature 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 by

This is the most common transcription bug when bridging a calculation engine: a tax engine stores 0.19 because that is what you multiply by, and 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. A Hash disagreeing with Eq here would be a silent, data-dependent grouping bug, so a test pins it.

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-126BT-129BT-146BT-131
1258,50212,50
2−108,50−85,00

The sign lives on the quantity, never on the price — BR-27 forbids a negative item net price.

Code lists — 4 876 values, generated

4 876 values across 18 tables, generated from the CEN artefacts and re-verified in CI: ISO 3166-1 countries, ISO 4217 currencies, UNTDID 1001 document types, UNTDID 5305 VAT categories, UNTDID 4451 note subjects, UN/ECE Rec 20 and Rec 21 unit codes, the EAS scheme list, ISO 6523 ICD, VATEX exemption reasons, and the rest. None is written by hand; cargo xtask check re-derives all of them and fails if a committed table differs.

contains answers yes or no. guard answers what to do, at the point of mapping:

use en16931::codes::guard;

// `9958` — the scheme every German integrator reaches for. Withdrawn 2023-07-31.
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\""));

Twelve EAS schemes have left the CEF list since CEN artefact validation-1.2.0, nine of them in 2023 alone, and the table names the successor of each. That the withdrawn codes really are gone from the current list, and that 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.

guard checks the same generated list the corresponding rule checks; skipping it loses nothing but the earlier message.

Formatting never shortens a value

No Display truncates. On the numeric types, precision is a minimum number of fraction digits: {:.4} on 1190.00 is 1190.0000, and {:.0} is still 1190.00. Width, fill and alignment work as usual. en16931::fmt exposes the two helpers, so a downstream Display can make the same promise.

Editions are values, not crate versions

Each profile names its Edition of EN 16931-1 (Profile::edition, printed in every report header). A profile is a value of one type, not a version of this crate, so checking one invoice against two profiles is two calls in one build.

See also

  • Validation — what the rules do with all of this.
  • Profiles — restricting the model for XRechnung or Peppol.