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