en16931

Getting started

Install the en16931 crates or the CLI, build your first EN 16931 invoice, reconcile the totals and read the validation report.

Two ways in. If what you have is a file and a question, use the command. If what you have is data in a program, use the library.

The command

cargo install en16931-cli

One binary, named en16931. It reads UBL, CII and ZUGFeRD PDFs, and picks the rule set from the document’s own BT-24 customization identifier.

en16931 validate rechnung.xml                # accept, reject or undefined — and why
en16931 validate rechnung.xml -r xrechnung   # …and require a profile of it
en16931 inspect  rechnung.pdf                # what *is* this file?
en16931 convert  rechnung.xml --to cii       # through the model; refuses to lose anything
en16931 extract  rechnung.pdf                # the embedded XML, verbatim
en16931 embed    gerendert.pdf rechnung.xml -o rechnung.pdf   # a ZUGFeRD hybrid
en16931 explain  BR-CO-14                    # what does this rule say, and who runs it?

Exit 0 accepted, 1 rejected or undefined, 2 a file that could not be opened — so a pipeline can tell this invoice is wrong from that path does not exist. The CLI page covers every subcommand.

The library

[dependencies]
en16931 = "0.8"
rust_decimal = "1"

Two dependencies — rust_decimal and thiserror. No XML parser, no I/O, no async, no unsafe, and it builds for wasm32, so an invoice can be validated in a browser without leaving the client.

Add the syntax layer only if a document has to cross a wire:

en16931-formats = { version = "0.8", features = ["ubl", "cii"] }

A first invoice

Two lines at two VAT rates, built, reconciled and validated. Nothing is elided:

use en16931::invoice::{Party, PaymentInstructions, PostalAddress};
use en16931::{Date, Identifier, InvoiceAmount, Percentage, Quantity, prelude::*};
use rust_decimal::dec;

let seller = Party::new(
        "Stadtwerke Musterstadt GmbH",
        PostalAddress::new("Hauptstraße 1", "Musterstadt", "12345", "DE")?,  // BR-09
    )
    .with_vat_identifier("DE123456789")
    .with_electronic_address(Identifier::eas("4012345000009", "0088")?);    // GLN
let buyer = Party::new(
        "Beispiel AG",
        PostalAddress::new("Marktplatz 2", "Beispielstadt", "54321", "DE")?, // BR-11
    )
    .with_electronic_address(Identifier::eas("991-01234-56", "0204")?);

let invoice = Invoice::builder(
        "urn:cen.eu:en16931:2017",       // BT-24 — which rule set applies
        "R-2026-0001",                   // BT-1
        Date::parse("2026-07-31")?,      // BT-2
        "380",                           // BT-3 — commercial invoice
        "EUR",                           // BT-5
    )
    .seller(seller)
    .buyer(buyer)
    .payment(PaymentInstructions::sepa_credit_transfer("DE02120300000000202051", None))
    .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))),
    ))
    .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());

Three things in there are worth pausing on.

Identifier::eas is checked at the call, not at validation time. 9958, withdrawn on 2023-07-31, is rejected with its successor named; for a GLN (0088) the check digit is checked too.

build_reconciled computes BG-22 and BG-23, the document totals and the VAT breakdown. They are a function of the lines — BR-CO-10 … BR-CO-16, the -08 and -09 rows of all nine VAT category families, and BR-CO-18 are that one function checked from the outside. If your engine already produced the positions, it should not also have to know that BT-107 is absent rather than zero when there are no allowances. Use .build() instead when the totals came from somewhere authoritative and you want them checked rather than replaced.

Mandatory terms are arguments. BT-24, BT-1, BT-2, BT-3 and BT-5 are arguments to builder, so code that builds an invoice cannot forget them. In Invoice itself they are Option, so that a received document missing one can still be read and reported on (BR-01 … BR-05).

Or start from a valid invoice. en16931::examples::for_profile(&profile) returns a valid invoice for every shipped profile, and examples::annex_a() the eight worked examples of EN 16931-1 Annex A. Change one field and the report names exactly what that change broke:

use en16931::{examples, profiles};

let mut invoice = examples::for_profile(&profiles::XRECHNUNG).expect("shipped");
invoice.buyer_reference = None;                     // drop the Leitweg-ID
let report = profiles::XRECHNUNG.validate(&invoice);
assert_eq!(report.fatal().map(|f| f.rule.as_str()).collect::<Vec<_>>(), ["BR-DE-15"]);

Reading a report

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 report 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 at once, 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 — EN 16931-1 writes BR-CO-4, the CEN artefacts write BR-CO-04:

use en16931::validation::rules;

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"));

Then a profile

Plain validate runs core EN 16931. A German public-sector invoice must also satisfy XRechnung, and that is a different call — or, better, a proof:

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

let proof: Validated<XRechnung> = Validated::new(invoice)?;   // Err carries the report

Validated<XRechnung> is a type you cannot forge. Hand it to a serialiser and that serialiser physically cannot be given an unchecked invoice, or one checked against a different profile. See profiles.

Then a document

When the invoice has to cross a wire, en16931-formats has one call per task. Receiving is the one a mandate makes everyone do — bytes and a policy in, a decision out:

use en16931::profiles;
use en16931_formats::{Policy, Recommendation, Syntax};

let policy = Policy { require: Some(&profiles::XRECHNUNG), ..Policy::default() };
let got = en16931_formats::receive(&bytes, &policy);
if got.recommendation != Recommendation::Accept {
    eprintln!("{got}");                 // the decision, why, and every layer's report
}

let out = en16931_formats::write(&proof, Syntax::Ubl);   // a proof in, BT-24 stamped

See syntaxes for reading, writing and converting, and ZUGFeRD for hybrid PDFs.

See also

  • The semantic model — business terms, the ten data types, and why amounts are not f64.
  • Validation — the 334 rules, severity, tolerance regimes, and reports you can store and diff.
  • Profiles — XRechnung, Peppol BIS Billing 3.0, CIUS versus Extension.
  • Syntaxes — UBL 2.1 and UN/CEFACT CII, both directions.
  • Conformance — what is measured, and against whose artefacts.