en16931 0.2.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
//! Profiles end to end — the CIUS restriction model, and the typed proof.

use en16931::DocumentReference;
use en16931::invoice::*;
use en16931::profiles::{self, En16931, PeppolBis3, XRechnung};
use en16931::validation::profile::{ProfileMarker, Validated};
use en16931::*;
use rust_decimal::dec;

fn amount(s: &str) -> InvoiceAmount {
    InvoiceAmount::parse(s).unwrap()
}

fn pct(v: i64) -> Percentage {
    Percentage::new(rust_decimal::Decimal::from(v))
}

/// A core-valid invoice, with nothing a CIUS additionally demands.
fn core_valid() -> Invoice {
    let party = |name: &str, country: &str| Party {
        name: Some(name.to_owned()),
        address: PostalAddress {
            country: Some(Code::new(country)),
            ..Default::default()
        },
        electronic_address: Some(Identifier::schemed(name, "0088")),
        vat_identifier: Some(format!("{country}123456789")), // BR-CO-26 / BR-CO-09
        ..Default::default()
    };
    let line = InvoiceLine {
        id: "1".to_owned(),
        note: None,
        order_line_reference: None,
        accounting_reference: None,
        object_identifier: None,
        quantity: Quantity::new(dec!(1)),
        unit_code: Code::new("C62"),
        net_amount: amount("100.00"),
        period: None,
        allowances: vec![],
        charges: vec![],
        price: PriceDetails {
            net_price: UnitPriceAmount::new(dec!(100)),
            price_discount: None,
            gross_price: None,
            base_quantity: None,
            base_quantity_code: None,
        },
        vat: LineVat {
            category: Code::new("S"),
            rate: Some(pct(19)),
        },
        item: Item {
            name: Some("Widget".to_owned()),
            ..Default::default()
        },
    };
    Invoice::builder(
        profiles::EN16931.specification_id,
        "INV-1",
        Date::parse("2026-06-30").unwrap(),
        Code::new("380"),
        Code::new("EUR"),
    )
    .seller(party("Seller GmbH", "DE"))
    .buyer(party("Buyer BV", "NL"))
    .due_date(Date::parse("2026-07-30").unwrap())
    .line(line)
    .vat_breakdown(VatBreakdown {
        taxable_amount: amount("100.00"),
        tax_amount: amount("19.00"),
        category: Code::new("S"),
        rate: Some(pct(19)),
        exemption_reason: None,
        exemption_reason_code: None,
    })
    .totals(DocumentTotals {
        line_total: amount("100.00"),
        allowance_total: None,
        charge_total: None,
        taxable_total: amount("100.00"),
        vat_total: Some(amount("19.00")),
        vat_total_accounting: None,
        gross_total: amount("119.00"),
        paid: None,
        rounding: None,
        due: amount("119.00"),
    })
    .build()
}

/// Fill in everything XRechnung additionally requires.
fn xrechnung_valid() -> Invoice {
    let mut inv = core_valid();
    inv.specification_id = Some(profiles::XRECHNUNG.specification_id.to_owned());
    inv.buyer_reference = Some("04011000-12345-34".to_owned()); // BR-DE-15
    inv.seller.address.city = Some("Berlin".to_owned()); // BR-DE-3
    inv.seller.address.post_code = Some("10115".to_owned()); // BR-DE-4
    inv.delivery = Some(Delivery {
        date: Some(Date::parse("2026-06-30").unwrap()), // BR-DE-TMP-32
        ..Default::default()
    });
    // Merged from Peppol: `R001` requires BT-23, `R007` fixes its shape.
    inv.business_process = Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0".to_owned());
    inv.seller.contact = Contact {
        name: Some("Frau Muster".to_owned()),         // BR-DE-5
        phone: Some("+49 30 123456".to_owned()),      // BR-DE-6
        email: Some("rechnung@seller.de".to_owned()), // BR-DE-7
    };
    inv.buyer.address.city = Some("Amsterdam".to_owned()); // BR-DE-8
    inv.buyer.address.post_code = Some("1011".to_owned()); // BR-DE-9
    inv.payment = Some(PaymentInstructions {
        means_code: Some(Code::new("58")), // SEPA credit transfer
        // BG-17, which BR-DE-23-a requires when BT-81 is 30 or 58. The IBAN is
        // a real one, so BR-DE-19's mod-97 check passes.
        means: Some(PaymentMeans::CreditTransfer(vec![CreditTransfer {
            account_identifier: Some("DE89370400440532013000".to_owned()),
            ..Default::default()
        }])),
        ..Default::default()
    }); // BR-DE-1
    inv
}

/// A CIUS *restricts*. So an invoice that satisfies it satisfies the core model,
/// but not the reverse — which is the entire content of §4.4.4.
#[test]
fn a_cius_restricts_and_the_direction_matters() {
    let core = core_valid();
    assert!(
        profiles::EN16931.validate(&core).is_valid(),
        "{}",
        profiles::EN16931.validate(&core)
    );

    // The same document under XRechnung: every added restriction fires.
    let report = profiles::XRECHNUNG.validate(&core);
    assert!(!report.is_valid());
    for id in [
        "BR-DE-1", "BR-DE-3", "BR-DE-4", "BR-DE-5", "BR-DE-6", "BR-DE-7", "BR-DE-8", "BR-DE-9",
        "BR-DE-15", "BR-DE-21",
    ] {
        assert!(report.has(id), "{id} did not fire:\n{report}");
    }

    // Fill them in, and it is valid under both.
    let xr = xrechnung_valid();
    assert!(
        profiles::XRECHNUNG.validate(&xr).is_valid(),
        "{}",
        profiles::XRECHNUNG.validate(&xr)
    );
    assert!(profiles::EN16931.validate(&xr).is_valid());
}

/// Restrictions carry the profile's **real** rule ids, so a finding is
/// lookup-able in KoSIT's index. That is the whole reason they are not collapsed
/// into one generic "profile restriction" rule.
#[test]
fn findings_carry_the_real_br_de_ids_and_business_term_paths() {
    let report = profiles::XRECHNUNG.validate(&core_valid());
    let f = report
        .fatal()
        .find(|f| f.rule == "BR-DE-3")
        .expect("BR-DE-3");
    assert_eq!(f.path.to_string(), "BG-4/BT-37");
    assert!(f.message.contains("Seller city"), "{}", f.message);
}

/// The BT-119 case: CEN's `BR-48` exempts category `O`, XRechnung's `BR-DE-14`
/// does not. Suppressing BT-119 for `O` is the natural mistake and it fails
/// KoSIT.
#[test]
fn br_de_14_requires_bt_119_where_br_48_exempts_it() {
    let mut inv = xrechnung_valid();
    inv.lines[0].vat = LineVat {
        category: Code::new("O"),
        rate: None, // BR-O-05: the LINE rate must be absent
    };
    inv.vat_breakdown = vec![VatBreakdown {
        taxable_amount: amount("100.00"),
        tax_amount: amount("0.00"),
        category: Code::new("O"),
        rate: None, // BR-48 permits this; BR-DE-14 does not
        exemption_reason: Some("Not subject to VAT".to_owned()),
        exemption_reason_code: None,
    }];
    inv.totals.vat_total = Some(amount("0.00"));
    inv.totals.gross_total = amount("100.00");
    inv.totals.due = amount("100.00");

    // Core: fine. BR-48's own exception covers it.
    assert!(
        !profiles::EN16931.validate(&inv).has("BR-48"),
        "BR-48 exempts category O"
    );
    // XRechnung: not fine.
    assert!(
        profiles::XRECHNUNG.validate(&inv).has("BR-DE-14"),
        "BR-DE-14 has no category exception"
    );
}

/// XRechnung and Peppol are **siblings**, not points on a scale: each permits a
/// BT-3 the other forbids.
#[test]
fn xrechnung_and_peppol_genuinely_disagree() {
    let mut inv = xrechnung_valid();
    inv.type_code = Some(Code::new("389")); // self-billed invoice
    // `P0100` is conditional on the business process being Peppol billing `01`
    // — `$profile != '01' or …` in the artefact — so BT-23 has to say so.
    inv.business_process = Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0".to_owned());

    // XRechnung's BR-DE-17 allows 389.
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-17"));
    // Peppol's P0100 does not — self-billing is a separate Peppol profile.
    assert!(
        profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-P0100")
    );

    // And the other way: 386 (prepayment invoice) is Peppol-legal, not XRechnung-legal.
    inv.type_code = Some(Code::new("386"));
    assert!(profiles::XRECHNUNG.validate(&inv).has("BR-DE-17"));
    assert!(
        !profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-P0100")
    );
}

/// §7.6 — a document declares its own profile, so the rule set can be selected
/// from the document rather than guessed by the caller.
#[test]
fn a_document_selects_its_own_rule_set() {
    let inv = xrechnung_valid();
    let declared = inv.specification_id.as_deref().unwrap();
    let profile = profiles::for_specification_id(declared).expect("known profile");
    assert_eq!(profile.id, "XRechnung 3.0");
    assert!(profile.validate(&inv).is_valid());
}

/// §7.4 — the typed proof, and §4.4.4's free widening.
#[test]
fn a_proof_survives_the_call_boundary_and_widens_for_free() {
    // A serialiser can demand this and be unable to receive anything else.
    fn serialise_xrechnung(v: &Validated<XRechnung>) -> String {
        v.invoice().number.clone().unwrap_or_default()
    }
    fn accepts_core(_: Validated<En16931>) {}

    let proof: Validated<XRechnung> = Validated::new(xrechnung_valid())
        .map_err(|b| b.1.to_string())
        .unwrap();
    assert_eq!(serialise_xrechnung(&proof), "INV-1");

    // §4.4.4: CIUS-valid implies core-valid, so this is infallible and free.
    accepts_core(proof.widen());

    // The failure branch hands the invoice back, so a caller can fix and retry.
    let rejected = Validated::<XRechnung>::new(core_valid()).unwrap_err();
    let (returned, report) = *rejected;
    assert_eq!(returned.number.as_deref(), Some("INV-1"));
    assert!(report.has("BR-DE-15"));
}

/// §4.4.2's structural criterion, told apart from the profiles that break it.
///
/// A conformant CIUS only *restricts*, so anything it accepts is also
/// core-valid. Suppressing a core rule breaks that, and two shipped profiles do
/// — deliberately, because they are not CIUSes:
///
/// * `XRECHNUNG_CVD` widens UNTDID 7143 by one value.
/// * `XRECHNUNG_EXTENSION` is an **Extension**: §4.3's other mechanism.
///
/// The previous version of this test asserted `is_conformant_cius()` for every
/// profile, and the method returned a constant `true`. It passed, proved
/// nothing, and was wrong about two of the five.
#[test]
fn conformance_is_reported_honestly() {
    let conformant: Vec<&str> = profiles::ALL
        .iter()
        .filter(|p| p.is_conformant_cius())
        .map(|p| p.id)
        .collect();
    assert_eq!(
        conformant,
        ["EN 16931", "XRechnung 3.0", "Peppol BIS Billing 3.0"],
        "a profile that suppresses a core rule is not a conformant CIUS"
    );
    for p in profiles::ALL {
        assert_eq!(
            p.is_conformant_cius(),
            p.suppressed.is_empty(),
            "{} — conformance and suppression must agree",
            p.id
        );
    }
}

/// The *behavioural* half of §4.4.4, which the structural claim only implies.
///
/// For a conformant CIUS, **everything it accepts the core model accepts**. That
/// is what makes [`Validated::widen`] infallible, and it is a property of the
/// rule sets that can be checked rather than argued.
///
/// Checked over the corpus of real documents each profile is built around, not
/// over a single hand-picked one — a claim of the form "for all documents"
/// deserves more than one witness.
#[test]
fn a_conformant_cius_never_accepts_what_core_rejects() {
    let docs = [core_valid(), xrechnung_valid()];
    for p in profiles::ALL.iter().filter(|p| p.is_conformant_cius()) {
        for doc in &docs {
            let mut doc = doc.clone();
            doc.specification_id = Some(p.specification_id.to_owned());
            if p.validate(&doc).is_valid() {
                let core = en16931::validate(&doc);
                assert!(
                    core.is_valid(),
                    "{} accepted a document core EN 16931 rejects, so §4.4.4's \
                     widening guarantee does not hold for it:\n{core}",
                    p.id
                );
            }
        }
    }
}

/// A profile report counts the restrictions it checked alongside the core rules,
/// so coverage is never overstated.
#[test]
fn profile_reports_state_their_own_coverage() {
    let core_only = profiles::EN16931.validate(&core_valid());
    let with_cius = profiles::XRECHNUNG.validate(&core_valid());
    assert!(
        with_cius.rules_checked() > core_only.rules_checked(),
        "{} vs {}",
        with_cius.rules_checked(),
        core_only.rules_checked()
    );
}

/// Markers and profiles agree — a marker cannot point at the wrong profile.
#[test]
fn markers_match_their_profiles() {
    assert_eq!(En16931::PROFILE.id, profiles::EN16931.id);
    assert_eq!(XRechnung::PROFILE.id, profiles::XRECHNUNG.id);
    assert_eq!(PeppolBis3::PROFILE.id, profiles::PEPPOL_BIS_3.id);
}

/// **The third tolerance regime.** Peppol's ±0.02 is not CEN's ±1.00 and not the
/// exact totals chain, and the three must not be confused.
#[test]
fn peppols_line_arithmetic_is_a_third_tolerance_regime() {
    let build = |net: &str| {
        let mut inv = core_valid();
        inv.specification_id = Some(profiles::PEPPOL_BIS_3.specification_id.to_owned());
        inv.buyer_reference = Some("REF".to_owned()); // PEPPOL-EN16931-R003
        inv.lines[0].quantity = Quantity::new(dec!(1));
        inv.lines[0].price.net_price = UnitPriceAmount::new(dec!(100));
        inv.lines[0].net_amount = amount(net);
        // Keep the totals chain consistent so only R120 can fire.
        inv.totals.line_total = amount(net);
        inv.totals.taxable_total = amount(net);
        inv.vat_breakdown[0].taxable_amount = amount(net);
        inv
    };

    // 1 × 100 = 100.00. Two cents out is inside Peppol's slack.
    let inv = build("100.02");
    assert!(
        !profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-R120")
    );
    // Three cents is not.
    let inv = build("100.03");
    assert!(
        profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-R120")
    );

    // And EN 16931 core has no such rule at all: a line whose amount does not
    // follow from its price is perfectly valid under the standard.
    let inv = build("999.00");
    assert!(!profiles::EN16931.validate(&inv).has("PEPPOL-EN16931-R120"));
}

/// `R046` looks like `R040`'s sibling and is **exact** — it carries no
/// `u:slack`. A producer should derive BT-146 rather than compute and state it.
#[test]
fn r046_is_exact_where_r040_is_tolerant() {
    let mut inv = core_valid();
    inv.specification_id = Some(profiles::PEPPOL_BIS_3.specification_id.to_owned());
    inv.buyer_reference = Some("REF".to_owned());
    inv.lines[0].price.gross_price = Some(UnitPriceAmount::new(dec!(101)));
    inv.lines[0].price.price_discount = Some(UnitPriceAmount::new(dec!(1)));
    // 101 − 1 = 100, exactly what BT-146 already is.
    assert!(
        !profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-R046")
    );

    // One cent out — tolerated by R040, fatal under R046.
    inv.lines[0].price.price_discount = Some(UnitPriceAmount::new(dec!(0.99)));
    assert!(
        profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-R046")
    );
}

/// `R130` is a cross-field rule between BT-130 (on the quantity) and BT-150 (on
/// the price), so only the line can check it.
#[test]
fn r130_compares_two_terms_neither_type_owns() {
    let mut inv = core_valid();
    inv.specification_id = Some(profiles::PEPPOL_BIS_3.specification_id.to_owned());
    inv.buyer_reference = Some("REF".to_owned());
    inv.lines[0].price.base_quantity = Some(Quantity::new(dec!(1)));
    inv.lines[0].price.base_quantity_code = Some(Code::new("H87")); // line is C62

    let report = profiles::PEPPOL_BIS_3.validate(&inv);
    assert!(report.has("PEPPOL-EN16931-R130"), "{report}");

    inv.lines[0].price.base_quantity_code = Some(Code::new("C62"));
    assert!(
        !profiles::PEPPOL_BIS_3
            .validate(&inv)
            .has("PEPPOL-EN16931-R130")
    );
}

/// `R121`: the base quantity is R120's divisor, so zero is undefined rather than
/// merely odd — and the engine must not divide by it.
#[test]
fn a_zero_base_quantity_is_reported_and_never_divided_by() {
    let mut inv = core_valid();
    inv.specification_id = Some(profiles::PEPPOL_BIS_3.specification_id.to_owned());
    inv.buyer_reference = Some("REF".to_owned());
    inv.lines[0].price.base_quantity = Some(Quantity::ZERO);

    let report = profiles::PEPPOL_BIS_3.validate(&inv); // must not panic
    assert!(report.has("PEPPOL-EN16931-R121"), "{report}");
    assert!(
        !report.has("PEPPOL-EN16931-R120"),
        "R120 must skip, not divide"
    );
}

/// `extra_rules` is §7.3.2's one axis that genuinely needs code — and it is now
/// exercised rather than empty.
#[test]
fn profiles_carry_conditional_rules_that_restrictions_cannot_express() {
    assert!(
        profiles::EN16931.extra_rules.is_empty(),
        "core adds nothing"
    );
    assert!(!profiles::PEPPOL_BIS_3.extra_rules.is_empty());
    // XRechnung inherits Peppol's arithmetic via the German national rule set.
    assert!(!profiles::XRECHNUNG.extra_rules.is_empty());
}

/// **`PaymentMeans` is an enum, so three rules have nothing left to check.**
///
/// `BR-DE-23-b`, `-24-b` and `-25-b` each forbid the two payment groups that
/// BT-81 did not name. The combination they forbid cannot be written down — the
/// model's own §6.1 note, finally honoured.
#[test]
fn the_payment_groups_are_mutually_exclusive_by_construction() {
    let mut inv = xrechnung_valid();

    // BT-81 = 58 with BG-17 present: correct.
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-23-a"));

    // Switch BT-81 to a card code without switching the group: `-a` fires,
    // because it ties the *variant* to BT-81's value — which no type can see.
    inv.payment.as_mut().unwrap().means_code = Some(Code::new("48"));
    let report = profiles::XRECHNUNG.validate(&inv);
    assert!(report.has("BR-DE-24-a"), "{report}");

    // Switching the group too satisfies it. There is no way to have both.
    inv.payment.as_mut().unwrap().means = Some(PaymentMeans::Card(PaymentCard {
        primary_account_number: Some("############1234".to_owned()),
        holder_name: Some("A. Muster".to_owned()),
    }));
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-24-a"));
}

/// Direct debit pulls in three rules at once — and the IBAN is checked offline.
#[test]
fn direct_debit_requires_its_own_terms_and_a_real_iban() {
    let mut inv = xrechnung_valid();
    inv.payment = Some(PaymentInstructions {
        means_code: Some(Code::new("59")), // SEPA direct debit
        means: Some(PaymentMeans::DirectDebit(DirectDebit::default())),
        ..Default::default()
    });

    let report = profiles::XRECHNUNG.validate(&inv);
    assert!(
        report.has("BR-DE-30"),
        "BT-90 creditor identifier: {report}"
    );
    assert!(report.has("BR-DE-31"), "BT-91 debited account: {report}");

    // …and BT-89, through `PEPPOL-EN16931-R061`. XRechnung 3.0 withdrew
    // `BR-DE-29` precisely because R061 covers it — and R061 *is* an XRechnung
    // rule, because KoSIT's build merges 31 Peppol asserts into the Schematron
    // it ships. The file in KoSIT's repository is an input to that build, not
    // the artefact its validator loads.
    assert!(
        report.has("PEPPOL-EN16931-R061"),
        "R061 replaced BR-DE-29 and is merged into XRechnung:\n{report}"
    );

    // Fill them, but with a mistyped IBAN.
    inv.payment.as_mut().unwrap().means = Some(PaymentMeans::DirectDebit(DirectDebit {
        mandate_reference: Some("MANDATE-1".to_owned()),
        creditor_identifier: Some("DE98ZZZ09999999999".to_owned()),
        debited_account: Some("DE89370400440532013001".to_owned()), // last digit wrong
    }));
    let report = profiles::XRECHNUNG.validate(&inv);
    assert!(!report.has("BR-DE-30"));
    assert!(!report.has("BR-DE-31"));
    assert!(report.has("BR-DE-20"), "mod-97 catches the typo: {report}");
    // …but advisory, not fatal: this crate cannot check a registry, only a
    // checksum, so it reports a suspicion rather than a rejection.
    assert!(report.is_valid(), "{report}");

    // Correct the checksum and the warning goes.
    inv.payment.as_mut().unwrap().means = Some(PaymentMeans::DirectDebit(DirectDebit {
        mandate_reference: Some("MANDATE-1".to_owned()),
        creditor_identifier: Some("DE98ZZZ09999999999".to_owned()),
        debited_account: Some("DE89370400440532013000".to_owned()),
    }));
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-20"));
}

/// `BR-DE-26` — a corrected invoice must say what it corrects.
#[test]
fn a_corrected_invoice_must_reference_the_original() {
    let mut inv = xrechnung_valid();
    inv.type_code = Some(Code::new("384")); // corrected invoice

    assert!(profiles::XRECHNUNG.validate(&inv).has("BR-DE-26"));

    inv.preceding_invoices = vec![PrecedingInvoice {
        reference: DocumentReference::new("INV-2026-000"),
        issue_date: Some(Date::parse("2026-05-31").unwrap()),
    }];
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-26"));
}

/// `BR-DE-27` / `BR-DE-28` — contact shape checks, weaker than an RFC parser on
/// purpose.
#[test]
fn contact_formats_are_shape_checked() {
    let mut inv = xrechnung_valid();

    inv.seller.contact.phone = Some("ext.".to_owned()); // fewer than three digits
    assert!(profiles::XRECHNUNG.validate(&inv).has("BR-DE-27"));
    inv.seller.contact.phone = Some("+49 30 123456".to_owned());
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-27"));

    inv.seller.contact.email = Some("not-an-address".to_owned());
    assert!(profiles::XRECHNUNG.validate(&inv).has("BR-DE-28"));
    inv.seller.contact.email = Some("rechnung@seller.de".to_owned());
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-28"));
}

/// `BR-DE-16` — a seller charging VAT must be identifiable to the tax authority.
#[test]
fn a_vat_charging_seller_needs_a_tax_identifier() {
    let mut inv = xrechnung_valid();
    inv.seller.vat_identifier = None;
    inv.seller.tax_registration = None;

    let report = profiles::XRECHNUNG.validate(&inv);
    assert!(report.has("BR-DE-16"), "{report}");

    // BT-32 satisfies it just as BT-31 does — the rule accepts either.
    inv.seller.tax_registration = Some("DE 199/123/45678".to_owned());
    assert!(!profiles::XRECHNUNG.validate(&inv).has("BR-DE-16"));
}

/// XRechnung merges Peppol's rules **and rewrites two of them**.
///
/// The same document can be an invalid Peppol invoice and a valid XRechnung,
/// for two independent reasons, and both are load-bearing.
#[test]
fn xrechnung_rewrites_r120_on_the_way_in() {
    // 1. Severity. A line whose net amount does not follow from its price is
    //    fatal under Peppol and a warning under XRechnung.
    let mut inv = xrechnung_valid();
    inv.lines[0].price.net_price = UnitPriceAmount::new(dec!(1));

    let de = profiles::XRECHNUNG.validate(&inv);
    assert!(de.has("PEPPOL-EN16931-R120"), "R120 still runs:\n{de}");
    assert!(
        de.is_valid(),
        "…but only as a warning, so the document stands:\n{de}"
    );
    assert!(
        de.warnings().any(|f| f.rule.contains("R120")),
        "and it is reported as one:\n{de}"
    );

    let mut peppol_doc = inv.clone();
    peppol_doc.specification_id = Some(profiles::PEPPOL_BIS_3.specification_id.to_owned());
    let pe = profiles::PEPPOL_BIS_3.validate(&peppol_doc);
    assert!(!pe.is_valid(), "Peppol keeps it fatal:\n{pe}");

    // 2. Slack. HUF has no minor unit in practice, so XRechnung widens the
    //    tolerance to 0.5 where Peppol always uses 0.02.
    let mut huf = xrechnung_valid();
    huf.currency = Some(Code::new("HUF"));
    // 0.30 out: inside XRechnung's 0.5, outside Peppol's 0.02.
    huf.lines[0].net_amount = InvoiceAmount::parse("100.30").unwrap();
    assert!(
        !profiles::XRECHNUNG
            .validate(&huf)
            .has("PEPPOL-EN16931-R120"),
        "0.30 is within XRechnung's HUF slack"
    );

    let mut huf_peppol = huf.clone();
    huf_peppol.specification_id = Some(profiles::PEPPOL_BIS_3.specification_id.to_owned());
    assert!(
        profiles::PEPPOL_BIS_3
            .validate(&huf_peppol)
            .has("PEPPOL-EN16931-R120"),
        "…and outside Peppol's, which is 0.02 for every currency"
    );
}

/// Fifteen Peppol rules are **not** merged, and must not fire under XRechnung.
///
/// `CL001`…`CL008` are Peppol's narrower code lists and `P0104`…`P0112` the
/// VATEX-to-category pinning. Reporting one of them as a German defect would be
/// a false positive.
#[test]
fn the_unmerged_peppol_rules_stay_out_of_xrechnung() {
    let ids: Vec<&str> = profiles::XRECHNUNG
        .extra_rules
        .iter()
        .map(|r| r.id.as_str())
        .collect();
    for unmerged in [
        "PEPPOL-EN16931-CL001",
        "PEPPOL-EN16931-CL008",
        "PEPPOL-EN16931-P0104",
        "PEPPOL-EN16931-P0112",
    ] {
        assert!(!ids.contains(&unmerged), "{unmerged} must not be merged");
    }
    // …and the merged ones are there.
    assert!(ids.contains(&"PEPPOL-EN16931-R061"));
    assert!(ids.contains(&"PEPPOL-EN16931-R120"));
}

/// The evidence behind the missing `Underlies` impls for CVD.
///
/// A conforming Clean Vehicles invoice marks a vehicle line with `BT-158` under
/// the scheme `CVD`, and `CVD` is **not in UNTDID 7143** — so core `BR-CL-13`
/// rejects it, while `XRECHNUNG_CVD` accepts it because it suppresses that rule.
///
/// This test exists because the crate briefly offered
/// `Validated<XRechnungCvd>::widen::<En16931>()`, which handed back a proof of
/// core-validity for exactly this document. If someone ever removes the
/// suppression, this test fails and the widening can be reinstated deliberately
/// rather than by assumption.
#[test]
fn a_cvd_invoice_can_be_core_invalid() {
    let mut inv = xrechnung_valid();
    inv.specification_id = Some(profiles::XRECHNUNG_CVD.specification_id.to_owned());
    inv.contract_reference = Some(en16931::DocumentReference::new("V-2026-88"));
    inv.tender_reference = Some(en16931::DocumentReference::new("LOS-3"));
    inv.lines[0].item.classification_identifiers = vec![Identifier::schemed("N1", "CVD")];
    inv.lines[0].item.attributes = vec![ItemAttribute {
        name: Some("cva".to_owned()),
        value: Some("zero-emission".to_owned()),
    }];

    let cvd = profiles::XRECHNUNG_CVD.validate(&inv);
    assert!(cvd.is_valid(), "a conforming CVD invoice:\n{cvd}");

    let core = en16931::validate(&inv);
    assert!(
        core.has("BR-CL-13"),
        "…which core EN 16931 rejects, because `CVD` is not in UNTDID 7143:\n{core}"
    );
    assert!(
        !profiles::XRECHNUNG_CVD.is_conformant_cius(),
        "so CVD is not a conformant CIUS, and nothing may widen out of it"
    );
}

// ── Deviations, recorded rather than hidden ───────────────────────────────────

/// A suppressed rule is skipped, and the report says so.
#[test]
fn suppression_is_loud() {
    use en16931::validation::Check;

    let mut inv = xrechnung_valid();
    inv.buyer_reference = None; // BR-DE-15 and PEPPOL-EN16931-R003

    let plain = profiles::XRECHNUNG.validate(&inv);
    assert!(plain.has("BR-DE-15"), "the rule fires normally:\n{plain}");
    assert!(plain.suppressed().is_empty());

    let deviated = Check::new(&profiles::XRECHNUNG)
        .without("BR-DE-15")
        .run(&inv);
    assert!(!deviated.has("BR-DE-15"), "suppressed:\n{deviated}");
    assert_eq!(deviated.suppressed(), ["BR-DE-15"]);
    assert!(
        deviated.to_string().contains("suppressed and NOT checked"),
        "a stored report must not misrepresent what ran:\n{deviated}"
    );
    // The count drops, so `rules_checked` cannot overstate coverage either.
    assert!(deviated.rules_checked() < plain.rules_checked());
}

/// Suppression accepts any spelling of the id, like every other lookup here.
#[test]
fn suppression_matches_ids_canonically() {
    use en16931::validation::Check;
    let inv = core_valid();
    let report = Check::new(&profiles::EN16931).without("br-co-3").run(&inv);
    assert_eq!(report.suppressed(), ["br-co-3"]);
}

/// **A deviated run cannot produce a proof.**
///
/// This is `XRECHNUNG_CVD`'s lesson at runtime: a rule set with a hole may
/// accept documents the full set rejects, so a `Validated<P>` derived from it
/// would claim something untrue. `Validated<P>` means *the whole rule set
/// passed*; if it could also mean *most of it*, no consumer could rely on it.
#[test]
fn a_deviated_run_refuses_to_prove() {
    use en16931::validation::{Check, ProveError};

    let inv = xrechnung_valid();
    // Without suppressions, the proof is available.
    let proof = Check::new(&profiles::XRECHNUNG).prove::<XRechnung>(inv.clone());
    assert!(proof.is_ok(), "a clean run proves");

    // With one, it is refused — even though the document is perfectly valid.
    let refused = Check::new(&profiles::XRECHNUNG)
        .without("BR-DE-15")
        .prove::<XRechnung>(inv);
    match refused {
        Err(ProveError::Suppressed(ids)) => assert_eq!(ids, ["BR-DE-15"]),
        Err(e) => panic!("wrong error: {e}"),
        Ok(_) => panic!("a suppressed run must not yield a proof"),
    }
}

/// A report names the profile it came from, and the edition.
#[test]
fn a_report_says_what_it_checked_against() {
    let report = profiles::XRECHNUNG.validate(&Invoice::default());
    assert_eq!(report.profile(), Some("XRechnung 3.0"));
    assert_eq!(report.edition(), en16931::Edition::En2017A1);
    let shown = report.to_string();
    assert!(shown.starts_with("XRechnung 3.0 validation"), "{shown}");

    // The bare core path says so rather than naming a profile it did not use.
    let core = en16931::validate(&Invoice::default());
    assert_eq!(core.profile(), None);
    assert!(core.to_string().starts_with("EN 16931 validation"));
}

/// The core path and the `EN 16931` profile must agree about what they checked.
///
/// They are separate code paths — one filters, one does not — and for a while
/// they reported 226 and 225 rules for the same invoice with the same findings.
/// A caller comparing "core" against "the EN 16931 profile" saw a difference
/// that meant nothing, which is worse than no number at all.
#[test]
fn the_core_path_and_the_en16931_profile_agree() {
    for invoice in [en16931::Invoice::default(), corpus_invoice()] {
        let core = en16931::validate(&invoice);
        let profile = en16931::profiles::EN16931.validate(&invoice);

        assert_eq!(
            core.rules_checked(),
            profile.rules_checked(),
            "the two paths disagree about how many rules ran"
        );
        let core_rules: Vec<&str> = core.findings().iter().map(|f| f.rule.as_str()).collect();
        let profile_rules: Vec<&str> = profile.findings().iter().map(|f| f.rule.as_str()).collect();
        assert_eq!(
            core_rules, profile_rules,
            "the two paths disagree on findings"
        );
    }
}

/// An invoice carrying an extension, so `EN-EXT-01` is genuinely applicable.
fn corpus_invoice() -> en16931::Invoice {
    let mut inv = en16931::Invoice::default();
    inv.extensions
        .third_party_payments
        .push(en16931::ThirdPartyPayment {
            payment_type: Some("BG-DEX-01".into()),
            amount: None,
            description: None,
        });
    inv
}