en16931 0.4.0

The EN 16931 semantic data model and its business rules, as Rust types. Validates the model rather than a serialised document, so findings point at BT-151 on line 3 instead of at an XPath. No XML, no PDF, no I/O.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
//! Conversion from [`billing::BillingDocument`] — behind the `billing` feature.
//!
//! # Why there is no `TryFrom`
//!
//! A `BillingDocument` has no seller (BR-06), no buyer (BR-07), no postal
//! addresses (BR-08/BR-10), no country codes (BR-09/BR-11), no electronic
//! addresses (BR-62/BR-63) and no item names — `LineItem::description` is not
//! BT-153, it is display text. A `TryFrom` would fail on every input that is not
//! pathologically pre-annotated, which makes it a trait impl whose only
//! behaviour is `Err` while inviting the reader to believe conversion is total.
//!
//! So: a builder that takes the document for its *arithmetic* and the caller for
//! everything the standard needs and a calculation engine has no business
//! knowing.
//!
//! ```no_run
//! # use en16931::billing_adapter::FromBilling;
//! # fn demo(doc: &billing::BillingDocument, seller: en16931::invoice::Party,
//! #         buyer: en16931::invoice::Party) -> Result<(), Box<dyn std::error::Error>> {
//! let invoice = FromBilling::new(doc)
//!     .specification_id("urn:cen.eu:en16931:2017")
//!     .seller(seller)
//!     .buyer(buyer)
//!     .build()?;
//! # Ok(()) }
//! ```
//!
//! # The three traps, and where they went
//!
//! Each of these was a genuine problem in `billing` 0.8 that the adapter had to
//! work around with heuristics. All three were fixed upstream, in the only place
//! they *could* be fixed — the engine knows which layer produced which position
//! and which predicate selected its base; an adapter looking at a finished
//! document never can.
//!
//! | Trap | Where it went |
//! |---|---|
//! | `tax_total` is **not** BT-110 — a levy is a BG-21 charge inside the taxable base, so mapping it to BT-110 breaks `BR-CO-14` on every levy-bearing invoice | `vat_total()` / `charge_total()` |
//! | `net_total` is neither BT-106 nor BT-109 — it is `BT-106 − BT-107`, which EN 16931 has no term for | `line_total()` / `taxable_total()` |
//! | Per-line VAT attribution was unrecoverable after assembly | `LineItem::vat`, derived from `TaxLayer::covers` |
//!
//! # What crosses the seam
//!
//! | | From | To |
//! |---|---|---|
//! | BT-1, BT-2, BT-9 | `meta` | header |
//! | BT-3, and the document *kind* | `meta.kind` / `is_credit_note()` | header — the kind is the semantics, BT-3 its rendering |
//! | BT-5 | `meta.currency` | header — refused when still `XXX` |
//! | BT-6, BT-111 | `vat_accounting_currency()` | header and BG-22, together |
//! | BT-20 | `meta.payment_terms` | header, terminator checked |
//! | BT-29, BT-46 | `meta.issuer_id` / `recipient_id` | merged into the caller's [`Party`] |
//! | BG-1 (BT-21, BT-22) | `meta.notes` | notes, with their subject codes |
//! | BG-14 | `meta.period` | header |
//! | BG-25 | `net_positions()` | lines |
//! | BG-20 | `discount_positions()` | allowances, sign flipped |
//! | BG-21 | `charge_positions()` | charges |
//! | BG-23 | `tax_breakdown()` | breakdown |
//! | BG-22 | the totals accessors | totals |
//! | `BG-X-45` | `advances()` | [`crate::extensions`] |
//!
//! **Deliberately not mapped**, because there is nothing to map them to:
//! `meta.period_label` and `meta.labels` are display text and arbitrary
//! key/value pairs, with no business term at all.
//!
//! Two rows are newer than the rest and were unmappable before `billing` 0.13.
//! BT-29 and BT-46 needed the ISO 6523 scheme to travel with the value — a bare
//! string documented as "MP-ID, GLN, BDEW code, or free-form" cannot be given a
//! `@schemeID` without guessing, and `BR-CL-10` checks that guess. BG-1 needed
//! notes to be `0..n` with a subject code, because BT-21 is what a routing
//! system reads and one uncoded string cannot carry it.
//!
//! # What the adapter still owes
//!
//! Three obligations `billing` deliberately leaves here:
//!
//! 1. **Call `verify_vat_attribution()`.** It is not part of `validate()`,
//!    because `AllocationRule` splits positions and breakdown with independent
//!    penny corrections and cannot preserve it. It is `BR-S-08`, and nothing
//!    downstream will catch a failure.
//! 2. **Reject `LineItem::vat == None` by name.** Lawful in `billing`, fatal in
//!    EN 16931 (`BR-CO-04`). Never default it.
//! 3. **Check BT-20's terminator.** See below. `billing` supplies it now; the
//!    adapter verifies it rather than assuming it.
//!
//! # The BT-20 newline, which is not a nicety
//!
//! `billing::PaymentTerms` renders EN 16931's BT-20 including Germany's Skonto
//! micro-syntax. Up to and including 0.12 it rendered it *without* a trailing
//! newline:
//!
//! ```text
//! Zahlbar in 30 Tagen.\n#SKONTO#TAGE=10#PROZENT=2.00#
//! ```
//!
//! `BR-DE-18` has two halves, and the second is easy to miss because it is
//! folded into the same `satisfies` as the first rather than being its own
//! `<assert>`:
//!
//! ```xpath
//! every $line in cac:PaymentTerms/cbc:Note[1]/tokenize(., '(\r?\n)')[starts-with(normalize-space(.), '#')]
//!   satisfies matches(normalize-space($line), $XR-SKONTO-REGEX)
//!         and matches(cac:PaymentTerms/cbc:Note[1]/tokenize(., '#.+#')[last()], '^\s*\n')
//! ```
//!
//! Everything after the **last** `#…#` must begin with a newline. Without one,
//! `tokenize(…)[last()]` is the empty string, the second `matches` is false, and
//! every German invoice carrying a Skonto is rejected — fatally.
//!
//! This adapter appended the newline for one release. `billing` 0.13 terminates
//! it upstream, which is where it belongs: the `#SKONTO#…#` syntax has no core
//! EN 16931 form, so a rendering that omits the terminator is valid nowhere.
//! What is left here is a **guard**, not a fix — idempotent, and paired with a
//! test that asserts upstream still does it.

use billing::{BillingDocument, LineItem, Sign};
use rust_decimal::Decimal;

use crate::extensions::{AdvancePayment, Extensions};
use crate::invoice::{
    Code, DocumentAllowanceCharge, DocumentTotals, Invoice, Item, LineAllowanceCharge, LineVat,
    Party, Period, PriceDetails, VatBreakdown,
};
use crate::{Date, InvoiceAmount, InvoiceLine, Percentage, Quantity, UnitPriceAmount};

// ── Errors ────────────────────────────────────────────────────────────────────

/// Conversion failed.
///
/// **Not** a validation finding. These say "the document you handed me cannot be
/// expressed as an EN 16931 invoice at all"; a [`crate::ValidationReport`] says
/// "this invoice does not satisfy BR-CO-14". Conflating them makes both harder
/// to act on.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConversionError {
    /// An amount carries more than two decimals.
    ///
    /// Rounding here is the mistake `AmountScale` exists to prevent: it breaks
    /// `BR-CO-10` and `BR-CO-15`, which are exact equalities over sums. Fix it
    /// at the source with `.amount_scale(AmountScale::EN16931)` on the builder.
    #[error(
        "{what} = {value} needs more than two decimals; rebuild the document with \
         `.amount_scale(AmountScale::EN16931)` rather than rounding here"
    )]
    PrecisionLoss {
        /// Which field.
        what: String,
        /// Its value.
        value: String,
    },

    /// A position carries no VAT attribution.
    ///
    /// Lawful in `billing`; fatal in EN 16931 under `BR-CO-04`. Set it with
    /// `LineItemBuilder::vat`, or ensure a `TaxLayer` covers the position.
    #[error("position {index} ({description:?}) has no VAT attribution; BR-CO-04 requires BT-151")]
    NoVatAttribution {
        /// Which position.
        index: usize,
        /// Its description, for finding it.
        description: String,
    },

    /// A quantity unit could not be resolved to a UN/ECE Rec 20 code.
    ///
    /// Guessing produces an invoice that validates and describes the wrong
    /// thing — and unlike a wrong amount, nobody notices.
    #[error(
        "unit label {label:?} on position {index} has no BT-130 code; set `Quantity::code` \
         or extend the `UnitResolver`"
    )]
    UnresolvedUnit {
        /// Which position.
        index: usize,
        /// The unresolved label.
        label: String,
    },

    /// A date string is not an ISO 8601 calendar date.
    #[error("{field} = {value:?} is not an ISO 8601 calendar date")]
    UnparsableDate {
        /// Which field.
        field: &'static str,
        /// Its value.
        value: String,
    },

    /// The document has no currency, or still carries ISO 4217 `XXX`.
    #[error(
        "the document's currency is {0}; XXX means \"no currency involved\" and a document \
         still carrying it was never configured"
    )]
    NoCurrency(String),

    /// `billing` reported an arithmetic problem.
    #[error("billing: {0}")]
    Billing(String),
}

// ── Unit resolution ───────────────────────────────────────────────────────────

/// Maps a display unit label to a UN/ECE Rec 20 code, for documents that predate
/// `Quantity::code` or whose caller only set the label.
///
/// `billing::Quantity::unit` is display text and is load-bearing for
/// `PerUnitLevy` base matching; BT-130 is a code list. Different namespaces, and
/// the mapping is not mechanical — `"Stk"`, `"Stück"`, `"pcs"` and `"pieces"`
/// are all `H87`.
#[derive(Debug, Clone, Default)]
pub struct UnitResolver {
    extra: Vec<(String, String)>,
}

/// The mappings that are unambiguous enough to build in.
///
/// Deliberately short. A resolver that guessed widely would produce invoices
/// that validate and describe the wrong thing.
const BUILT_IN: &[(&str, &str)] = &[
    ("kWh", "KWH"),
    ("MWh", "MWH"),
    ("Wh", "WHR"),
    ("kW", "KWT"),
    ("", "MTQ"),
    ("m3", "MTQ"),
    ("", "MTK"),
    ("m", "MTR"),
    ("km", "KMT"),
    ("kg", "KGM"),
    ("g", "GRM"),
    ("t", "TNE"),
    ("l", "LTR"),
    ("h", "HUR"),
    ("d", "DAY"),
    ("Monat", "MON"),
    ("month", "MON"),
    ("Stk", "H87"),
    ("Stück", "H87"),
    ("pcs", "H87"),
    ("piece", "H87"),
    ("Stunde", "HUR"),
    ("%", "P1"),
    ("Pauschale", "C62"),
    ("one", "C62"),
];

impl UnitResolver {
    /// A resolver with only the built-in table.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Add or override a mapping.
    #[must_use]
    pub fn with(mut self, label: impl Into<String>, code: impl Into<String>) -> Self {
        self.extra.push((label.into(), code.into()));
        self
    }

    /// Resolve a label, caller-supplied mappings first.
    #[must_use]
    pub fn resolve(&self, label: &str) -> Option<&str> {
        self.extra
            .iter()
            .find(|(l, _)| l == label)
            .map(|(_, c)| c.as_str())
            .or_else(|| BUILT_IN.iter().find(|(l, _)| *l == label).map(|(_, c)| *c))
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Narrow a `billing` amount to two decimals **without rounding**.
fn amount(a: billing::Amount<5>, what: &str) -> Result<InvoiceAmount, ConversionError> {
    a.exact_to::<2>()
        .map_err(|_| ConversionError::PrecisionLoss {
            what: what.to_owned(),
            value: a.to_string(),
        })
        .map(|v: billing::Amount<2>| InvoiceAmount::from_minor_units(v.to_raw()))
}

/// `billing` stores VAT rates as fractions (`0.19`) because that is what you
/// multiply by; EN 16931 stores what you print (`19`). Convert once, here.
fn rate(fraction: Decimal) -> Percentage {
    Percentage::from_fraction(fraction).unwrap_or_else(|| Percentage::new(fraction))
}

fn date(s: Option<&str>, field: &'static str) -> Result<Option<Date>, ConversionError> {
    s.map(|v| {
        Date::parse(v).map_err(|_| ConversionError::UnparsableDate {
            field,
            value: v.to_owned(),
        })
    })
    .transpose()
}

fn period(
    p: Option<&billing::Period>,
    what: &'static str,
) -> Result<Option<Period>, ConversionError> {
    p.map(|p| {
        Ok(Period {
            start: date(Some(&p.from), what)?,
            end: date(Some(&p.to), what)?,
        })
    })
    .transpose()
}

/// BT-20, checked for the terminator `BR-DE-18` requires.
///
/// # This used to add the newline, and no longer has to
///
/// `billing` ≤ 0.12 rendered the field and stopped at the closing `#`, so every
/// XRechnung carrying a Skonto failed `BR-DE-18` — see the module documentation
/// for the XPath and why the second half of that rule is so easy to read past.
/// The adapter appended the newline itself.
///
/// 0.13 terminates it upstream, which is the right place: the `#SKONTO#…#`
/// micro-syntax **is** the German CIUS convention and has no core EN 16931 form,
/// so a rendering without the terminator is valid nowhere at all.
///
/// The guard stays, because the alternative is trusting a convention across a
/// crate boundary with nothing checking it — and this is the one field where
/// getting it wrong is invisible until a counterparty's validator says no.
/// `billing_renders_bt_20_with_the_terminator_br_de_18_needs` asserts upstream
/// still does it, so a regression there fails our build rather than a customer's
/// invoice.
fn payment_terms(t: Option<&billing::terms::PaymentTerms>) -> Option<String> {
    let t = t.filter(|t| !t.is_empty())?;
    let rendered = t.to_string();
    // Idempotent: `ends_with` is what makes this a guard rather than a second
    // opinion about how BT-20 should be spelled.
    if t.discounts().is_empty() || rendered.ends_with('\n') {
        return Some(rendered);
    }
    Some(format!("{rendered}\n"))
}

/// Add the document's BT-29 / BT-46 to a party, without duplicating one the
/// caller already supplied.
///
/// # Why merge rather than choose
///
/// Both are legitimate and they are not the same fact. The caller's `Party`
/// carries master data — the identifiers a customer record holds — and the
/// document's carries the party code the *billing run* was keyed on: an MP-ID in
/// the energy market, a GLN in retail. Overwriting either way loses one.
///
/// EN 16931 makes BT-29 and BT-46 repeatable precisely because a party has more
/// than one identity, so carrying both is what the model is for. The scheme is
/// compared alongside the value: the same digits under `0088` and under `0293`
/// are two different registries saying two different things.
fn with_identifier(mut party: Party, id: Option<&billing::PartyIdentifier>) -> Party {
    let Some(id) = id.filter(|i| !i.value.trim().is_empty()) else {
        return party;
    };
    let scheme = id.scheme.as_deref().filter(|s| !s.trim().is_empty());
    let already = party
        .identifiers
        .iter()
        .any(|existing| existing.content() == id.value && existing.scheme() == scheme);
    if !already {
        party.identifiers.push(match scheme {
            Some(s) => crate::Identifier::schemed(&id.value, s),
            None => crate::Identifier::new(&id.value),
        });
    }
    party
}

fn line_vat(v: Option<&billing::vat::LineVat>) -> Option<LineVat> {
    v.map(|v| LineVat {
        category: Code::new(v.category.code()),
        // Category `O` states no rate at all — BR-O-05/06/07 say the element
        // "shall not contain" it, where every other zero-tax category says it
        // "shall be 0". `billing` stores a plain `Decimal`, so an `O` position
        // holds `0`; `states_rate` is what tells us to drop it.
        rate: crate::VatCategory::from_code(v.category.code())
            .is_none_or(crate::VatCategory::states_rate)
            .then(|| rate(v.rate)),
    })
}

fn allowance_charge(item: &LineItem) -> (Option<InvoiceAmount>, Option<Percentage>, Option<Code>) {
    match &item.allowance_charge {
        Some(ac) => (
            ac.base_amount
                .and_then(|b| b.exact_to::<2>().ok())
                .map(|v: billing::Amount<2>| InvoiceAmount::from_minor_units(v.to_raw())),
            ac.percentage.map(Percentage::new),
            ac.reason_code.as_deref().map(Code::new),
        ),
        None => (None, None, None),
    }
}

// ── The builder ───────────────────────────────────────────────────────────────

/// Builds an [`Invoice`] from a [`BillingDocument`] plus the terms the standard
/// requires and a calculation engine does not hold.
pub struct FromBilling<'a> {
    doc: &'a BillingDocument,
    specification_id: Option<String>,
    seller: Party,
    buyer: Party,
    units: UnitResolver,
    verify_attribution: bool,
}

impl<'a> FromBilling<'a> {
    /// Start from a document. Borrowed, not consumed — callers routinely keep the
    /// billing document for archival.
    #[must_use]
    pub fn new(doc: &'a BillingDocument) -> Self {
        Self {
            doc,
            specification_id: None,
            seller: Party::default(),
            buyer: Party::default(),
            units: UnitResolver::new(),
            verify_attribution: true,
        }
    }

    /// BT-24 — the profile this invoice declares.
    #[must_use]
    pub fn specification_id(mut self, id: impl Into<String>) -> Self {
        self.specification_id = Some(id.into());
        self
    }

    /// BG-4 — the seller.
    #[must_use]
    pub fn seller(mut self, seller: Party) -> Self {
        self.seller = seller;
        self
    }

    /// BG-7 — the buyer.
    #[must_use]
    pub fn buyer(mut self, buyer: Party) -> Self {
        self.buyer = buyer;
        self
    }

    /// Supply unit-code mappings for documents whose quantities carry only a
    /// display label.
    #[must_use]
    pub fn units(mut self, units: UnitResolver) -> Self {
        self.units = units;
        self
    }

    /// Skip `verify_vat_attribution`.
    ///
    /// Only correct for a document produced by `AllocationRule`, which splits
    /// positions and breakdown with independent penny corrections and therefore
    /// cannot preserve BR-S-08 exactly. For anything else, leaving this on is
    /// what catches a mis-tagged tax layer before the counterparty does.
    #[must_use]
    pub fn allow_unverified_attribution(mut self) -> Self {
        self.verify_attribution = false;
        self
    }

    /// Convert.
    ///
    /// # Errors
    /// [`ConversionError`] when the document cannot be expressed as an
    /// EN 16931 invoice at all. Whether the *result* is valid is a separate
    /// question — run [`crate::validate`] on it.
    pub fn build(self) -> Result<Invoice, ConversionError> {
        let doc = self.doc;

        // Order matters, and it is cheapest-and-most-unambiguous first. A
        // document that was never given a currency should be told so, not told
        // about its VAT attribution: `verify_vat_attribution` is a deep
        // arithmetic check and its message, while correct, is the least
        // actionable thing a caller with a configuration problem can be handed.
        let currency = doc.currency();
        if currency.is_unset() {
            return Err(ConversionError::NoCurrency(currency.code().to_owned()));
        }

        if self.verify_attribution {
            doc.verify_vat_attribution()
                .map_err(|e| ConversionError::Billing(e.to_string()))?;
        }

        // Totals first: `self` is consumed by the party moves below, and the
        // borrow checker is right that reading it afterwards is a mistake
        // waiting to happen.
        let totals = self.totals()?;

        // Struct-update rather than default-then-assign: `Invoice` is
        // `#[non_exhaustive]`, but this crate is inside its own boundary, so the
        // functional-update form is available here and states every mapped term
        // in one place.
        let mut inv = Invoice {
            // **Not derived from BT-3.** `billing::DocumentKind::is_credit_note`
            // is the semantic statement; the code is its rendering. The adapter
            // used to leave this at the default, so a `billing` credit note
            // became an EN 16931 *invoice* carrying BT-3 = 381 — which fails
            // `BR-CL-01` (381 is a credit-note code and not an invoice one),
            // wrongly runs `BR-CO-25` (which must not fire on a credit note),
            // and, worst of all, serialises as `<ubl:Invoice>` because
            // `en16931-formats` picks the root element from this field. A credit
            // note on the wire wearing an invoice's document element.
            kind: if doc.meta.kind.is_credit_note() {
                crate::DocumentKind::CreditNote
            } else {
                crate::DocumentKind::Invoice
            },
            specification_id: self.specification_id.clone(),
            number: Some(doc.meta.invoice_number.clone()).filter(|s| !s.is_empty()),
            issue_date: date(doc.meta.issue_date.as_deref(), "issue_date")?,
            due_date: date(doc.meta.due_date.as_deref(), "due_date")?,
            type_code: Some(Code::new(doc.meta.kind.code().to_string())),
            currency: Some(Code::new(currency.code())),
            // BT-20. One of the two ways to satisfy `BR-CO-25`, and the only
            // machine-readable home a Skonto has before EN 16931-1:2026 gives it
            // fields of its own.
            payment_terms: payment_terms(doc.meta.payment_terms.as_ref()),
            // BT-6. `billing` will not construct a `VatAccountingCurrency` with
            // `XXX`, so this cannot introduce the currency hazard BT-5 is
            // guarded against above.
            vat_accounting_currency: doc
                .vat_accounting_currency()
                .map(|a| Code::new(a.currency().code())),
            invoicing_period: period(doc.meta.period.as_ref(), "period")?,
            // BG-1, both terms. `billing` 0.13 models notes as `0..n` with an
            // optional UNCL 4451 subject code, which is what the standard has —
            // before that it was one uncoded string, and BT-21 could not cross
            // at all. BT-21 is not decoration: a reverse-charge sentence and a
            // payment instruction are both free text, and only the code says
            // which is which to a system routing on it.
            notes: doc
                .meta
                .notes
                .iter()
                .map(|n| crate::invoice::InvoiceNote {
                    subject_code: n.subject_code.as_deref().map(Code::new),
                    note: Some(n.text.clone()),
                })
                .collect(),
            // BG-4 / BG-7 are the caller's — a tariff engine has no business
            // knowing a postal address — but BT-29 and BT-46 are the document's,
            // and `billing` 0.13 carries them with the ISO 6523 scheme that
            // makes them mappable at all. Merged rather than overwritten: the
            // caller's master data and the document's party code are both real,
            // and neither is a substitute for the other.
            seller: with_identifier(self.seller.clone(), doc.meta.issuer_id.as_ref()),
            buyer: with_identifier(self.buyer.clone(), doc.meta.recipient_id.as_ref()),
            ..Default::default()
        };

        // BG-25 — the net positions become invoice lines.
        for (i, item) in doc.net_positions().iter().enumerate() {
            inv.lines.push(self.line(i, item)?);
        }

        // BG-20 — discounts. Allowances are stated **positive**; `billing`
        // carries them as credits.
        for (i, item) in doc.discount_positions().iter().enumerate() {
            let (base, pct, reason_code) = allowance_charge(item);
            inv.allowances.push(DocumentAllowanceCharge {
                amount: amount(
                    item.net_amount
                        .checked_neg()
                        .map_err(|e| ConversionError::Billing(e.to_string()))?,
                    &format!("discount[{i}] BT-92"),
                )?,
                base_amount: base,
                percentage: pct,
                vat: line_vat(item.vat.as_ref()).ok_or_else(|| {
                    ConversionError::NoVatAttribution {
                        index: i,
                        description: item.description.clone(),
                    }
                })?,
                // BT-97 falls back to the position's description, which is what
                // a human reads on the rendered invoice anyway. BR-33 accepts
                // either the text or the code.
                reason: Some(item.description.clone()),
                reason_code,
            });
        }

        // BG-21 — the tax positions that are NOT VAT. A per-unit levy or a
        // commission is part of the taxable base, so EN 16931 calls it a
        // document level charge, not tax.
        for (i, item) in doc.charge_positions().enumerate() {
            let (base, pct, reason_code) = allowance_charge(item);
            inv.charges.push(DocumentAllowanceCharge {
                amount: amount(item.net_amount, &format!("charge[{i}] BT-99"))?,
                base_amount: base,
                percentage: pct,
                vat: line_vat(item.vat.as_ref()).ok_or_else(|| {
                    ConversionError::NoVatAttribution {
                        index: i,
                        description: item.description.clone(),
                    }
                })?,
                reason: Some(item.description.clone()),
                reason_code,
            });
        }

        // BG-23.
        for (i, e) in doc.tax_breakdown().iter().enumerate() {
            let category = Code::new(e.category.code());
            let states_rate = crate::VatCategory::from_code(e.category.code()).is_none_or(|_| true); // BT-119 is not BT-152; BR-48 governs it.
            inv.vat_breakdown.push(VatBreakdown {
                taxable_amount: amount(e.taxable_base, &format!("BG-23[{i}] BT-116"))?,
                tax_amount: amount(e.tax_amount, &format!("BG-23[{i}] BT-117"))?,
                category,
                rate: states_rate.then(|| rate(e.rate)),
                exemption_reason: e.exemption_reason.clone(),
                exemption_reason_code: e.exemption_reason_code.as_deref().map(Code::new),
            });
        }

        // The per-advance tax has no core business term. Carrying it into
        // `Extensions` rather than dropping it is the difference between a
        // lawful final invoice and a §14c Abs. 1 UStG liability — see
        // `crate::extensions`. `EN-EXT-01` then warns if the target profile
        // cannot represent it.
        inv.extensions = self.advances()?;

        inv.totals = totals;
        Ok(inv)
    }

    /// One BG-25 line.
    fn line(&self, i: usize, item: &LineItem) -> Result<InvoiceLine, ConversionError> {
        let quantity = item.quantity.as_ref();

        // BT-130. `Quantity::code` first; the resolver only as a fallback.
        let unit_code = match quantity {
            Some(q) => match q.code.as_deref() {
                Some(c) => c.to_owned(),
                None => self
                    .units
                    .resolve(&q.unit)
                    .ok_or_else(|| ConversionError::UnresolvedUnit {
                        index: i,
                        label: q.unit.clone(),
                    })?
                    .to_owned(),
            },
            // A flat charge with no quantity: `1` of `C62` ("one"), so BR-22 and
            // BR-23 hold and `1 × amount` reproduces the amount exactly.
            None => "C62".to_owned(),
        };

        let net = amount(item.net_amount, &format!("line[{i}] BT-131"))?;

        // The sign convention flips here. `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 shows it:
        // 25 cases invoiced, −10 returned, one ordinary invoice.
        let (bt_129, bt_146) = match (quantity, item.unit_price.as_ref()) {
            (Some(q), Some(p)) => {
                let mut qty = q.value;
                let mut price = p.value;
                if item.sign == Sign::Credit {
                    qty = -qty;
                }
                // A negative unit price — lawful in `billing` for spot markets —
                // violates BR-27. Flip it onto the quantity instead of dropping
                // the line: `1000 kWh × −0.005` becomes `−1000 kWh × 0.005`.
                if price < Decimal::ZERO {
                    price = -price;
                    qty = -qty;
                }
                (Quantity::new(qty), UnitPriceAmount::new(price))
            }
            // Fixed amount: quantity 1 (or −1 for a credit) at the full amount.
            _ => {
                let one = if item.sign == Sign::Credit {
                    Decimal::NEGATIVE_ONE
                } else {
                    Decimal::ONE
                };
                let abs = net.into_decimal().abs();
                (Quantity::new(one), UnitPriceAmount::new(abs))
            }
        };

        Ok(InvoiceLine {
            id: (i + 1).to_string(),
            note: None,
            // BT-132 / BT-133 have no `billing` analogue: it is a financial
            // document and these are the buyer's procurement and bookkeeping
            // handles. A caller who has them sets them after conversion.
            order_line_reference: None,
            accounting_reference: None,
            object_identifier: None,
            quantity: bt_129,
            unit_code: Code::new(unit_code),
            net_amount: net,
            period: period(item.period.as_ref(), "line period")?,
            allowances: self.line_allowances(item, billing::AllowanceKind::Allowance)?,
            charges: self.line_allowances(item, billing::AllowanceKind::Charge)?,
            price: PriceDetails {
                net_price: bt_146,
                price_discount: item
                    .unit_price
                    .as_ref()
                    .and_then(|p| p.price_discount)
                    .map(UnitPriceAmount::new),
                gross_price: item
                    .unit_price
                    .as_ref()
                    .and_then(|p| p.gross_price)
                    .map(UnitPriceAmount::new),
                base_quantity: item
                    .unit_price
                    .as_ref()
                    .and_then(|p| p.base_quantity)
                    .map(Quantity::new),
                base_quantity_code: item
                    .unit_price
                    .as_ref()
                    .and_then(|p| p.base_quantity_code.clone())
                    .map(Code::new),
            },
            vat: line_vat(item.vat.as_ref()).ok_or_else(|| ConversionError::NoVatAttribution {
                index: i,
                description: item.description.clone(),
            })?,
            // BT-153. `description` is the only thing `billing` has, and an
            // unlabelled position is already rejected there, so this is always
            // non-empty — but BR-25 is checked by the engine regardless.
            item: Item {
                name: Some(item.description.clone()),
                ..Default::default()
            },
        })
    }

    /// BG-27 or BG-28 for one line.
    fn line_allowances(
        &self,
        item: &LineItem,
        kind: billing::AllowanceKind,
    ) -> Result<Vec<LineAllowanceCharge>, ConversionError> {
        item.line_allowances
            .iter()
            .filter(|a| a.kind == kind)
            .map(|a| {
                Ok(LineAllowanceCharge {
                    amount: amount(a.amount, "line allowance/charge")?,
                    base_amount: a
                        .base_amount
                        .map(|b| amount(b, "line allowance/charge base"))
                        .transpose()?,
                    percentage: a.percentage.map(Percentage::new),
                    reason: a.reason.clone(),
                    reason_code: a.reason_code.as_deref().map(Code::new),
                })
            })
            .collect()
    }

    /// ZUGFeRD EXTENDED `BG-X-45`, from `billing`'s itemised advances.
    ///
    /// Empty for an ordinary invoice, and empty for a *residual* invoice, which
    /// bills only the remainder and deliberately lists no advances. Non-empty
    /// makes this a **final invoice**.
    fn advances(&self) -> Result<Extensions, ConversionError> {
        let billing_err = |e: billing::BillingError| ConversionError::Billing(e.to_string());
        let mut out = Vec::new();
        for a in self.doc.advances() {
            out.push(AdvancePayment {
                gross: amount(a.checked_gross().map_err(billing_err)?, "BT-X-291")?,
                received_on: date(a.received_on(), "advance received_on")?,
                tax: a
                    .tax()
                    .iter()
                    .map(|e| {
                        Ok(VatBreakdown {
                            taxable_amount: amount(e.taxable_base, "BG-X-46 base")?,
                            tax_amount: amount(e.tax_amount, "BG-X-46 tax")?,
                            category: Code::new(e.category.code()),
                            rate: Some(rate(e.rate)),
                            exemption_reason: e.exemption_reason.clone(),
                            exemption_reason_code: e
                                .exemption_reason_code
                                .as_deref()
                                .map(Code::new),
                        })
                    })
                    .collect::<Result<Vec<_>, ConversionError>>()?,
                reference: a.reference().map(crate::DocumentReference::new),
                reference_date: date(a.reference_date(), "advance reference_date")?,
            });
        }
        Ok(Extensions {
            // `billing` models neither sub-lines nor third-party settlement.
            sub_invoice_lines: Vec::new(),
            third_party_payments: Vec::new(),
            advance_payments: out,
        })
    }

    /// BG-22, taking each term from the accessor that actually means it.
    fn totals(&self) -> Result<DocumentTotals, ConversionError> {
        let doc = self.doc;
        let billing_err = |e: billing::BillingError| ConversionError::Billing(e.to_string());

        let line_total = amount(doc.line_total().map_err(billing_err)?, "BT-106")?;
        let allowances = doc.discount_total();
        let charges = doc.charge_total().map_err(billing_err)?;
        let vat = doc.vat_total().map_err(billing_err)?;

        Ok(DocumentTotals {
            line_total,
            // Absent is not zero: BT-107 may be omitted only when there are no
            // allowances at all, and BR-CO-13 branches on its presence.
            allowance_total: (!doc.discount_positions().is_empty())
                .then(|| amount(allowances.checked_neg().map_err(billing_err)?, "BT-107"))
                .transpose()?,
            charge_total: (doc.charge_positions().next().is_some())
                .then(|| amount(charges, "BT-108"))
                .transpose()?,
            taxable_total: amount(doc.taxable_total().map_err(billing_err)?, "BT-109")?,
            vat_total: (!doc.tax_breakdown().is_empty())
                .then(|| amount(vat, "BT-110"))
                .transpose()?,
            // BT-111. `BR-53` makes it mandatory whenever BT-6 is present, and
            // the two now come from one place — an adapter that mapped the
            // currency and left the amount `None` would manufacture a `BR-53`
            // finding out of a document that carries both.
            vat_total_accounting: doc
                .vat_accounting_currency()
                .map(|a| amount(a.vat_total(), "BT-111"))
                .transpose()?,
            gross_total: amount(doc.gross_total(), "BT-112")?,
            paid: (!doc.prepaid().is_zero())
                .then(|| amount(doc.prepaid(), "BT-113"))
                .transpose()?,
            rounding: (!doc.rounding().is_zero())
                .then(|| amount(doc.rounding(), "BT-114"))
                .transpose()?,
            due: amount(doc.amount_due().map_err(billing_err)?, "BT-115")?,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_resolver_prefers_caller_mappings_and_refuses_to_guess() {
        let r = UnitResolver::new().with("kWh", "XXX").with("Kiste", "BX");
        assert_eq!(r.resolve("kWh"), Some("XXX"), "caller overrides built-in");
        assert_eq!(r.resolve("Kiste"), Some("BX"));
        assert_eq!(r.resolve("Stk"), Some("H87"), "built-in still reachable");
        assert_eq!(r.resolve("Furlong"), None, "never guesses");
    }

    #[test]
    fn every_built_in_unit_code_is_real() {
        for (label, code) in BUILT_IN {
            assert!(
                crate::codes::contains(crate::codes::generated::UNIT_CODES, code),
                "{label} maps to {code}, which is not in BR-CL-23's list"
            );
        }
    }

    #[test]
    fn rates_convert_from_fraction_to_per_cent() {
        // The most common transcription bug when bridging the two crates.
        assert_eq!(
            rate(rust_decimal::dec!(0.19)),
            Percentage::new(rust_decimal::dec!(19))
        );
        assert_eq!(
            rate(rust_decimal::dec!(0.075)),
            Percentage::new(rust_decimal::dec!(7.5))
        );
        assert_eq!(rate(Decimal::ZERO), Percentage::ZERO);
    }
}