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 e.category.as_str().trim().is_empty() {
198            continue;
199        }
200        if !uncl_5305(e.category.as_str()) {
201            report.push(Finding::fatal(
202                "BR-CL-17",
203                Path::at_term(Group::TaxBreakdown, i, BtId(118)),
204                format!("BT-118 {} is not UNCL 5305", e.category),
205            ));
206        }
207    }
208}
209
210fn br_cl_18(inv: &Invoice, report: &mut Report) {
211    if !vat_profile(inv) {
212        return;
213    }
214    for (i, line) in inv.lines.iter().enumerate() {
215        if line.tax.system != TaxSystem::Vat || line.tax.code.trim().is_empty() {
216            continue;
217        }
218        if !uncl_5305(&line.tax.code) {
219            report.push(Finding::fatal(
220                "BR-CL-18",
221                Path::at_term(Group::Line, i, BtId(151)),
222                format!("BT-151 {} is not UNCL 5305", line.tax.code),
223            ));
224        }
225    }
226}
227
228fn br_cl_22(inv: &Invoice, report: &mut Report) {
229    for (i, e) in inv.tax_breakdown.iter().enumerate() {
230        let Some(code) = e.exemption_code.as_ref() else {
231            continue;
232        };
233        if !vatex(code.as_str()) {
234            report.push(Finding::fatal(
235                "BR-CL-22",
236                Path::at_term(Group::TaxBreakdown, i, BtId(121)),
237                format!("BT-121 {code} is not a VATEX code"),
238            ));
239        }
240    }
241}
242
243fn br_cl_23(inv: &Invoice, report: &mut Report) {
244    for (i, line) in inv.lines.iter().enumerate() {
245        let Some(u) = line.unit.as_ref() else {
246            continue;
247        };
248        if !unit(u.as_str()) {
249            report.push(Finding::fatal(
250                "BR-CL-23",
251                Path::at_term(Group::Line, i, BtId(130)),
252                format!("BT-130 {u} is not UNECE Rec 20/21"),
253            ));
254        }
255    }
256}
257
258fn br_cl_24(inv: &Invoice, report: &mut Report) {
259    for (i, doc) in inv.supporting_documents.iter().enumerate() {
260        let Some(att) = doc.attachment.as_ref() else {
261            continue;
262        };
263        if att.mime.trim().is_empty() {
264            continue;
265        }
266        if !mime(att.mime.as_str()) {
267            report.push(Finding::fatal(
268                "BR-CL-24",
269                Path::at_term(Group::Attachment, i, BtId(125)),
270                format!("mime {} is not in the subset", att.mime),
271            ));
272        }
273    }
274}
275
276fn br_cl_07(inv: &Invoice, report: &mut Report) {
277    // BR-CL-07: BT-18 / BT-128 scheme (when present) is UNTDID 1153, not ICD.
278    if let Some(scheme) = inv
279        .invoiced_object
280        .as_ref()
281        .and_then(|id| id.scheme.as_deref())
282        && !uncl_1153(scheme)
283    {
284        report.push(Finding::fatal(
285            "BR-CL-07",
286            Path::term(BtId(18)),
287            format!("object identifier scheme {scheme} is not UNTDID 1153"),
288        ));
289    }
290    for (i, line) in inv.lines.iter().enumerate() {
291        let Some(scheme) = line
292            .invoiced_object
293            .as_ref()
294            .and_then(|id| id.scheme.as_deref())
295        else {
296            continue;
297        };
298        if !uncl_1153(scheme) {
299            report.push(Finding::fatal(
300                "BR-CL-07",
301                Path::at_term(Group::Line, i, BtId(128)),
302                format!("object identifier scheme {scheme} is not UNTDID 1153"),
303            ));
304        }
305    }
306}
307
308fn br_cl_10(inv: &Invoice, report: &mut Report) {
309    // BR-CL-10: PartyIdentification scheme is ISO 6523 ICD. SEPA allowed on seller/payee.
310    let parties = [
311        (&inv.seller.identifiers[..], Group::Seller, 29u16, true),
312        (&inv.buyer.identifiers[..], Group::Buyer, 46u16, false),
313    ];
314    for (ids, group, bt, sepa_ok) in parties {
315        for id in ids {
316            let Some(scheme) = id.scheme.as_deref() else {
317                continue;
318            };
319            let ok = icd(scheme) || (sepa_ok && scheme.eq_ignore_ascii_case("SEPA"));
320            if !ok {
321                report.push(Finding::fatal(
322                    "BR-CL-10",
323                    Path::group_term(group, BtId(bt)),
324                    format!("identifier scheme {scheme} is not ISO 6523 ICD"),
325                ));
326            }
327        }
328    }
329    if let Some(payee) = inv.payee.as_ref()
330        && let Some(id) = payee.identifier.as_ref()
331        && let Some(scheme) = id.scheme.as_deref()
332        && !(icd(scheme) || scheme.eq_ignore_ascii_case("SEPA"))
333    {
334        report.push(Finding::fatal(
335            "BR-CL-10",
336            Path::term(BtId(60)),
337            format!("payee identifier scheme {scheme} is not ISO 6523 ICD"),
338        ));
339    }
340}
341
342fn br_cl_11(inv: &Invoice, report: &mut Report) {
343    // BR-CL-11: CompanyID scheme is ICD when present. Unschemed (PINT-MY BRN) does not fire.
344    for (reg, group, bt) in [
345        (inv.seller.legal_registration.as_ref(), Group::Seller, 30u16),
346        (inv.buyer.legal_registration.as_ref(), Group::Buyer, 47u16),
347        (
348            inv.payee
349                .as_ref()
350                .and_then(|p| p.legal_registration.as_ref()),
351            Group::Seller,
352            61u16,
353        ),
354    ] {
355        let Some(id) = reg else {
356            continue;
357        };
358        let Some(scheme) = id.scheme.as_deref() else {
359            continue;
360        };
361        if !icd(scheme) {
362            report.push(Finding::fatal(
363                "BR-CL-11",
364                Path::group_term(group, BtId(bt)),
365                format!("legal registration scheme {scheme} is not ISO 6523 ICD"),
366            ));
367        }
368    }
369}
370
371fn br_cl_21(inv: &Invoice, report: &mut Report) {
372    // BR-CL-21: BT-157 StandardItemIdentification scheme is ICD. Not BT-155 item_id.
373    for (i, line) in inv.lines.iter().enumerate() {
374        let Some(scheme) = line
375            .standard_id
376            .as_ref()
377            .and_then(|id| id.scheme.as_deref())
378        else {
379            continue;
380        };
381        if !icd(scheme) {
382            report.push(Finding::fatal(
383                "BR-CL-21",
384                Path::at_term(Group::Line, i, BtId(157)),
385                format!("BT-157 scheme {scheme} is not ISO 6523 ICD"),
386            ));
387        }
388    }
389}
390
391fn br_cl_26(inv: &Invoice, report: &mut Report) {
392    // BR-CL-26: DeliveryLocation/ID scheme is ICD.
393    let Some(scheme) = inv
394        .delivery
395        .as_ref()
396        .and_then(|d| d.location_id.as_ref())
397        .and_then(|id| id.scheme.as_deref())
398    else {
399        return;
400    };
401    if !icd(scheme) {
402        report.push(Finding::fatal(
403            "BR-CL-26",
404            Path::term(BtId(71)),
405            format!("deliver-to location scheme {scheme} is not ISO 6523 ICD"),
406        ));
407    }
408}
409
410fn br_cl_25(inv: &Invoice, report: &mut Report) {
411    for (party, group, bt) in [
412        (&inv.seller, Group::Seller, 34u16),
413        (&inv.buyer, Group::Buyer, 49u16),
414    ] {
415        let Some(ep) = party.electronic_address.as_ref() else {
416            continue;
417        };
418        let Some(scheme) = ep.scheme.as_deref() else {
419            continue;
420        };
421        if !eas(scheme) {
422            report.push(Finding::fatal(
423                "BR-CL-25",
424                Path::group_term(group, BtId(bt)),
425                format!("EAS {scheme} is not in the Electronic Address Identifier Scheme list"),
426            ));
427        }
428    }
429}
430
431fn br_cl_06(inv: &Invoice, report: &mut Report) {
432    let Some(code) = inv.tax_point_code.as_ref() else {
433        return;
434    };
435    // BR-CL-06: BT-8 is UNCL 2005 subset 3 / 35 / 432.
436    if !lists::UNCL_2005.contains(&code.as_str()) {
437        report.push(Finding::fatal(
438            "BR-CL-06",
439            Path::term(BtId(8)),
440            format!("BT-8 {code} is not UNCL 2005 (3, 35, 432)"),
441        ));
442    }
443}
444
445fn br_cl_13(inv: &Invoice, report: &mut Report) {
446    for (i, line) in inv.lines.iter().enumerate() {
447        for cl in &line.classifications {
448            let Some(scheme) = cl.scheme.as_deref() else {
449                continue;
450            };
451            // BR-CL-13 / IBR-CL-13: Item classification listID is UNCL 7143 (CG is CLASS in PINT-MY).
452            if !lists::UNCL_7143.contains(&scheme) {
453                report.push(Finding::fatal(
454                    "BR-CL-13",
455                    Path::at_term(Group::Line, i, BtId(158)),
456                    format!("classification listID {scheme} is not UNCL 7143"),
457                ));
458            }
459        }
460    }
461}
462
463fn br_cl_15(inv: &Invoice, report: &mut Report) {
464    for (i, line) in inv.lines.iter().enumerate() {
465        let Some(c) = line.origin_country.as_ref() else {
466            continue;
467        };
468        if !country(c.as_str()) {
469            report.push(Finding::fatal(
470                "BR-CL-15",
471                Path::at_term(Group::Line, i, BtId(159)),
472                format!("BT-159 {c} is not ISO 3166-1 alpha-2"),
473            ));
474        }
475    }
476}
477
478fn br_cl_19(inv: &Invoice, report: &mut Report) {
479    for (i, a) in inv.document_allowances.iter().enumerate() {
480        let Some(code) = a.reason_code.as_ref() else {
481            continue;
482        };
483        if !lists::UNCL_5189.contains(&code.as_str()) {
484            report.push(Finding::fatal(
485                "BR-CL-19",
486                Path::at_term(Group::DocumentAllowance, i, BtId(98)),
487                format!("BT-98 {code} is not UNCL 5189"),
488            ));
489        }
490    }
491}
492
493fn br_cl_20(inv: &Invoice, report: &mut Report) {
494    for (i, a) in inv.document_charges.iter().enumerate() {
495        let Some(code) = a.reason_code.as_ref() else {
496            continue;
497        };
498        if !lists::UNCL_7161.contains(&code.as_str()) {
499            report.push(Finding::fatal(
500                "BR-CL-20",
501                Path::at_term(Group::DocumentCharge, i, BtId(105)),
502                format!("BT-105 {code} is not UNCL 7161"),
503            ));
504        }
505    }
506}
507
508const fn r(id: &'static str, text: &'static str, eval: fn(&Invoice, &mut Report)) -> Rule {
509    Rule {
510        id,
511        severity: Severity::Fatal,
512        text,
513        source: Source::ArtefactOnly,
514        eval,
515    }
516}
517
518pub static RULES: &[Rule] = &[
519    r(
520        "BR-CL-01",
521        "Document type code MUST be coded by the invoice and credit note related code lists of UNTDID 1001.",
522        br_cl_01,
523    ),
524    r(
525        "BR-CL-03",
526        "currencyID MUST be coded using ISO 4217 alpha-3 (wire @currencyID).",
527        br_cl_03,
528    ),
529    r(
530        "BR-CL-04",
531        "Invoice currency code MUST be coded using ISO 4217 alpha-3.",
532        br_cl_04,
533    ),
534    r(
535        "BR-CL-08",
536        "Invoice note subject code (BT-21) MUST be coded using UNCL 4451.",
537        br_cl_08,
538    ),
539    r(
540        "BR-CL-05",
541        "Tax accounting currency MUST be coded using ISO 4217 alpha-3.",
542        br_cl_05,
543    ),
544    r(
545        "BR-CL-14",
546        "Country codes MUST be coded using ISO 3166-1 alpha-2.",
547        br_cl_14,
548    ),
549    r(
550        "BR-CL-06",
551        "VAT point date code (BT-8) MUST be coded using UNCL 2005 (3, 35, 432).",
552        br_cl_06,
553    ),
554    r(
555        "BR-CL-13",
556        "Item classification scheme (BT-158-1) MUST be coded using UNCL 7143.",
557        br_cl_13,
558    ),
559    r(
560        "BR-CL-15",
561        "Item origin country (BT-159) MUST be coded using ISO 3166-1 alpha-2.",
562        br_cl_15,
563    ),
564    r(
565        "BR-CL-16",
566        "Payment means code MUST be coded using UNCL 4461.",
567        br_cl_16,
568    ),
569    r(
570        "BR-CL-19",
571        "Document allowance reason code MUST be coded using UNCL 5189.",
572        br_cl_19,
573    ),
574    r(
575        "BR-CL-20",
576        "Document charge reason code MUST be coded using UNCL 7161.",
577        br_cl_20,
578    ),
579    r(
580        "BR-CL-17",
581        "VAT category code (BT-118) MUST be coded using UNCL 5305 (VAT profiles only).",
582        br_cl_17,
583    ),
584    r(
585        "BR-CL-18",
586        "Invoiced item VAT category code (BT-151) MUST be coded using UNCL 5305 (VAT profiles only).",
587        br_cl_18,
588    ),
589    r(
590        "BR-CL-22",
591        "VAT exemption reason code MUST be coded using the VATEX list (case-insensitive).",
592        br_cl_22,
593    ),
594    r(
595        "BR-CL-23",
596        "Unit codes MUST be coded using UNECE Rec 20 (generated list). Rec 21 is not generated.",
597        br_cl_23,
598    ),
599    r(
600        "BR-CL-24",
601        "Attachment mime code MUST be from the allowed MIME list (subset).",
602        br_cl_24,
603    ),
604    r(
605        "BR-CL-25",
606        "Electronic address scheme MUST be from EAS (subset).",
607        br_cl_25,
608    ),
609    r(
610        "BR-CL-07",
611        "Object identifier identification scheme (BT-18 / BT-128) MUST be coded using UNTDID 1153.",
612        br_cl_07,
613    ),
614    r(
615        "BR-CL-10",
616        "Party identifier scheme MUST be ISO 6523 ICD (SEPA allowed on seller/payee).",
617        br_cl_10,
618    ),
619    r(
620        "BR-CL-11",
621        "Legal registration identifier scheme MUST be ISO 6523 ICD when present.",
622        br_cl_11,
623    ),
624    r(
625        "BR-CL-21",
626        "Item standard identifier scheme (BT-157) MUST be ISO 6523 ICD.",
627        br_cl_21,
628    ),
629    r(
630        "BR-CL-26",
631        "Deliver-to location identifier scheme MUST be ISO 6523 ICD.",
632        br_cl_26,
633    ),
634];
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::invoice::{Invoice, Party};
640    use crate::rules::explain;
641    use crate::validate;
642
643    #[test]
644    fn us_dollar_sign_fails_cl04_eur_passes() {
645        let mut inv = Invoice::blank(
646            Profile::En16931,
647            "1",
648            "US$",
649            Party::new("S", "DE"),
650            Party::new("B", "FR"),
651        );
652        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
653        inv.type_code = Some(crate::code::Code::new("380"));
654        inv.lines = vec![crate::invoice::Line::new(
655            "1",
656            "A",
657            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
658            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
659        )];
660        let report = validate(&inv);
661        assert!(
662            report.findings.iter().any(|f| f.id == "BR-CL-04"),
663            "{report}"
664        );
665        inv.currency = "EUR".into();
666        let report = validate(&inv);
667        assert!(
668            report.findings.iter().all(|f| f.id != "BR-CL-04"),
669            "{report}"
670        );
671        inv.currency = "XXX".into();
672        let report = validate(&inv);
673        assert!(
674            report.findings.iter().all(|f| f.id != "BR-CL-04"),
675            "{report}"
676        );
677    }
678
679    #[test]
680    fn br_cl_08_note_subject_4451() {
681        let mut inv = Invoice::blank(
682            Profile::En16931,
683            "1",
684            "EUR",
685            Party::new("S", "DE"),
686            Party::new("B", "FR"),
687        );
688        inv.notes.push(crate::invoice::InvoiceNote {
689            subject: Some(crate::code::Code::new("NOPE")),
690            text: "x".into(),
691        });
692        let report = validate(&inv);
693        assert!(
694            report.findings.iter().any(|f| f.id == "BR-CL-08"),
695            "{report}"
696        );
697        inv.notes[0].subject = Some(crate::code::Code::new("AAA"));
698        assert!(validate(&inv).findings.iter().all(|f| f.id != "BR-CL-08"));
699        inv.notes[0].subject = None;
700        assert!(validate(&inv).findings.iter().all(|f| f.id != "BR-CL-08"));
701        assert!(explain("BR-CL-08").unwrap().contains("4451"));
702        assert!(explain("BR-CL-03").unwrap().contains("currencyID"));
703    }
704
705    #[test]
706    fn invoice_381_fails_cl01() {
707        let mut inv = Invoice::blank(
708            Profile::En16931,
709            "1",
710            "EUR",
711            Party::new("S", "DE"),
712            Party::new("B", "FR"),
713        );
714        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
715        inv.type_code = Some(crate::code::Code::new("381"));
716        inv.kind = DocumentKind::Invoice;
717        inv.lines = vec![crate::invoice::Line::new(
718            "1",
719            "A",
720            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
721            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
722        )];
723        let report = validate(&inv);
724        assert!(
725            report.findings.iter().any(|f| f.id == "BR-CL-01"),
726            "{report}"
727        );
728    }
729
730    #[test]
731    fn artefact_pins_are_fully_qualified() {
732        assert_eq!(ARTEFACT_VERSION, "validation-1.3.16");
733        assert_eq!(PEPPOL_BIS_VERSION, "v3.0.20");
734        assert_eq!(PINT_MY_VERSION, "1.3.0");
735    }
736
737    #[test]
738    fn br_cl_07_rejects_non_1153_scheme() {
739        let mut inv = Invoice::blank(
740            Profile::En16931,
741            "1",
742            "EUR",
743            Party::new("S", "DE"),
744            Party::new("B", "FR"),
745        );
746        inv.invoiced_object = Some(crate::identifier::Identifier::schemed("X", "NOPE"));
747        let report = validate(&inv);
748        assert!(
749            report.findings.iter().any(|f| f.id == "BR-CL-07"),
750            "{report}"
751        );
752    }
753
754    #[test]
755    fn br_cl_21_binds_standard_id_not_item_id() {
756        let mut inv = Invoice::blank(
757            Profile::En16931,
758            "1",
759            "EUR",
760            Party::new("S", "DE"),
761            Party::new("B", "FR"),
762        );
763        let mut line = crate::invoice::Line::new(
764            "1",
765            "A",
766            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
767            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
768        );
769        line.item_id = Some(crate::identifier::Identifier::schemed("SKU", "FOO"));
770        line.standard_id = Some(crate::identifier::Identifier::schemed("GTIN", "FOO"));
771        inv.lines = vec![line];
772        let report = validate(&inv);
773        assert!(
774            report.findings.iter().any(|f| f.id == "BR-CL-21"),
775            "{report}"
776        );
777    }
778
779    #[test]
780    fn invoice_326_is_not_br_cl_01() {
781        let mut inv = Invoice::blank(
782            Profile::PeppolBis3,
783            "1",
784            "EUR",
785            {
786                let mut p = Party::new("S", "DE");
787                p.electronic_address = Some(crate::identifier::Identifier::schemed("1", "0088"));
788                p
789            },
790            {
791                let mut p = Party::new("B", "DE");
792                p.electronic_address = Some(crate::identifier::Identifier::schemed("2", "0088"));
793                p
794            },
795        );
796        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
797        inv.type_code = Some(crate::code::Code::new("326"));
798        inv.specification_id = Some(Profile::PEPPOL_BIS3_PREFIX.into());
799        inv.business_process = Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0".into());
800        let report = validate(&inv);
801        assert!(
802            report.findings.iter().all(|f| f.id != "BR-CL-01"),
803            "{report}"
804        );
805    }
806}