Skip to main content

en16931_formats/
lib.rs

1//! **European e-invoicing formats**, on top of the EN 16931 semantic model.
2//!
3//! ```text
4//!         ┌──────────────┐                       ┌──────────────────────┐
5//!         │   billing    │                       │  inbound documents   │
6//!         │ calculations │                       │  UBL / CII / EDIFACT │
7//!         └──────┬───────┘                       └──────────┬───────────┘
8//!                │  adapter (optional feature)              │
9//!                └──────────────┬───────────────────────────┘
10//!                               ▼
11//!                     ┌─────────────────────┐
12//!                     │      en16931        │  semantic model, 316 rules,
13//!                     │  proof of validity  │  no XML, no PDF, no I/O
14//!                     └──────────┬──────────┘
15//!                                ▼
16//!                     ┌─────────────────────┐
17//!                     │  en16931-formats    │  ← you are here
18//!                     │   UBL · CII · PDF   │
19//!                     └─────────────────────┘
20//! ```
21//!
22//! [`en16931`] decides whether an invoice is **correct**. This crate decides
23//! what it looks like **on the wire**, and re-implements not one of the 316
24//! rules.
25//!
26//! # Why one crate, not one per format
27//!
28//! XRechnung is carried in UBL *and* CII; every ZUGFeRD payload is CII. A crate
29//! per format would need the CII binding twice, and two bindings drift. Cargo
30//! features already express which syntax a consumer wants, so a crate boundary
31//! here would be solving with a package what `--no-default-features` solves for
32//! free.
33//!
34//! What *is* a separate crate is [`en16931`], and that boundary is
35//! load-bearing: this crate depends on it, so rustc forbids the reverse. "The
36//! semantic rules do not depend on a syntax" is enforced rather than asked for,
37//! and `en16931`'s dependency graph stays at ten crates and builds for
38//! `wasm32`.
39//!
40//! # Features, and what each one costs
41//!
42//! | Feature | Default | Graph | What |
43//! |---|---|---|---|
44//! | [`ubl`] | ✅ | 13 crates | UBL 2.1, both directions |
45//! | [`cii`] | — | 13 crates | UN/CEFACT CII D16B, both directions |
46//! | [`zugferd`] | — | **57 crates** | ZUGFeRD / Factur-X hybrid PDFs |
47//! | `render` | — | + a typesetting engine | Corporate design — **not yet implemented** |
48//!
49//! `zugferd` is off by default and that matters: `lopdf` brings AES, ChaCha20,
50//! SHA-2, `getrandom` and `libc`, and the result does not build for
51//! `wasm32-unknown-unknown`. Nobody reading a UBL invoice should pay for that.
52//!
53//! # The 91 % that costs a writer nothing
54//!
55//! CEN's artefacts carry **1 339** syntax rules, and **1 220 of them (91 %)**
56//! say some element "shall not be used" — they fence off the parts of UBL 2.1
57//! and CII D16B that EN 16931 does not use. That inverts the usual expectation:
58//!
59//! * A **writer** driven from the semantic model cannot violate them. It has no
60//!   way to express `cbc:UUID`, because the model has no term for it. They are
61//!   *unreachable*, not cheaply satisfied — the same shape as `InvoiceAmount`
62//!   making `BR-DEC-*` unrepresentable. So the writer answers to roughly **119**
63//!   real rules.
64//! * A **reader** must cope with all 1 339, because the document came from
65//!   somewhere else.
66//!
67//! Unreachability is a claim, so the serialiser enforces it against the
68//! prohibitions extracted from CEN's own Schematron, and `tests/subset.rs`
69//! asserts the writer never needs that safety net. See [`ubl::prohibitions`].
70//!
71//! # Quick start
72//!
73//! ```
74//! # #[cfg(feature = "ubl")] {
75//! use en16931::Invoice;
76//!
77//! let xml = en16931_formats::ubl::to_string(&Invoice::default());
78//! let read = en16931_formats::ubl::from_str(&xml).expect("readable");
79//! assert!(read.unmapped.is_empty(), "nothing was silently dropped");
80//! # }
81//! ```
82//!
83//! # Attribution
84//!
85//! The bindings are derived from CEN's EUPL-1.2 validation artefacts and the
86//! element order from the authorities' published instances. The notice is a
87//! licence condition, not decoration, and it has a test.
88
89#![cfg_attr(docsrs, feature(doc_cfg))]
90#![warn(missing_docs, clippy::pedantic)]
91#![allow(
92    clippy::must_use_candidate,
93    clippy::missing_errors_doc,
94    // The writer is one function per aggregate and `write` is a straight walk
95    // of the document element's sequence. Splitting it to satisfy a line count
96    // would scatter the order it exists to express.
97    clippy::too_many_lines,
98    // `en16931::invoice::*` is the model. Naming forty types individually in
99    // three files is noise, not clarity.
100    clippy::wildcard_imports
101)]
102
103#[cfg(feature = "cii")]
104#[cfg_attr(docsrs, doc(cfg(feature = "cii")))]
105pub mod cii;
106#[cfg(feature = "ubl")]
107#[cfg_attr(docsrs, doc(cfg(feature = "ubl")))]
108pub mod ubl;
109pub mod xrechnung;
110
111#[cfg(any(feature = "ubl", feature = "cii"))]
112mod xml;
113#[cfg(feature = "zugferd")]
114#[cfg_attr(docsrs, doc(cfg(feature = "zugferd")))]
115pub mod zugferd;
116
117pub use xrechnung::{Flavour, detect};
118
119/// A document was **not** written, because it did not pass the profile it was
120/// asked to be written for.
121///
122/// Returned by [`ubl::to_string_for`] / [`ubl::write_for`] and their CII twins.
123///
124/// # Why an error type rather than the report itself
125///
126/// [`en16931::ValidationReport`] is a *product* — you store it, diff it, show
127/// it to an operator — and it deliberately does not implement
128/// [`std::error::Error`]. `report.is_valid()` being false is an ordinary
129/// outcome of validating, not a failure of validation.
130///
131/// It *is* an error when the thing you asked for was a shippable document. This
132/// type is that framing, so `?` works and the message says what happened rather
133/// than spilling forty findings into a log line. The report is right there in
134/// [`report`](Self::report) when you want it.
135///
136/// [`ubl::to_string_for`]: crate::ubl::to_string_for
137/// [`ubl::write_for`]: crate::ubl::write_for
138#[derive(Debug)]
139pub struct NotValid {
140    profile: &'static str,
141    // Boxed: a report carries a `Vec<Finding>` and several `String`s, and the
142    // success arm of these `Result`s is one `String`. Unboxed, the error would
143    // widen every one of them.
144    report: Box<en16931::ValidationReport>,
145}
146
147impl NotValid {
148    /// The full report — every finding, in the order the validator produced it.
149    #[must_use]
150    pub fn report(&self) -> &en16931::ValidationReport {
151        &self.report
152    }
153
154    /// Take the report.
155    #[must_use]
156    pub fn into_report(self) -> en16931::ValidationReport {
157        *self.report
158    }
159
160    /// The profile the invoice was checked against — `"XRechnung 3.0"`.
161    #[must_use]
162    pub fn profile(&self) -> &'static str {
163        self.profile
164    }
165}
166
167impl std::fmt::Display for NotValid {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        write!(
170            f,
171            "not written: the invoice does not satisfy {} ({} fatal finding(s)); \
172             see `NotValid::report` for all of them",
173            self.profile,
174            self.report.fatal().count()
175        )
176    }
177}
178
179impl std::error::Error for NotValid {}
180
181/// Validate against `profile`, stamping BT-24 from it, or say why not.
182///
183/// Shared by both syntaxes: the check is about the *model*, so doing it twice
184/// would be two places for it to differ.
185///
186/// The stamp happens **before** validation, not after. `BR-01` requires BT-24
187/// and XRechnung's `BR-DE-21` constrains its value, so a document validated
188/// carrying the caller's BT-24 and shipped carrying the profile's would have
189/// been checked as something other than what it claims to be.
190#[cfg(any(feature = "ubl", feature = "cii"))]
191fn prepare_for(
192    invoice: &en16931::Invoice,
193    profile: &'static en16931::validation::profile::Profile,
194) -> Result<en16931::Invoice, NotValid> {
195    let mut inv = invoice.clone();
196    inv.specification_id = Some(profile.specification_id.to_owned());
197    let report = profile.validate(&inv);
198    if report.is_valid() {
199        Ok(inv)
200    } else {
201        Err(NotValid {
202            profile: profile.id,
203            report: Box::new(report),
204        })
205    }
206}
207
208/// The CEN attribution notice, as [`en16931`] carries it.
209///
210/// Re-exported rather than restated, so the two cannot drift.
211pub const ATTRIBUTION: &str = en16931::ATTRIBUTION;
212
213/// Which syntax a document is written in.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215#[non_exhaustive]
216pub enum Syntax {
217    /// OASIS UBL 2.1 — `Invoice` or `CreditNote`.
218    Ubl,
219    /// UN/CEFACT Cross Industry Invoice D16B.
220    Cii,
221}
222
223/// Guess the syntax from the document element, without parsing.
224///
225/// Returns `None` rather than guessing when the root is neither — "not an
226/// e-invoice at all" and "an e-invoice in the other syntax" need different
227/// messages, and a caller that cannot tell them apart writes a bad one.
228#[must_use]
229pub fn sniff(xml: &str) -> Option<Syntax> {
230    let root = xml
231        .split('<')
232        .find(|s| !s.is_empty() && !s.starts_with('?') && !s.starts_with('!'))?;
233    let name = root
234        .split([' ', '>', '\t', '\n', '\r', '/'])
235        .next()?
236        .rsplit(':')
237        .next()?;
238    match name {
239        "Invoice" | "CreditNote" => Some(Syntax::Ubl),
240        "CrossIndustryInvoice" => Some(Syntax::Cii),
241        _ => None,
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn sniff_distinguishes_the_syntaxes() {
251        assert_eq!(
252            sniff("<?xml version=\"1.0\"?><Invoice xmlns=\"x\">"),
253            Some(Syntax::Ubl)
254        );
255        assert_eq!(sniff("<CreditNote>"), Some(Syntax::Ubl));
256        assert_eq!(sniff("<rsm:CrossIndustryInvoice>"), Some(Syntax::Cii));
257        assert_eq!(sniff("<html>"), None);
258        assert_eq!(sniff(""), None);
259    }
260
261    /// A prologue or comment before the root must not be mistaken for it.
262    #[test]
263    fn sniff_skips_the_prologue() {
264        assert_eq!(
265            sniff("<?xml?>\n<!-- a note -->\n<Invoice>"),
266            Some(Syntax::Ubl)
267        );
268    }
269
270    /// The notice travels with the crate, and is not a second copy that can rot.
271    #[test]
272    fn the_attribution_is_en16931s() {
273        assert_eq!(ATTRIBUTION, en16931::ATTRIBUTION);
274        assert!(ATTRIBUTION.contains("CEN"));
275    }
276}