Skip to main content

core_invoice/
codes.rs

1//! Code lists generated from `refers/` genericode (code points only; EUPL XML stays out of git).
2//!
3//! `task lists` / `python3 xtask/gen_codes.py` refreshes [`generated_codes`].
4
5use crate::bt::{BtId, Group, Path};
6use crate::generated_codes as lists;
7use crate::invoice::Invoice;
8use crate::kind::DocumentKind;
9use crate::profile::Profile;
10use crate::report::{Finding, Report, Severity, Source};
11use crate::rules::Rule;
12use crate::tax::TaxSystem;
13
14/// CEN EN 16931 validation artefacts pin. Fully-qualified tag, not the branch.
15pub const ARTEFACT_VERSION: &str = "validation-1.3.16";
16pub const PEPPOL_BIS_VERSION: &str = "v3.0.20";
17pub const PINT_MY_VERSION: &str = "1.3.0";
18/// ConnectingEurope/eInvoicing-EN16931 release tag commit (docs/spec.md).
19pub const EN16931_GIT: &str = "b6c9e06";
20pub const PINT_VERSION: &str = "1.1.2";
21
22fn listed(list: &[&str], code: &str) -> bool {
23    list.iter().any(|c| c.eq_ignore_ascii_case(code))
24}
25
26pub fn currency(code: &str) -> bool {
27    listed(lists::ISO_4217, code)
28}
29pub fn country(code: &str) -> bool {
30    listed(lists::ISO_3166, code)
31}
32pub fn uncl_5305(code: &str) -> bool {
33    listed(lists::UNCL_5305, code)
34}
35pub fn invoice_type(code: &str) -> bool {
36    lists::UNCL_1001_INVOICE.contains(&code)
37}
38pub fn credit_note_type(code: &str) -> bool {
39    lists::UNCL_1001_CREDIT_NOTE.contains(&code)
40}
41pub fn eas(code: &str) -> bool {
42    lists::EAS.contains(&code)
43}
44pub fn vatex(code: &str) -> bool {
45    listed(lists::VATEX, code)
46}
47pub fn unit(code: &str) -> bool {
48    lists::REC20.contains(&code)
49}
50pub fn mime(code: &str) -> bool {
51    lists::MIME.contains(&code)
52}
53pub fn icd(code: &str) -> bool {
54    lists::ICD.contains(&code)
55}
56pub fn uncl_1153(code: &str) -> bool {
57    listed(lists::UNCL_1153, code)
58}
59pub fn uncl_4451(code: &str) -> bool {
60    listed(lists::UNCL_4451, code)
61}
62pub fn pint_my_taxcat(code: &str) -> bool {
63    listed(lists::PINT_MY_TAXCAT, code)
64}
65
66pub mod guard {
67    use crate::profile::Profile;
68
69    /// EAS membership with a withdrawn-successor hint. No network.
70    pub fn eas(code: &str, profile: Profile) -> Result<(), String> {
71        match code {
72            "9958" => Err("EAS 9958 is withdrawn; use 0204".into()),
73            "T" if profile == Profile::PintMy => {
74                Err("PINT-MY tax category T is withdrawn; use SA/SE/HVG/LVG".into())
75            }
76            _ => Ok(()),
77        }
78    }
79}
80
81fn br_cl_01(inv: &Invoice, report: &mut Report) {
82    let Some(code) = inv.type_code.as_ref() else {
83        return;
84    };
85    let ok = match inv.kind {
86        DocumentKind::Invoice => invoice_type(code.as_str()),
87        DocumentKind::CreditNote => credit_note_type(code.as_str()),
88    };
89    if !ok {
90        report.push(Finding::fatal(
91            "BR-CL-01",
92            Path::term(BtId(3)),
93            format!(
94                "type code {} is not in the UNTDID 1001 list for {:?}",
95                code, inv.kind
96            ),
97        ));
98    }
99}
100
101fn br_cl_03(_inv: &Invoice, _report: &mut Report) {
102    // BR-CL-03: @currencyID ∈ ISO 4217. Wire-only; formats::validate_xml walks attributes.
103}
104
105fn br_cl_08(inv: &Invoice, report: &mut Report) {
106    // BR-CL-08: BT-21 note subject, restriction of UNTDID 4451. Absent subject does not fire.
107    for (i, n) in inv.notes.iter().enumerate() {
108        let Some(code) = n.subject.as_ref() else {
109            continue;
110        };
111        if !uncl_4451(code.as_str()) {
112            report.push(Finding::fatal(
113                "BR-CL-08",
114                Path::at_term(Group::Document, i, BtId(21)),
115                format!("note subject {code} is not in UNTDID 4451 (EN restriction)"),
116            ));
117        }
118    }
119}
120
121fn br_cl_04(inv: &Invoice, report: &mut Report) {
122    if inv.currency.trim().is_empty() {
123        return;
124    }
125    if !currency(&inv.currency) {
126        report.push(Finding::fatal(
127            "BR-CL-04",
128            Path::term(BtId(5)),
129            format!("BT-5 {} is not an ISO 4217 alphabetic code", inv.currency),
130        ));
131    }
132}
133
134fn br_cl_05(inv: &Invoice, report: &mut Report) {
135    let Some(code) = inv.tax_currency.as_ref() else {
136        return;
137    };
138    if !currency(code.as_str()) {
139        report.push(Finding::fatal(
140            "BR-CL-05",
141            Path::term(BtId(6)),
142            format!("BT-6 {code} is not an ISO 4217 alphabetic code"),
143        ));
144    }
145}
146
147fn br_cl_14(inv: &Invoice, report: &mut Report) {
148    for (party, group, bt) in [
149        (&inv.seller, Group::Seller, 40u16),
150        (&inv.buyer, Group::Buyer, 55u16),
151    ] {
152        if party.country().trim().is_empty() {
153            continue;
154        }
155        if !country(party.country()) {
156            report.push(Finding::fatal(
157                "BR-CL-14",
158                Path::group_term(group, BtId(bt)),
159                format!("country {} is not ISO 3166-1 alpha-2", party.country()),
160            ));
161        }
162    }
163}
164
165fn br_cl_16(inv: &Invoice, report: &mut Report) {
166    let Some(pay) = inv.payment.as_ref() else {
167        return;
168    };
169    let Some(code) = pay.means_code.as_ref() else {
170        return;
171    };
172    let ok = lists::UNCL_4461.contains(&code.as_str())
173        || (inv.profile == Profile::PintMy && pint_my_payment(code.as_str()));
174    if !ok {
175        report.push(Finding::fatal(
176            "BR-CL-16",
177            Path::group_term(Group::Payment, BtId(81)),
178            format!("BT-81 {code} is not in UNCL 4461 (MY Z0x are profile extras)"),
179        ));
180    }
181}
182
183/// Z01/Z03–Z08 are PINT-MY extras on BT-81, not UNCL 4461 membership for EN/Peppol.
184fn pint_my_payment(code: &str) -> bool {
185    matches!(code, "Z01" | "Z03" | "Z04" | "Z05" | "Z06" | "Z07" | "Z08")
186}
187
188fn vat_profile(inv: &Invoice) -> bool {
189    matches!(inv.profile, Profile::En16931 | Profile::PeppolBis3)
190}
191
192fn br_cl_17(inv: &Invoice, report: &mut Report) {
193    if !vat_profile(inv) {
194        return;
195    }
196    for (i, e) in inv.tax_breakdown.iter().enumerate() {
197        if !uncl_5305(e.category.as_str()) {
198            report.push(Finding::fatal(
199                "BR-CL-17",
200                Path::at_term(Group::TaxBreakdown, i, BtId(118)),
201                format!("BT-118 {} is not UNCL 5305", e.category),
202            ));
203        }
204    }
205}
206
207fn br_cl_18(inv: &Invoice, report: &mut Report) {
208    if !vat_profile(inv) {
209        return;
210    }
211    for (i, line) in inv.lines.iter().enumerate() {
212        if line.tax.system != TaxSystem::Vat {
213            continue;
214        }
215        if !uncl_5305(&line.tax.code) {
216            report.push(Finding::fatal(
217                "BR-CL-18",
218                Path::at_term(Group::Line, i, BtId(151)),
219                format!("BT-151 {} is not UNCL 5305", line.tax.code),
220            ));
221        }
222    }
223}
224
225fn br_cl_22(inv: &Invoice, report: &mut Report) {
226    for (i, e) in inv.tax_breakdown.iter().enumerate() {
227        let Some(code) = e.exemption_code.as_ref() else {
228            continue;
229        };
230        if !vatex(code.as_str()) {
231            report.push(Finding::fatal(
232                "BR-CL-22",
233                Path::at_term(Group::TaxBreakdown, i, BtId(121)),
234                format!("BT-121 {code} is not a VATEX code"),
235            ));
236        }
237    }
238}
239
240fn br_cl_23(inv: &Invoice, report: &mut Report) {
241    for (i, line) in inv.lines.iter().enumerate() {
242        let Some(u) = line.unit.as_ref() else {
243            continue;
244        };
245        if !unit(u.as_str()) {
246            report.push(Finding::fatal(
247                "BR-CL-23",
248                Path::at_term(Group::Line, i, BtId(130)),
249                format!("BT-130 {u} is not UNECE Rec 20/21"),
250            ));
251        }
252    }
253}
254
255fn br_cl_24(inv: &Invoice, report: &mut Report) {
256    for (i, doc) in inv.supporting_documents.iter().enumerate() {
257        let Some(att) = doc.attachment.as_ref() else {
258            continue;
259        };
260        if !mime(att.mime.as_str()) {
261            report.push(Finding::fatal(
262                "BR-CL-24",
263                Path::at_term(Group::Attachment, i, BtId(125)),
264                format!("mime {} is not in the subset", att.mime),
265            ));
266        }
267    }
268}
269
270fn br_cl_07(inv: &Invoice, report: &mut Report) {
271    // BR-CL-07: BT-18 / BT-128 scheme (when present) is UNTDID 1153, not ICD.
272    if let Some(scheme) = inv
273        .invoiced_object
274        .as_ref()
275        .and_then(|id| id.scheme.as_deref())
276        && !uncl_1153(scheme)
277    {
278        report.push(Finding::fatal(
279            "BR-CL-07",
280            Path::term(BtId(18)),
281            format!("object identifier scheme {scheme} is not UNTDID 1153"),
282        ));
283    }
284    for (i, line) in inv.lines.iter().enumerate() {
285        let Some(scheme) = line
286            .invoiced_object
287            .as_ref()
288            .and_then(|id| id.scheme.as_deref())
289        else {
290            continue;
291        };
292        if !uncl_1153(scheme) {
293            report.push(Finding::fatal(
294                "BR-CL-07",
295                Path::at_term(Group::Line, i, BtId(128)),
296                format!("object identifier scheme {scheme} is not UNTDID 1153"),
297            ));
298        }
299    }
300}
301
302fn br_cl_10(inv: &Invoice, report: &mut Report) {
303    // BR-CL-10: PartyIdentification scheme is ISO 6523 ICD. SEPA allowed on seller/payee.
304    let parties = [
305        (&inv.seller.identifiers[..], Group::Seller, 29u16, true),
306        (&inv.buyer.identifiers[..], Group::Buyer, 46u16, false),
307    ];
308    for (ids, group, bt, sepa_ok) in parties {
309        for id in ids {
310            let Some(scheme) = id.scheme.as_deref() else {
311                continue;
312            };
313            let ok = icd(scheme) || (sepa_ok && scheme.eq_ignore_ascii_case("SEPA"));
314            if !ok {
315                report.push(Finding::fatal(
316                    "BR-CL-10",
317                    Path::group_term(group, BtId(bt)),
318                    format!("identifier scheme {scheme} is not ISO 6523 ICD"),
319                ));
320            }
321        }
322    }
323    if let Some(payee) = inv.payee.as_ref()
324        && let Some(id) = payee.identifier.as_ref()
325        && let Some(scheme) = id.scheme.as_deref()
326        && !(icd(scheme) || scheme.eq_ignore_ascii_case("SEPA"))
327    {
328        report.push(Finding::fatal(
329            "BR-CL-10",
330            Path::term(BtId(60)),
331            format!("payee identifier scheme {scheme} is not ISO 6523 ICD"),
332        ));
333    }
334}
335
336fn br_cl_11(inv: &Invoice, report: &mut Report) {
337    // BR-CL-11: CompanyID scheme is ICD when present. Unschemed (PINT-MY BRN) does not fire.
338    for (reg, group, bt) in [
339        (inv.seller.legal_registration.as_ref(), Group::Seller, 30u16),
340        (inv.buyer.legal_registration.as_ref(), Group::Buyer, 47u16),
341        (
342            inv.payee
343                .as_ref()
344                .and_then(|p| p.legal_registration.as_ref()),
345            Group::Seller,
346            61u16,
347        ),
348    ] {
349        let Some(id) = reg else {
350            continue;
351        };
352        let Some(scheme) = id.scheme.as_deref() else {
353            continue;
354        };
355        if !icd(scheme) {
356            report.push(Finding::fatal(
357                "BR-CL-11",
358                Path::group_term(group, BtId(bt)),
359                format!("legal registration scheme {scheme} is not ISO 6523 ICD"),
360            ));
361        }
362    }
363}
364
365fn br_cl_21(inv: &Invoice, report: &mut Report) {
366    // BR-CL-21: BT-157 StandardItemIdentification scheme is ICD. Not BT-155 item_id.
367    for (i, line) in inv.lines.iter().enumerate() {
368        let Some(scheme) = line
369            .standard_id
370            .as_ref()
371            .and_then(|id| id.scheme.as_deref())
372        else {
373            continue;
374        };
375        if !icd(scheme) {
376            report.push(Finding::fatal(
377                "BR-CL-21",
378                Path::at_term(Group::Line, i, BtId(157)),
379                format!("BT-157 scheme {scheme} is not ISO 6523 ICD"),
380            ));
381        }
382    }
383}
384
385fn br_cl_26(inv: &Invoice, report: &mut Report) {
386    // BR-CL-26: DeliveryLocation/ID scheme is ICD.
387    let Some(scheme) = inv
388        .delivery
389        .as_ref()
390        .and_then(|d| d.location_id.as_ref())
391        .and_then(|id| id.scheme.as_deref())
392    else {
393        return;
394    };
395    if !icd(scheme) {
396        report.push(Finding::fatal(
397            "BR-CL-26",
398            Path::term(BtId(71)),
399            format!("deliver-to location scheme {scheme} is not ISO 6523 ICD"),
400        ));
401    }
402}
403
404fn br_cl_25(inv: &Invoice, report: &mut Report) {
405    for (party, group, bt) in [
406        (&inv.seller, Group::Seller, 34u16),
407        (&inv.buyer, Group::Buyer, 49u16),
408    ] {
409        let Some(ep) = party.electronic_address.as_ref() else {
410            continue;
411        };
412        let Some(scheme) = ep.scheme.as_deref() else {
413            continue;
414        };
415        if !eas(scheme) {
416            report.push(Finding::fatal(
417                "BR-CL-25",
418                Path::group_term(group, BtId(bt)),
419                format!("EAS {scheme} is not in the Electronic Address Identifier Scheme list"),
420            ));
421        }
422    }
423}
424
425fn br_cl_06(inv: &Invoice, report: &mut Report) {
426    let Some(code) = inv.tax_point_code.as_ref() else {
427        return;
428    };
429    // BR-CL-06: BT-8 is UNCL 2005 subset 3 / 35 / 432.
430    if !lists::UNCL_2005.contains(&code.as_str()) {
431        report.push(Finding::fatal(
432            "BR-CL-06",
433            Path::term(BtId(8)),
434            format!("BT-8 {code} is not UNCL 2005 (3, 35, 432)"),
435        ));
436    }
437}
438
439fn br_cl_13(inv: &Invoice, report: &mut Report) {
440    for (i, line) in inv.lines.iter().enumerate() {
441        for cl in &line.classifications {
442            let Some(scheme) = cl.scheme.as_deref() else {
443                continue;
444            };
445            // BR-CL-13 / IBR-CL-13: Item classification listID is UNCL 7143 (CG is CLASS in PINT-MY).
446            if !lists::UNCL_7143.contains(&scheme) {
447                report.push(Finding::fatal(
448                    "BR-CL-13",
449                    Path::at_term(Group::Line, i, BtId(158)),
450                    format!("classification listID {scheme} is not UNCL 7143"),
451                ));
452            }
453        }
454    }
455}
456
457fn br_cl_15(inv: &Invoice, report: &mut Report) {
458    for (i, line) in inv.lines.iter().enumerate() {
459        let Some(c) = line.origin_country.as_ref() else {
460            continue;
461        };
462        if !country(c.as_str()) {
463            report.push(Finding::fatal(
464                "BR-CL-15",
465                Path::at_term(Group::Line, i, BtId(159)),
466                format!("BT-159 {c} is not ISO 3166-1 alpha-2"),
467            ));
468        }
469    }
470}
471
472fn br_cl_19(inv: &Invoice, report: &mut Report) {
473    for (i, a) in inv.document_allowances.iter().enumerate() {
474        let Some(code) = a.reason_code.as_ref() else {
475            continue;
476        };
477        if !lists::UNCL_5189.contains(&code.as_str()) {
478            report.push(Finding::fatal(
479                "BR-CL-19",
480                Path::at_term(Group::DocumentAllowance, i, BtId(98)),
481                format!("BT-98 {code} is not UNCL 5189"),
482            ));
483        }
484    }
485}
486
487fn br_cl_20(inv: &Invoice, report: &mut Report) {
488    for (i, a) in inv.document_charges.iter().enumerate() {
489        let Some(code) = a.reason_code.as_ref() else {
490            continue;
491        };
492        if !lists::UNCL_7161.contains(&code.as_str()) {
493            report.push(Finding::fatal(
494                "BR-CL-20",
495                Path::at_term(Group::DocumentCharge, i, BtId(105)),
496                format!("BT-105 {code} is not UNCL 7161"),
497            ));
498        }
499    }
500}
501
502const fn r(id: &'static str, text: &'static str, eval: fn(&Invoice, &mut Report)) -> Rule {
503    Rule {
504        id,
505        severity: Severity::Fatal,
506        text,
507        source: Source::ArtefactOnly,
508        eval,
509    }
510}
511
512pub static RULES: &[Rule] = &[
513    r(
514        "BR-CL-01",
515        "Document type code MUST be coded by the invoice and credit note related code lists of UNTDID 1001.",
516        br_cl_01,
517    ),
518    r(
519        "BR-CL-03",
520        "currencyID MUST be coded using ISO 4217 alpha-3 (wire @currencyID).",
521        br_cl_03,
522    ),
523    r(
524        "BR-CL-04",
525        "Invoice currency code MUST be coded using ISO 4217 alpha-3.",
526        br_cl_04,
527    ),
528    r(
529        "BR-CL-08",
530        "Invoice note subject code (BT-21) MUST be coded using UNCL 4451.",
531        br_cl_08,
532    ),
533    r(
534        "BR-CL-05",
535        "Tax accounting currency MUST be coded using ISO 4217 alpha-3.",
536        br_cl_05,
537    ),
538    r(
539        "BR-CL-14",
540        "Country codes MUST be coded using ISO 3166-1 alpha-2.",
541        br_cl_14,
542    ),
543    r(
544        "BR-CL-06",
545        "VAT point date code (BT-8) MUST be coded using UNCL 2005 (3, 35, 432).",
546        br_cl_06,
547    ),
548    r(
549        "BR-CL-13",
550        "Item classification scheme (BT-158-1) MUST be coded using UNCL 7143.",
551        br_cl_13,
552    ),
553    r(
554        "BR-CL-15",
555        "Item origin country (BT-159) MUST be coded using ISO 3166-1 alpha-2.",
556        br_cl_15,
557    ),
558    r(
559        "BR-CL-16",
560        "Payment means code MUST be coded using UNCL 4461.",
561        br_cl_16,
562    ),
563    r(
564        "BR-CL-19",
565        "Document allowance reason code MUST be coded using UNCL 5189.",
566        br_cl_19,
567    ),
568    r(
569        "BR-CL-20",
570        "Document charge reason code MUST be coded using UNCL 7161.",
571        br_cl_20,
572    ),
573    r(
574        "BR-CL-17",
575        "VAT category code (BT-118) MUST be coded using UNCL 5305 (VAT profiles only).",
576        br_cl_17,
577    ),
578    r(
579        "BR-CL-18",
580        "Invoiced item VAT category code (BT-151) MUST be coded using UNCL 5305 (VAT profiles only).",
581        br_cl_18,
582    ),
583    r(
584        "BR-CL-22",
585        "VAT exemption reason code MUST be coded using the VATEX list (case-insensitive).",
586        br_cl_22,
587    ),
588    r(
589        "BR-CL-23",
590        "Unit codes MUST be coded using UNECE Rec 20 (generated list). Rec 21 is not generated.",
591        br_cl_23,
592    ),
593    r(
594        "BR-CL-24",
595        "Attachment mime code MUST be from the allowed MIME list (subset).",
596        br_cl_24,
597    ),
598    r(
599        "BR-CL-25",
600        "Electronic address scheme MUST be from EAS (subset).",
601        br_cl_25,
602    ),
603    r(
604        "BR-CL-07",
605        "Object identifier identification scheme (BT-18 / BT-128) MUST be coded using UNTDID 1153.",
606        br_cl_07,
607    ),
608    r(
609        "BR-CL-10",
610        "Party identifier scheme MUST be ISO 6523 ICD (SEPA allowed on seller/payee).",
611        br_cl_10,
612    ),
613    r(
614        "BR-CL-11",
615        "Legal registration identifier scheme MUST be ISO 6523 ICD when present.",
616        br_cl_11,
617    ),
618    r(
619        "BR-CL-21",
620        "Item standard identifier scheme (BT-157) MUST be ISO 6523 ICD.",
621        br_cl_21,
622    ),
623    r(
624        "BR-CL-26",
625        "Deliver-to location identifier scheme MUST be ISO 6523 ICD.",
626        br_cl_26,
627    ),
628];
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::invoice::{Invoice, Party};
634    use crate::rules::explain;
635    use crate::validate;
636
637    #[test]
638    fn us_dollar_sign_fails_cl04_eur_passes() {
639        let mut inv = Invoice::blank(
640            Profile::En16931,
641            "1",
642            "US$",
643            Party::new("S", "DE"),
644            Party::new("B", "FR"),
645        );
646        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
647        inv.type_code = Some(crate::code::Code::new("380"));
648        inv.lines = vec![crate::invoice::Line::new(
649            "1",
650            "A",
651            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
652            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
653        )];
654        let report = validate(&inv);
655        assert!(
656            report.findings.iter().any(|f| f.id == "BR-CL-04"),
657            "{report}"
658        );
659        inv.currency = "EUR".into();
660        let report = validate(&inv);
661        assert!(
662            report.findings.iter().all(|f| f.id != "BR-CL-04"),
663            "{report}"
664        );
665        inv.currency = "XXX".into();
666        let report = validate(&inv);
667        assert!(
668            report.findings.iter().all(|f| f.id != "BR-CL-04"),
669            "{report}"
670        );
671    }
672
673    #[test]
674    fn br_cl_08_note_subject_4451() {
675        let mut inv = Invoice::blank(
676            Profile::En16931,
677            "1",
678            "EUR",
679            Party::new("S", "DE"),
680            Party::new("B", "FR"),
681        );
682        inv.notes.push(crate::invoice::InvoiceNote {
683            subject: Some(crate::code::Code::new("NOPE")),
684            text: "x".into(),
685        });
686        let report = validate(&inv);
687        assert!(
688            report.findings.iter().any(|f| f.id == "BR-CL-08"),
689            "{report}"
690        );
691        inv.notes[0].subject = Some(crate::code::Code::new("AAA"));
692        assert!(validate(&inv).findings.iter().all(|f| f.id != "BR-CL-08"));
693        inv.notes[0].subject = None;
694        assert!(validate(&inv).findings.iter().all(|f| f.id != "BR-CL-08"));
695        assert!(explain("BR-CL-08").unwrap().contains("4451"));
696        assert!(explain("BR-CL-03").unwrap().contains("currencyID"));
697    }
698
699    #[test]
700    fn invoice_381_fails_cl01() {
701        let mut inv = Invoice::blank(
702            Profile::En16931,
703            "1",
704            "EUR",
705            Party::new("S", "DE"),
706            Party::new("B", "FR"),
707        );
708        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
709        inv.type_code = Some(crate::code::Code::new("381"));
710        inv.kind = DocumentKind::Invoice;
711        inv.lines = vec![crate::invoice::Line::new(
712            "1",
713            "A",
714            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
715            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
716        )];
717        let report = validate(&inv);
718        assert!(
719            report.findings.iter().any(|f| f.id == "BR-CL-01"),
720            "{report}"
721        );
722    }
723
724    #[test]
725    fn artefact_pins_are_fully_qualified() {
726        assert_eq!(ARTEFACT_VERSION, "validation-1.3.16");
727        assert_eq!(PEPPOL_BIS_VERSION, "v3.0.20");
728        assert_eq!(PINT_MY_VERSION, "1.3.0");
729    }
730
731    #[test]
732    fn br_cl_07_rejects_non_1153_scheme() {
733        let mut inv = Invoice::blank(
734            Profile::En16931,
735            "1",
736            "EUR",
737            Party::new("S", "DE"),
738            Party::new("B", "FR"),
739        );
740        inv.invoiced_object = Some(crate::identifier::Identifier::schemed("X", "NOPE"));
741        let report = validate(&inv);
742        assert!(
743            report.findings.iter().any(|f| f.id == "BR-CL-07"),
744            "{report}"
745        );
746    }
747
748    #[test]
749    fn br_cl_21_binds_standard_id_not_item_id() {
750        let mut inv = Invoice::blank(
751            Profile::En16931,
752            "1",
753            "EUR",
754            Party::new("S", "DE"),
755            Party::new("B", "FR"),
756        );
757        let mut line = crate::invoice::Line::new(
758            "1",
759            "A",
760            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
761            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
762        );
763        line.item_id = Some(crate::identifier::Identifier::schemed("SKU", "FOO"));
764        line.standard_id = Some(crate::identifier::Identifier::schemed("GTIN", "FOO"));
765        inv.lines = vec![line];
766        let report = validate(&inv);
767        assert!(
768            report.findings.iter().any(|f| f.id == "BR-CL-21"),
769            "{report}"
770        );
771    }
772
773    #[test]
774    fn invoice_326_is_not_br_cl_01() {
775        let mut inv = Invoice::blank(
776            Profile::PeppolBis3,
777            "1",
778            "EUR",
779            {
780                let mut p = Party::new("S", "DE");
781                p.electronic_address = Some(crate::identifier::Identifier::schemed("1", "0088"));
782                p
783            },
784            {
785                let mut p = Party::new("B", "DE");
786                p.electronic_address = Some(crate::identifier::Identifier::schemed("2", "0088"));
787                p
788            },
789        );
790        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
791        inv.type_code = Some(crate::code::Code::new("326"));
792        inv.specification_id = Some(Profile::PEPPOL_BIS3_PREFIX.into());
793        inv.business_process = Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0".into());
794        let report = validate(&inv);
795        assert!(
796            report.findings.iter().all(|f| f.id != "BR-CL-01"),
797            "{report}"
798        );
799    }
800}