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}