Skip to main content

core_invoice/
rules.rs

1//! Rule registry. [`crate::validate()`] and [`explain`] share one table.
2
3use crate::bt::{BtId, Group, Path};
4use crate::invoice::Invoice;
5use crate::numeric::Percentage;
6use crate::report::{Finding, Report, Severity, Source};
7
8/// One registered rule. `eval` is the only emitter of this `id`.
9#[derive(Clone, Copy)]
10pub struct Rule {
11    /// Registered id (`BR-02`, `PEPPOL-EN16931-R010`).
12    pub id: &'static str,
13    /// Fatal fails [`Report::ok`]; Warning and Info do not.
14    pub severity: Severity,
15    /// Authority text returned by [`explain`].
16    pub text: &'static str,
17    /// Provenance of `id`.
18    pub source: Source,
19    /// Semantic check. Pushes findings onto the report.
20    pub eval: fn(&Invoice, &mut Report),
21}
22
23/// Case-insensitive id match with numeric zero-padding (`br-2` = `BR-02`).
24pub fn matches_id(registered: &str, query: &str) -> bool {
25    let a = canonical(registered);
26    let b = canonical(query);
27    a.eq_ignore_ascii_case(&b)
28}
29
30fn canonical(id: &str) -> String {
31    let id = id.trim();
32    let Some((head, tail)) = id.rsplit_once('-') else {
33        return id.to_ascii_uppercase();
34    };
35    if tail.chars().all(|c| c.is_ascii_digit()) {
36        return format!("{}-{tail:0>2}", head.to_ascii_uppercase());
37    }
38    id.to_ascii_uppercase()
39}
40
41/// Authority text for `id`, or `None` if unregistered. Same table as [`crate::validate()`].
42pub fn explain(id: &str) -> Option<&'static str> {
43    if let Some(text) = catalogue()
44        .iter()
45        .find(|r| matches_id(r.id, id))
46        .map(|r| r.text)
47    {
48        return Some(text);
49    }
50    #[cfg(feature = "xrechnung")]
51    if let Some(r) = crate::xrechnung::RULES
52        .iter()
53        .find(|r| matches_id(r.id, id))
54    {
55        return Some(r.text);
56    }
57    None
58}
59
60/// CORE rules only. Peppol extras are `Profile::extra_rules`, not this list.
61pub fn core_rules() -> &'static [Rule] {
62    static CELL: std::sync::OnceLock<Vec<Rule>> = std::sync::OnceLock::new();
63    CELL.get_or_init(|| {
64        ALL.iter()
65            .copied()
66            .chain(crate::category::RULES.iter().copied())
67            .chain(crate::codes::RULES.iter().copied())
68            .chain(DEC.iter().copied())
69            .collect()
70    })
71}
72
73/// Markdown table of `catalogue()` × shipped profiles. Not a legal-validator claim.
74pub fn conformance_matrix() -> String {
75    use crate::profile::Profile;
76    let profiles = [
77        Profile::En16931,
78        Profile::PeppolBis3,
79        Profile::Pint,
80        Profile::PintMy,
81    ];
82    let mut s = String::from(
83        "# Rule matrix\n\nIds **we** emit. Fatal ids comparable to pinned ConnectingEurope / PINT-MY as evidenced by `task svrl`. Not OpenPEPPOL Valid (BIS pin is .sch). Not IRBM Valid.\n\nCORE runs on every profile. Extra rules are `Profile::extra_rules`.\n\n| id | en16931 | peppol | pint | pint-my |\n|---|---|---|---|---|\n",
84    );
85    for rule in catalogue() {
86        s.push_str("| ");
87        s.push_str(rule.id);
88        for p in profiles {
89            let core = crate::rules::core_rules().iter().any(|r| r.id == rule.id);
90            let extra = p.extra_rules().iter().any(|r| r.id == rule.id);
91            let cell = if core {
92                "CORE"
93            } else if extra {
94                "extra"
95            } else {
96                "—"
97            };
98            s.push_str(" | ");
99            s.push_str(cell);
100        }
101        s.push_str(" |\n");
102    }
103    s
104}
105
106/// Explain/rules dump: CORE plus every profile's extras so `explain PEPPOL-EN16931-R010` works.
107pub fn catalogue() -> &'static [Rule] {
108    static CELL: std::sync::OnceLock<Vec<Rule>> = std::sync::OnceLock::new();
109    CELL.get_or_init(|| {
110        #[allow(unused_mut)]
111        core_rules()
112            .iter()
113            .copied()
114            .chain(crate::peppol::RULES.iter().copied())
115            .collect()
116    })
117}
118
119fn spec_lookup(invoice: &Invoice, report: &mut Report) {
120    let Some(id) = invoice.specification_id.as_deref() else {
121        return;
122    };
123    if id.contains('*') {
124        return;
125    }
126    match crate::profile::Profile::for_specification_id(id) {
127        crate::profile::ProfileLookup::Unknown => {
128            report.push(Finding::fatal(
129                "CORE-SPEC-01",
130                Path::term(BtId(24)),
131                "Unrecognised specification identifier (BT-24)",
132            ));
133        }
134        crate::profile::ProfileLookup::WrongProcess | crate::profile::ProfileLookup::Profile(_) => {
135        }
136    }
137}
138
139fn core_process_01(invoice: &Invoice, report: &mut Report) {
140    let Some(id) = invoice.specification_id.as_deref() else {
141        return;
142    };
143    if matches!(
144        crate::profile::Profile::for_specification_id(id),
145        crate::profile::ProfileLookup::WrongProcess
146    ) {
147        report.push(Finding::fatal(
148            "CORE-PROCESS-01",
149            Path::term(BtId(24)),
150            "Specification identifier is a self-billing (or other) process; not billing",
151        ));
152    }
153}
154
155fn ibr_sr_63(invoice: &Invoice, report: &mut Report) {
156    let Some(id) = invoice.specification_id.as_deref() else {
157        return;
158    };
159    if id.contains('*') {
160        report.push(Finding::fatal(
161            "IBR-SR-63",
162            Path::term(BtId(24)),
163            "BT-24 shall not contain '*' (wildcard is an SMP capability, not an instance id)",
164        ));
165    }
166}
167
168fn br_01(invoice: &Invoice, report: &mut Report) {
169    if invoice
170        .specification_id
171        .as_deref()
172        .unwrap_or("")
173        .trim()
174        .is_empty()
175    {
176        report.push(Finding::fatal(
177            "BR-01",
178            Path::term(BtId(24)),
179            "An Invoice shall have a Specification identifier (BT-24)",
180        ));
181    }
182}
183
184fn br_03(invoice: &Invoice, report: &mut Report) {
185    if invoice.issue_date.is_none() {
186        report.push(Finding::fatal(
187            "BR-03",
188            Path::term(BtId(2)),
189            "An Invoice shall have an Invoice issue date (BT-2)",
190        ));
191    }
192}
193
194fn br_04(invoice: &Invoice, report: &mut Report) {
195    if invoice
196        .type_code
197        .as_ref()
198        .map(|c| c.is_empty())
199        .unwrap_or(true)
200    {
201        report.push(Finding::fatal(
202            "BR-04",
203            Path::term(BtId(3)),
204            "An Invoice shall have an Invoice type code (BT-3)",
205        ));
206    }
207}
208
209fn br_08(invoice: &Invoice, report: &mut Report) {
210    if invoice.seller.address.is_none() {
211        report.push(Finding::fatal(
212            "BR-08",
213            Path::group(Group::Seller),
214            "The Seller shall have a Seller postal address (BG-5)",
215        ));
216    }
217}
218
219fn br_10(invoice: &Invoice, report: &mut Report) {
220    if invoice.buyer.address.is_none() {
221        report.push(Finding::fatal(
222            "BR-10",
223            Path::group(Group::Buyer),
224            "The Buyer shall have a Buyer postal address (BG-8)",
225        ));
226    }
227}
228
229fn br_22(invoice: &Invoice, report: &mut Report) {
230    for (i, line) in invoice.lines.iter().enumerate() {
231        if line.quantity.is_none() {
232            report.push(Finding::fatal(
233                "BR-22",
234                Path::at_term(Group::Line, i, BtId(129)),
235                "Each Invoice line shall have an Invoiced quantity (BT-129)",
236            ));
237        }
238    }
239}
240
241fn br_23(invoice: &Invoice, report: &mut Report) {
242    // BR-23: exists(@unitCode) independent of quantity. Missing qty still fires BR-22 and BR-23.
243    for (i, line) in invoice.lines.iter().enumerate() {
244        if line.unit.is_none() {
245            report.push(Finding::fatal(
246                "BR-23",
247                Path::at_term(Group::Line, i, BtId(130)),
248                "An Invoice line shall have an Invoiced quantity unit of measure code (BT-130)",
249            ));
250        }
251    }
252}
253
254fn br_24(_invoice: &Invoice, _report: &mut Report) {
255    // BR-24: Line.net is not Option (BT-131); type-retired. explain still works.
256}
257
258fn br_17(invoice: &Invoice, report: &mut Report) {
259    // BR-17: Payee name (BT-59) if BG-10 present.
260    if let Some(p) = invoice.payee.as_ref()
261        && p.name.trim().is_empty()
262    {
263        report.push(Finding::fatal(
264            "BR-17",
265            Path::term(BtId(59)),
266            "Payee name (BT-59) shall be provided if Payee (BG-10) is used",
267        ));
268    }
269}
270
271fn br_18(invoice: &Invoice, report: &mut Report) {
272    if let Some(tr) = invoice.tax_representative.as_ref()
273        && tr.name.trim().is_empty()
274    {
275        report.push(Finding::fatal(
276            "BR-18",
277            Path::term(BtId(62)),
278            "Seller tax representative name (BT-62) shall be provided if BG-11 is used",
279        ));
280    }
281}
282
283fn br_20(invoice: &Invoice, report: &mut Report) {
284    if let Some(tr) = invoice.tax_representative.as_ref() {
285        let cc = tr
286            .address
287            .as_ref()
288            .and_then(|a| a.country.as_ref())
289            .map(|c| c.as_str().trim())
290            .unwrap_or("");
291        if cc.is_empty() {
292            report.push(Finding::fatal(
293                "BR-20",
294                Path::term(BtId(69)),
295                "Tax representative country (BT-69) shall be provided if BG-11 is used",
296            ));
297        }
298    }
299}
300
301fn br_56(invoice: &Invoice, report: &mut Report) {
302    if let Some(tr) = invoice.tax_representative.as_ref()
303        && tr.vat_identifier.is_none()
304    {
305        report.push(Finding::fatal(
306            "BR-56",
307            Path::term(BtId(63)),
308            "Seller tax representative VAT identifier (BT-63) shall be provided if BG-11 is used",
309        ));
310    }
311}
312
313fn br_29(invoice: &Invoice, report: &mut Report) {
314    if let Some(p) = invoice.period.as_ref()
315        && let (Some(s), Some(e)) = (p.start, p.end)
316        && e < s
317    {
318        report.push(Finding::fatal(
319            "BR-29",
320            Path::term(BtId(74)),
321            "Invoicing period end date shall be on or after start date",
322        ));
323    }
324}
325
326fn br_30(invoice: &Invoice, report: &mut Report) {
327    for (i, line) in invoice.lines.iter().enumerate() {
328        if let Some(p) = line.period.as_ref()
329            && let (Some(s), Some(e)) = (p.start, p.end)
330            && e < s
331        {
332            report.push(Finding::fatal(
333                "BR-30",
334                Path::at_term(Group::Line, i, BtId(135)),
335                "Invoice line period end date shall be on or after start date",
336            ));
337        }
338    }
339}
340
341fn br_52(invoice: &Invoice, report: &mut Report) {
342    for (i, d) in invoice.supporting_documents.iter().enumerate() {
343        if d.id.as_str().trim().is_empty() {
344            report.push(Finding::fatal(
345                "BR-52",
346                Path::at_term(Group::Attachment, i, BtId(122)),
347                "Each additional supporting document shall contain a reference (BT-122)",
348            ));
349        }
350    }
351}
352
353fn br_54(invoice: &Invoice, report: &mut Report) {
354    for (i, line) in invoice.lines.iter().enumerate() {
355        for a in &line.attributes {
356            if a.name.trim().is_empty() || a.value.trim().is_empty() {
357                report.push(Finding::fatal(
358                    "BR-54",
359                    Path::at_term(Group::Line, i, BtId(160)),
360                    "Each item attribute (BG-32) shall contain name (BT-160) and value (BT-161)",
361                ));
362            }
363        }
364    }
365}
366
367fn br_55(invoice: &Invoice, report: &mut Report) {
368    for (i, p) in invoice.preceding.iter().enumerate() {
369        if p.reference.as_str().trim().is_empty() {
370            report.push(Finding::fatal(
371                "BR-55",
372                Path::at_term(Group::Document, i, BtId(25)),
373                "Each preceding invoice reference (BG-3) shall contain BT-25",
374            ));
375        }
376    }
377}
378
379fn br_57(invoice: &Invoice, report: &mut Report) {
380    let Some(d) = invoice.delivery.as_ref() else {
381        return;
382    };
383    let Some(addr) = d.address.as_ref() else {
384        return;
385    };
386    let cc = addr
387        .country
388        .as_ref()
389        .map(|c| c.as_str().trim())
390        .unwrap_or("");
391    if cc.is_empty() {
392        report.push(Finding::fatal(
393            "BR-57",
394            Path::term(BtId(80)),
395            "Each deliver-to address (BG-15) shall contain country (BT-80)",
396        ));
397    }
398}
399
400fn br_62(invoice: &Invoice, report: &mut Report) {
401    if let Some(ep) = invoice.seller.electronic_address.as_ref()
402        && ep.scheme.as_deref().unwrap_or("").trim().is_empty()
403    {
404        report.push(Finding::fatal(
405            "BR-62",
406            Path::group_term(Group::Seller, BtId(34)),
407            "Seller electronic address (BT-34) shall have a scheme",
408        ));
409    }
410}
411
412fn br_63(invoice: &Invoice, report: &mut Report) {
413    if let Some(ep) = invoice.buyer.electronic_address.as_ref()
414        && ep.scheme.as_deref().unwrap_or("").trim().is_empty()
415    {
416        report.push(Finding::fatal(
417            "BR-63",
418            Path::group_term(Group::Buyer, BtId(49)),
419            "Buyer electronic address (BT-49) shall have a scheme",
420        ));
421    }
422}
423
424fn br_64(invoice: &Invoice, report: &mut Report) {
425    for (i, line) in invoice.lines.iter().enumerate() {
426        if let Some(id) = line.standard_id.as_ref()
427            && id.scheme.as_deref().unwrap_or("").trim().is_empty()
428        {
429            report.push(Finding::fatal(
430                "BR-64",
431                Path::at_term(Group::Line, i, BtId(157)),
432                "Item standard identifier (BT-157) shall have a scheme",
433            ));
434        }
435    }
436}
437
438fn br_65(invoice: &Invoice, report: &mut Report) {
439    for (i, line) in invoice.lines.iter().enumerate() {
440        for cl in &line.classifications {
441            if cl.scheme.as_deref().unwrap_or("").trim().is_empty() {
442                report.push(Finding::fatal(
443                    "BR-65",
444                    Path::at_term(Group::Line, i, BtId(158)),
445                    "Item classification identifier (BT-158) shall have a scheme (listID)",
446                ));
447            }
448        }
449    }
450}
451
452fn br_co_09(invoice: &Invoice, report: &mut Report) {
453    // BR-CO-09: VAT ids have ISO 3166 prefix (Greece EL). Must not run on PINT-MY TIN.
454    if invoice.profile == crate::profile::Profile::PintMy {
455        return;
456    }
457    let ids = [
458        invoice.seller.vat_identifier.as_ref(),
459        invoice.buyer.vat_identifier.as_ref(),
460        invoice
461            .tax_representative
462            .as_ref()
463            .and_then(|t| t.vat_identifier.as_ref()),
464    ];
465    for id in ids.into_iter().flatten() {
466        let v = id.value.trim();
467        if v.len() < 2 {
468            report.push(Finding::fatal(
469                "BR-CO-09",
470                Path::term(BtId(31)),
471                "VAT identifier shall have an ISO 3166-1 alpha-2 prefix (Greece EL)",
472            ));
473            continue;
474        }
475        let prefix = &v[..2];
476        let ok = prefix.eq_ignore_ascii_case("EL") || crate::codes::country(prefix);
477        if !ok {
478            report.push(Finding::fatal(
479                "BR-CO-09",
480                Path::term(BtId(31)),
481                "VAT identifier shall have an ISO 3166-1 alpha-2 prefix (Greece EL)",
482            ));
483        }
484    }
485}
486
487fn br_co_19(invoice: &Invoice, report: &mut Report) {
488    if let Some(p) = invoice.period.as_ref()
489        && p.start.is_none()
490        && p.end.is_none()
491        && invoice.tax_point_code.is_none()
492    {
493        report.push(Finding::fatal(
494            "BR-CO-19",
495            Path::term(BtId(73)),
496            "If invoicing period (BG-14) is used, start or end shall be present",
497        ));
498    }
499}
500
501fn br_co_20(invoice: &Invoice, report: &mut Report) {
502    for (i, line) in invoice.lines.iter().enumerate() {
503        if let Some(p) = line.period.as_ref()
504            && p.start.is_none()
505            && p.end.is_none()
506        {
507            report.push(Finding::fatal(
508                "BR-CO-20",
509                Path::at_term(Group::Line, i, BtId(134)),
510                "If invoice line period (BG-26) is used, start or end shall be present",
511            ));
512        }
513    }
514}
515
516fn reason_or_code(reason: Option<&str>, code: Option<&crate::code::Code>) -> bool {
517    reason.is_some_and(|s| !s.trim().is_empty())
518        || code.is_some_and(|c| !c.as_str().trim().is_empty())
519}
520
521fn br_co_21(invoice: &Invoice, report: &mut Report) {
522    for (i, a) in invoice.document_allowances.iter().enumerate() {
523        if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
524            report.push(Finding::fatal(
525                "BR-CO-21",
526                Path::at_term(Group::DocumentAllowance, i, BtId(97)),
527                "Document level allowance shall have a reason or reason code",
528            ));
529        }
530    }
531}
532
533fn br_co_22(invoice: &Invoice, report: &mut Report) {
534    for (i, a) in invoice.document_charges.iter().enumerate() {
535        if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
536            report.push(Finding::fatal(
537                "BR-CO-22",
538                Path::at_term(Group::DocumentCharge, i, BtId(104)),
539                "Document level charge shall have a reason or reason code",
540            ));
541        }
542    }
543}
544
545fn br_co_23(invoice: &Invoice, report: &mut Report) {
546    for (i, line) in invoice.lines.iter().enumerate() {
547        for a in &line.allowances {
548            if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
549                report.push(Finding::fatal(
550                    "BR-CO-23",
551                    Path::at_term(Group::Line, i, BtId(139)),
552                    "Invoice line allowance shall have a reason or reason code",
553                ));
554            }
555        }
556    }
557}
558
559fn br_co_24(invoice: &Invoice, report: &mut Report) {
560    for (i, line) in invoice.lines.iter().enumerate() {
561        for a in &line.charges {
562            if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
563                report.push(Finding::fatal(
564                    "BR-CO-24",
565                    Path::at_term(Group::Line, i, BtId(144)),
566                    "Invoice line charge shall have a reason or reason code",
567                ));
568            }
569        }
570    }
571}
572
573fn br_12(invoice: &Invoice, report: &mut Report) {
574    if invoice.totals.as_ref().and_then(|t| t.line_net).is_none() {
575        report.push(Finding::fatal(
576            "BR-12",
577            Path::term(BtId(106)),
578            "An Invoice shall have the Sum of Invoice line net amount (BT-106)",
579        ));
580    }
581}
582
583fn br_13(invoice: &Invoice, report: &mut Report) {
584    if invoice
585        .totals
586        .as_ref()
587        .and_then(|t| t.without_tax)
588        .is_none()
589    {
590        report.push(Finding::fatal(
591            "BR-13",
592            Path::term(BtId(109)),
593            "An Invoice shall have the Invoice total amount without VAT (BT-109)",
594        ));
595    }
596}
597
598fn br_14(invoice: &Invoice, report: &mut Report) {
599    if invoice.totals.as_ref().and_then(|t| t.with_tax).is_none() {
600        report.push(Finding::fatal(
601            "BR-14",
602            Path::term(BtId(112)),
603            "An Invoice shall have the Invoice total amount with VAT (BT-112)",
604        ));
605    }
606}
607
608fn br_15(invoice: &Invoice, report: &mut Report) {
609    // BR-15: PayableAmount (BT-115). Missing BG-22 or missing PayableAmount both fire.
610    // A stated 0.00 is present; do not treat it as absent.
611    if invoice.totals.as_ref().and_then(|t| t.payable).is_none() {
612        report.push(Finding::fatal(
613            "BR-15",
614            Path::term(BtId(115)),
615            "An Invoice shall have the Amount due for payment (BT-115)",
616        ));
617    }
618}
619
620fn br_19(invoice: &Invoice, report: &mut Report) {
621    // BR-19: Seller tax representative postal address (BG-12) if BG-11 is used.
622    if let Some(tr) = invoice.tax_representative.as_ref()
623        && tr.address.is_none()
624    {
625        report.push(Finding::fatal(
626            "BR-19",
627            Path::term(BtId(64)),
628            "The Seller tax representative postal address (BG-12) shall be provided if BG-11 is used",
629        ));
630    }
631}
632
633fn br_31(_invoice: &Invoice, _report: &mut Report) {
634    // BR-31: AllowanceCharge.amount is not Option (BT-92); type-retired.
635}
636
637fn br_32(invoice: &Invoice, report: &mut Report) {
638    for (i, a) in invoice.document_allowances.iter().enumerate() {
639        if a.tax
640            .as_ref()
641            .map(|t| t.code.trim())
642            .unwrap_or("")
643            .is_empty()
644        {
645            report.push(Finding::fatal(
646                "BR-32",
647                Path::at_term(Group::DocumentAllowance, i, BtId(95)),
648                "Each Document level allowance (BG-20) shall have a VAT category code (BT-95)",
649            ));
650        }
651    }
652}
653
654fn br_33(invoice: &Invoice, report: &mut Report) {
655    for (i, a) in invoice.document_allowances.iter().enumerate() {
656        if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
657            report.push(Finding::fatal(
658                "BR-33",
659                Path::at_term(Group::DocumentAllowance, i, BtId(97)),
660                "Each Document level allowance (BG-20) shall have a reason (BT-97) or reason code (BT-98)",
661            ));
662        }
663    }
664}
665
666fn br_36(_invoice: &Invoice, _report: &mut Report) {
667    // BR-36: charge amount is not Option (BT-99); type-retired.
668}
669
670fn br_37(invoice: &Invoice, report: &mut Report) {
671    for (i, a) in invoice.document_charges.iter().enumerate() {
672        if a.tax
673            .as_ref()
674            .map(|t| t.code.trim())
675            .unwrap_or("")
676            .is_empty()
677        {
678            report.push(Finding::fatal(
679                "BR-37",
680                Path::at_term(Group::DocumentCharge, i, BtId(102)),
681                "Each Document level charge (BG-21) shall have a VAT category code (BT-102)",
682            ));
683        }
684    }
685}
686
687fn br_38(invoice: &Invoice, report: &mut Report) {
688    for (i, a) in invoice.document_charges.iter().enumerate() {
689        if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
690            report.push(Finding::fatal(
691                "BR-38",
692                Path::at_term(Group::DocumentCharge, i, BtId(104)),
693                "Each Document level charge (BG-21) shall have a reason (BT-104) or reason code (BT-105)",
694            ));
695        }
696    }
697}
698
699fn br_41(_invoice: &Invoice, _report: &mut Report) {
700    // BR-41: line allowance amount is not Option (BT-136); type-retired.
701}
702
703fn br_42(invoice: &Invoice, report: &mut Report) {
704    for (i, line) in invoice.lines.iter().enumerate() {
705        for a in &line.allowances {
706            if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
707                report.push(Finding::fatal(
708                    "BR-42",
709                    Path::at_term(Group::Line, i, BtId(139)),
710                    "Each Invoice line allowance (BG-27) shall have a reason or reason code",
711                ));
712            }
713        }
714    }
715}
716
717fn br_43(_invoice: &Invoice, _report: &mut Report) {
718    // BR-43: line charge amount is not Option (BT-141); type-retired.
719}
720
721fn br_44(invoice: &Invoice, report: &mut Report) {
722    for (i, line) in invoice.lines.iter().enumerate() {
723        for a in &line.charges {
724            if !reason_or_code(a.reason.as_deref(), a.reason_code.as_ref()) {
725                report.push(Finding::fatal(
726                    "BR-44",
727                    Path::at_term(Group::Line, i, BtId(144)),
728                    "Each Invoice line charge shall have a reason or reason code",
729                ));
730            }
731        }
732    }
733}
734
735fn br_45(_invoice: &Invoice, _report: &mut Report) {
736    // BR-45: TaxBreakdown.taxable is not Option (BT-116); type-retired.
737}
738
739fn br_46(_invoice: &Invoice, _report: &mut Report) {
740    // BR-46: TaxBreakdown.tax is not Option (BT-117); type-retired.
741}
742
743fn br_47(invoice: &Invoice, report: &mut Report) {
744    for (i, row) in invoice.tax_breakdown.iter().enumerate() {
745        if row.category.as_str().trim().is_empty() {
746            report.push(Finding::fatal(
747                "BR-47",
748                Path::at_term(Group::TaxBreakdown, i, BtId(118)),
749                "Each VAT breakdown (BG-23) shall be defined through a VAT category code (BT-118)",
750            ));
751        }
752    }
753}
754
755fn br_48(invoice: &Invoice, report: &mut Report) {
756    for (i, row) in invoice.tax_breakdown.iter().enumerate() {
757        let cat = row.category.as_str();
758        // EN O has no BT-119. TTX has no IBT-119 (ALIGNED-IBRP-048).
759        if cat == "O" || cat == "TTX" || row.scheme.eq_ignore_ascii_case("AAL") {
760            continue;
761        }
762        if row.rate.is_none() {
763            report.push(Finding::fatal(
764                "BR-48",
765                Path::at_term(Group::TaxBreakdown, i, BtId(119)),
766                "Each VAT breakdown (BG-23) shall have a VAT category rate (BT-119), except if not subject to VAT",
767            ));
768        }
769    }
770}
771
772fn br_49(invoice: &Invoice, report: &mut Report) {
773    let Some(pay) = invoice.payment.as_ref() else {
774        return;
775    };
776    if pay
777        .means_code
778        .as_ref()
779        .map(|c| c.as_str().trim().is_empty())
780        .unwrap_or(true)
781    {
782        report.push(Finding::fatal(
783            "BR-49",
784            Path::term(BtId(81)),
785            "A Payment instruction (BG-16) shall specify the Payment means type code (BT-81)",
786        ));
787    }
788}
789
790fn br_50(invoice: &Invoice, report: &mut Report) {
791    let Some(pay) = invoice.payment.as_ref() else {
792        return;
793    };
794    let Some(crate::payment::PaymentMeans::CreditTransfer(accts)) = pay.means.as_ref() else {
795        return;
796    };
797    if accts.is_empty() || accts.iter().any(|a| a.account_id.value.trim().is_empty()) {
798        report.push(Finding::fatal(
799            "BR-50",
800            Path::term(BtId(84)),
801            "A Payment account identifier (BT-84) shall be present if Credit transfer (BG-17) is used",
802        ));
803    }
804}
805
806fn br_61(invoice: &Invoice, report: &mut Report) {
807    let Some(pay) = invoice.payment.as_ref() else {
808        return;
809    };
810    let code = pay
811        .means_code
812        .as_ref()
813        .map(|c| c.as_str().trim())
814        .unwrap_or("");
815    if code != "30" && code != "58" {
816        return;
817    }
818    let has_account = matches!(
819        pay.means.as_ref(),
820        Some(crate::payment::PaymentMeans::CreditTransfer(a))
821            if a.iter().any(|x| !x.account_id.value.trim().is_empty())
822    );
823    if !has_account {
824        report.push(Finding::fatal(
825            "BR-61",
826            Path::term(BtId(84)),
827            "If BT-81 is 30 or 58 (credit transfer), the Payment account identifier (BT-84) shall be present",
828        ));
829    }
830}
831
832fn br_co_26(invoice: &Invoice, report: &mut Report) {
833    // BR-CO-26: BT-29 (not SEPA) and/or BT-30 and/or BT-31. Skip Pint/PintMy (IBR-02/04).
834    if matches!(
835        invoice.profile,
836        crate::profile::Profile::Pint | crate::profile::Profile::PintMy
837    ) {
838        return;
839    }
840    let p = &invoice.seller;
841    let vat = p
842        .vat_identifier
843        .as_ref()
844        .is_some_and(|i| !i.value.trim().is_empty());
845    let legal = p
846        .legal_registration
847        .as_ref()
848        .is_some_and(|i| !i.value.trim().is_empty());
849    let ident = p
850        .identifiers
851        .iter()
852        .any(|i| i.scheme.as_deref() != Some("SEPA") && !i.value.trim().is_empty());
853    if !(vat || legal || ident) {
854        report.push(Finding::fatal(
855            "BR-CO-26",
856            Path::group_term(Group::Seller, BtId(29)),
857            "Seller identifier (BT-29), legal registration (BT-30) and/or VAT identifier (BT-31) shall be present",
858        ));
859    }
860}
861
862fn br_26(invoice: &Invoice, report: &mut Report) {
863    // BR-26: Item net price (BT-146) present (UBL Schematron).
864    for (i, line) in invoice.lines.iter().enumerate() {
865        if line.price.is_none() {
866            report.push(Finding::fatal(
867                "BR-26",
868                Path::at_term(Group::Line, i, BtId(146)),
869                "Each Invoice line shall contain the Item net price (BT-146)",
870            ));
871        }
872    }
873}
874
875fn br_27(invoice: &Invoice, report: &mut Report) {
876    // BR-27: Item net price (BT-146) shall NOT be negative.
877    for (i, line) in invoice.lines.iter().enumerate() {
878        if let Some(price) = line.price.as_ref()
879            && price.net.raw().is_sign_negative()
880        {
881            report.push(Finding::fatal(
882                "BR-27",
883                Path::at_term(Group::Line, i, BtId(146)),
884                "The Item net price (BT-146) shall NOT be negative",
885            ));
886        }
887    }
888}
889
890fn br_28(invoice: &Invoice, report: &mut Report) {
891    // BR-28: Item gross price (BT-148) shall NOT be negative.
892    for (i, line) in invoice.lines.iter().enumerate() {
893        if let Some(g) = line.price.as_ref().and_then(|p| p.gross)
894            && g.raw().is_sign_negative()
895        {
896            report.push(Finding::fatal(
897                "BR-28",
898                Path::at_term(Group::Line, i, BtId(148)),
899                "The Item gross price (BT-148) shall NOT be negative",
900            ));
901        }
902    }
903}
904
905fn br_co_03(invoice: &Invoice, report: &mut Report) {
906    // BR-CO-03: BT-7 and BT-8 are mutually exclusive.
907    if invoice.tax_point_date.is_some() && invoice.tax_point_code.is_some() {
908        report.push(Finding::fatal(
909            "BR-CO-03",
910            Path::term(BtId(7)),
911            "Value added tax point date (BT-7) and Value added tax point date code (BT-8) are mutually exclusive",
912        ));
913    }
914}
915
916fn br_51(invoice: &Invoice, report: &mut Report) {
917    // BR-51 is the sole core Warning: PAN (BT-87) at most 10 digits.
918    let Some(crate::payment::PaymentMeans::Card(card)) =
919        invoice.payment.as_ref().and_then(|p| p.means.as_ref())
920    else {
921        return;
922    };
923    if card.pan.chars().filter(|c| c.is_ascii_digit()).count() > 10 {
924        report.push(Finding::warning(
925            "BR-51",
926            Path::term(BtId(87)),
927            "An invoice should never include a full card primary account number (BT-87)",
928        ));
929    }
930}
931
932fn br_co_nlp(_invoice: &Invoice, _report: &mut Report) {
933    // BR-CO-05…08: artefact test is true() (NLP). Do not invent a reason-code ontology.
934}
935
936fn br_09(invoice: &Invoice, report: &mut Report) {
937    if invoice.seller.country().trim().is_empty() {
938        report.push(Finding::fatal(
939            "BR-09",
940            Path::term(BtId(40)),
941            "The Seller postal address shall contain a Seller country code (BT-40)",
942        ));
943    }
944}
945
946fn br_11(invoice: &Invoice, report: &mut Report) {
947    if invoice.buyer.country().trim().is_empty() {
948        report.push(Finding::fatal(
949            "BR-11",
950            Path::term(BtId(55)),
951            "The Buyer postal address shall contain a Buyer country code (BT-55)",
952        ));
953    }
954}
955
956fn br_21(invoice: &Invoice, report: &mut Report) {
957    for (i, line) in invoice.lines.iter().enumerate() {
958        if line.id.trim().is_empty() {
959            report.push(Finding::fatal(
960                "BR-21",
961                Path::at_term(Group::Line, i, BtId(126)),
962                "Each Invoice line shall have an Invoice line identifier (BT-126)",
963            ));
964        }
965    }
966}
967
968fn br_25(invoice: &Invoice, report: &mut Report) {
969    for (i, line) in invoice.lines.iter().enumerate() {
970        if line.name.trim().is_empty() {
971            report.push(Finding::fatal(
972                "BR-25",
973                Path::at_term(Group::Line, i, BtId(153)),
974                "Each Invoice line shall have an Item name (BT-153)",
975            ));
976        }
977    }
978}
979
980fn br_02(invoice: &Invoice, report: &mut Report) {
981    if invoice.number.trim().is_empty() {
982        report.push(Finding::fatal(
983            "BR-02",
984            Path::term(BtId(1)),
985            "Invoice number (BT-1) shall be present",
986        ));
987    }
988}
989
990fn br_05(invoice: &Invoice, report: &mut Report) {
991    if invoice.currency.trim().is_empty() {
992        report.push(Finding::fatal(
993            "BR-05",
994            Path::term(BtId(5)),
995            "Invoice currency code (BT-5) shall be present",
996        ));
997    }
998}
999
1000fn br_53(invoice: &Invoice, report: &mut Report) {
1001    // BR-53 artefact: every TaxCurrencyCode (BT-6) has a TaxTotal/TaxAmount @currencyID of that code.
1002    // When BT-6 equals BT-5, the document TaxTotal (BT-110) satisfies it. BT-111 is a second
1003    // TaxTotal only when the currencies differ. Never derive BT-111.
1004    let Some(tax_ccy) = invoice
1005        .tax_currency
1006        .as_ref()
1007        .map(|c| c.as_str())
1008        .filter(|c| !c.trim().is_empty())
1009    else {
1010        return;
1011    };
1012    let totals = invoice.totals.as_ref();
1013    let has_amount = if tax_ccy.eq_ignore_ascii_case(&invoice.currency) {
1014        totals.and_then(|t| t.tax_total).is_some()
1015    } else {
1016        totals.and_then(|t| t.tax_total_accounting).is_some()
1017    };
1018    if !has_amount {
1019        report.push(Finding::fatal(
1020            "BR-53",
1021            Path::term(BtId(111)),
1022            "If the VAT accounting currency code (BT-6) is present, then a TaxAmount in that currency shall be provided",
1023        ));
1024    }
1025}
1026
1027fn br_06(invoice: &Invoice, report: &mut Report) {
1028    if invoice.seller.name.trim().is_empty() {
1029        report.push(Finding::fatal(
1030            "BR-06",
1031            Path::term(BtId(27)),
1032            "Seller name (BT-27) shall be present",
1033        ));
1034    }
1035}
1036
1037fn br_07(invoice: &Invoice, report: &mut Report) {
1038    if invoice.buyer.name.trim().is_empty() {
1039        report.push(Finding::fatal(
1040            "BR-07",
1041            Path::term(BtId(44)),
1042            "Buyer name (BT-44) shall be present",
1043        ));
1044    }
1045}
1046
1047fn br_16(invoice: &Invoice, report: &mut Report) {
1048    if invoice.lines.is_empty() {
1049        report.push(Finding::fatal(
1050            "BR-16",
1051            Path::group(Group::Line),
1052            "An invoice shall have at least one Invoice line (BG-25)",
1053        ));
1054    }
1055}
1056
1057fn br_co_04(invoice: &Invoice, report: &mut Report) {
1058    // Missing BT-151 is a finding (BR-CO-04 / line tax presence), not category S.
1059    for (i, line) in invoice.lines.iter().enumerate() {
1060        if line.tax.code.trim().is_empty() {
1061            report.push(Finding::fatal(
1062                "BR-CO-04",
1063                Path::at_term(Group::Line, i, BtId(151)),
1064                "Invoiced item VAT category code (BT-151) shall be present",
1065            ));
1066        }
1067    }
1068}
1069
1070// PINT-TAX: sibling profiles; PintMy.tax_systems is SST only (wire TaxScheme VAT/AAL).
1071fn pint_tax(invoice: &Invoice, report: &mut Report) {
1072    if matches!(invoice.profile, crate::profile::Profile::Unknown) {
1073        return;
1074    }
1075    for (i, line) in invoice.lines.iter().enumerate() {
1076        if line.tax.code.trim().is_empty() {
1077            continue;
1078        }
1079        if !invoice.profile.allows(line.tax.system) {
1080            report.push(Finding::fatal(
1081                "PINT-TAX",
1082                Path::at_term(Group::Line, i, BtId(151)),
1083                format!(
1084                    "Tax system {} is not allowed on profile {}",
1085                    line.tax.system.as_str(),
1086                    invoice.profile.slug()
1087                ),
1088            ));
1089        }
1090    }
1091}
1092
1093fn pint_my_only(invoice: &Invoice) -> bool {
1094    invoice.profile == crate::profile::Profile::PintMy
1095}
1096
1097fn ibr_02_my(invoice: &Invoice, report: &mut Report) {
1098    if !pint_my_only(invoice) {
1099        return;
1100    }
1101    if invoice.seller.legal_registration.is_none() {
1102        report.push(Finding::fatal(
1103            "IBR-02-MY",
1104            Path::term(BtId(30)),
1105            "Seller legal registration identifier (BRN) shall be present",
1106        ));
1107    }
1108}
1109
1110fn ibr_03_my(invoice: &Invoice, report: &mut Report) {
1111    if !pint_my_only(invoice) {
1112        return;
1113    }
1114    if invoice.buyer.legal_registration.is_none() {
1115        report.push(Finding::fatal(
1116            "IBR-03-MY",
1117            Path::term(BtId(47)),
1118            "Buyer legal registration identifier (BRN) shall be present",
1119        ));
1120    }
1121}
1122
1123fn ibr_04_my(invoice: &Invoice, report: &mut Report) {
1124    if !pint_my_only(invoice) {
1125        return;
1126    }
1127    if invoice.seller.tax_registration.is_none() {
1128        report.push(Finding::fatal(
1129            "IBR-04-MY",
1130            Path::term(BtId(32)),
1131            "Seller TIN (tax registration) shall be present",
1132        ));
1133    }
1134}
1135
1136fn ibr_cl_05_my(invoice: &Invoice, report: &mut Report) {
1137    // IBR-CL-05-MY: BT-6 ⇒ MYR. Not BT-5. Not IRBM.
1138    if !pint_my_only(invoice) {
1139        return;
1140    }
1141    let Some(ccy) = invoice.tax_currency.as_ref() else {
1142        return;
1143    };
1144    if !ccy.as_str().eq_ignore_ascii_case("MYR") {
1145        report.push(Finding::fatal(
1146            "IBR-CL-05-MY",
1147            Path::term(BtId(6)),
1148            "If tax currency (BT-6 / IBT-006) is present it shall be MYR",
1149        ));
1150    }
1151}
1152
1153fn aligned_ibrp_cl_01_my(invoice: &Invoice, report: &mut Report) {
1154    if !pint_my_only(invoice) {
1155        return;
1156    }
1157    for (i, line) in invoice.lines.iter().enumerate() {
1158        if line.tax.code.trim().is_empty() {
1159            continue;
1160        }
1161        if !crate::tax::pint_my_category(&line.tax.code) {
1162            report.push(Finding::fatal(
1163                "ALIGNED-IBRP-CL-01-MY",
1164                Path::at_term(Group::Line, i, BtId(151)),
1165                format!(
1166                    "Tax category {} is not a PINT-MY code (SA SE HVG LVG TTX E O)",
1167                    line.tax.code
1168                ),
1169            ));
1170        }
1171    }
1172}
1173
1174fn totals_of(invoice: &Invoice) -> Option<&crate::invoice::DocumentTotals> {
1175    invoice.totals.as_ref()
1176}
1177
1178fn overflow(report: &mut Report, id: &'static str, term: u16, label: &str) {
1179    report.push(Finding::fatal(
1180        id,
1181        Path::group_term(Group::Totals, BtId(term)),
1182        format!("{label} overflowed; amounts are not representable"),
1183    ));
1184}
1185
1186fn br_co_10(invoice: &Invoice, report: &mut Report) {
1187    let Some(totals) = totals_of(invoice) else {
1188        return;
1189    };
1190    let Some(expected) =
1191        crate::amount::InvoiceAmount::checked_sum(invoice.lines.iter().map(|l| l.net))
1192    else {
1193        overflow(report, "BR-CO-10", 106, "BT-106");
1194        return;
1195    };
1196    match totals.line_net {
1197        Some(stated) if stated != expected => report.push(Finding::fatal(
1198            "BR-CO-10",
1199            Path::group_term(Group::Totals, BtId(106)),
1200            format!("BT-106 {stated} ≠ Σ BT-131 {expected}"),
1201        )),
1202        None => report.push(Finding::fatal(
1203            "BR-CO-10",
1204            Path::group_term(Group::Totals, BtId(106)),
1205            format!("BT-106 is absent; expected {expected}"),
1206        )),
1207        _ => {}
1208    }
1209}
1210
1211fn br_co_11(invoice: &Invoice, report: &mut Report) {
1212    let Some(totals) = totals_of(invoice) else {
1213        return;
1214    };
1215    let path = Path::group_term(Group::Totals, BtId(107));
1216    let Some(expected) = crate::amount::InvoiceAmount::checked_sum(
1217        invoice.document_allowances.iter().map(|a| a.amount),
1218    ) else {
1219        overflow(report, "BR-CO-11", 107, "BT-107");
1220        return;
1221    };
1222    match (
1223        invoice.document_allowances.is_empty(),
1224        totals.allowance_total,
1225    ) {
1226        (true, None) => {}
1227        // BR-CO-11 artefact: present BT-107 = Σ BG-20. Empty sum is 0, so 0 with no BG-20 is valid.
1228        (true, Some(stated)) if stated.is_zero() => {}
1229        (true, Some(stated)) => report.push(Finding::fatal(
1230            "BR-CO-11",
1231            path,
1232            format!("BT-107 {stated} ≠ Σ BT-92 0.00 (no BG-20)"),
1233        )),
1234        (false, None) => report.push(Finding::fatal(
1235            "BR-CO-11",
1236            path,
1237            format!("BT-107 is absent; expected Σ BT-92 {expected}"),
1238        )),
1239        (false, Some(stated)) if stated != expected => report.push(Finding::fatal(
1240            "BR-CO-11",
1241            path,
1242            format!("BT-107 {stated} ≠ Σ BT-92 {expected}"),
1243        )),
1244        _ => {}
1245    }
1246}
1247
1248fn br_co_12(invoice: &Invoice, report: &mut Report) {
1249    let Some(totals) = totals_of(invoice) else {
1250        return;
1251    };
1252    let path = Path::group_term(Group::Totals, BtId(108));
1253    let Some(expected) = crate::amount::InvoiceAmount::checked_sum(
1254        invoice.document_charges.iter().map(|c| c.amount),
1255    ) else {
1256        overflow(report, "BR-CO-12", 108, "BT-108");
1257        return;
1258    };
1259    match (invoice.document_charges.is_empty(), totals.charge_total) {
1260        (true, None) => {}
1261        // BR-CO-12 artefact: present BT-108 = Σ BG-21. Empty sum is 0.
1262        (true, Some(stated)) if stated.is_zero() => {}
1263        (true, Some(stated)) => report.push(Finding::fatal(
1264            "BR-CO-12",
1265            path,
1266            format!("BT-108 {stated} ≠ Σ BT-99 0.00 (no BG-21)"),
1267        )),
1268        (false, None) => report.push(Finding::fatal(
1269            "BR-CO-12",
1270            path,
1271            format!("BT-108 is absent; expected Σ BT-99 {expected}"),
1272        )),
1273        (false, Some(stated)) if stated != expected => report.push(Finding::fatal(
1274            "BR-CO-12",
1275            path,
1276            format!("BT-108 {stated} ≠ Σ BT-99 {expected}"),
1277        )),
1278        _ => {}
1279    }
1280}
1281
1282fn br_co_13(invoice: &Invoice, report: &mut Report) {
1283    let Some(totals) = totals_of(invoice) else {
1284        return;
1285    };
1286    let Some(line_net) = totals.line_net else {
1287        return;
1288    };
1289    let expected = match (totals.allowance_total, totals.charge_total) {
1290        (None, None) => Some(line_net),
1291        (Some(a), None) => line_net.checked_sub(a),
1292        (None, Some(c)) => line_net.checked_add(c),
1293        (Some(a), Some(c)) => line_net.checked_sub(a).and_then(|v| v.checked_add(c)),
1294    };
1295    let Some(expected) = expected else {
1296        overflow(report, "BR-CO-13", 109, "BT-109");
1297        return;
1298    };
1299    match totals.without_tax {
1300        Some(stated) if stated != expected => report.push(Finding::fatal(
1301            "BR-CO-13",
1302            Path::group_term(Group::Totals, BtId(109)),
1303            format!("BT-109 {stated} ≠ BT-106 − BT-107 + BT-108 = {expected}"),
1304        )),
1305        None => report.push(Finding::fatal(
1306            "BR-CO-13",
1307            Path::group_term(Group::Totals, BtId(109)),
1308            format!("BT-109 is absent; expected {expected}"),
1309        )),
1310        _ => {}
1311    }
1312}
1313
1314fn br_co_14(invoice: &Invoice, report: &mut Report) {
1315    let Some(totals) = totals_of(invoice) else {
1316        return;
1317    };
1318    let path = Path::group_term(Group::Totals, BtId(110));
1319    let rows = invoice
1320        .tax_breakdown
1321        .iter()
1322        .filter(|e| crate::reconcile::counts_toward_tax_total(invoice.profile, e));
1323    let Some(expected) = crate::amount::InvoiceAmount::checked_sum(rows.map(|e| e.tax)) else {
1324        overflow(report, "BR-CO-14", 110, "BT-110");
1325        return;
1326    };
1327    match totals.tax_total {
1328        Some(stated) if stated != expected => report.push(Finding::fatal(
1329            "BR-CO-14",
1330            path,
1331            format!("BT-110 {stated} ≠ Σ BT-117 {expected}"),
1332        )),
1333        None if !expected.is_zero() => report.push(Finding::fatal(
1334            "BR-CO-14",
1335            path,
1336            format!("BT-110 is absent; expected {expected}"),
1337        )),
1338        _ => {}
1339    }
1340}
1341
1342fn br_co_15(invoice: &Invoice, report: &mut Report) {
1343    let Some(totals) = totals_of(invoice) else {
1344        return;
1345    };
1346    let Some(without) = totals.without_tax else {
1347        return;
1348    };
1349    let tax = totals
1350        .tax_total
1351        .unwrap_or(crate::amount::InvoiceAmount::ZERO);
1352    let Some(expected) = without.checked_add(tax) else {
1353        overflow(report, "BR-CO-15", 112, "BT-112");
1354        return;
1355    };
1356    match totals.with_tax {
1357        Some(stated) if stated != expected => report.push(Finding::fatal(
1358            "BR-CO-15",
1359            Path::group_term(Group::Totals, BtId(112)),
1360            format!("BT-112 {stated} ≠ BT-109 + BT-110 = {expected}"),
1361        )),
1362        None => report.push(Finding::fatal(
1363            "BR-CO-15",
1364            Path::group_term(Group::Totals, BtId(112)),
1365            format!("BT-112 is absent; expected {expected}"),
1366        )),
1367        _ => {}
1368    }
1369}
1370
1371fn br_co_16(invoice: &Invoice, report: &mut Report) {
1372    let Some(totals) = totals_of(invoice) else {
1373        return;
1374    };
1375    let Some(with_tax) = totals.with_tax else {
1376        return;
1377    };
1378    let expected = match (totals.paid, totals.rounding) {
1379        (None, None) => Some(with_tax),
1380        (Some(p), None) => with_tax.checked_sub(p),
1381        (None, Some(r)) => with_tax.checked_add(r),
1382        (Some(p), Some(r)) => with_tax.checked_sub(p).and_then(|v| v.checked_add(r)),
1383    };
1384    let Some(expected) = expected else {
1385        overflow(report, "BR-CO-16", 115, "BT-115");
1386        return;
1387    };
1388    let Some(stated) = totals.payable else {
1389        return;
1390    };
1391    if stated != expected {
1392        report.push(Finding::fatal(
1393            "BR-CO-16",
1394            Path::group_term(Group::Totals, BtId(115)),
1395            format!("BT-115 {stated} ≠ BT-112 − BT-113 + BT-114 = {expected}"),
1396        ));
1397    }
1398}
1399
1400fn br_co_17(invoice: &Invoice, report: &mut Report) {
1401    use crate::arith::{derived_vat, within_vat_tolerance, xpath_round};
1402    use rust_decimal::Decimal;
1403    for (i, e) in invoice.tax_breakdown.iter().enumerate() {
1404        if e.category.as_str().eq_ignore_ascii_case("TTX") {
1405            continue;
1406        }
1407        let path = Path::at_term(Group::TaxBreakdown, i, BtId(117));
1408        let rate = e.rate.map_or(Decimal::ZERO, Percentage::as_percent);
1409        if xpath_round(rate) == Decimal::ZERO {
1410            if xpath_round(e.tax.raw()) != Decimal::ZERO {
1411                report.push(Finding::fatal(
1412                    "BR-CO-17",
1413                    path,
1414                    format!("zero-rate group must have tax 0 (found {})", e.tax),
1415                ));
1416            }
1417            continue;
1418        }
1419        let Some(expected) = derived_vat(e.taxable.raw(), rate) else {
1420            continue;
1421        };
1422        let stated = e.tax.raw().abs();
1423        if !within_vat_tolerance(stated, expected) {
1424            report.push(Finding::fatal(
1425                "BR-CO-17",
1426                path,
1427                format!(
1428                    "BT-117 {} is not within ±1.00 exclusive of derived {expected}",
1429                    e.tax
1430                ),
1431            ));
1432        }
1433    }
1434}
1435
1436/// Presence, co-occurrence, and totals rows in CORE (before category/codes/DEC).
1437pub static ALL: &[Rule] = &[
1438    Rule {
1439        id: "CORE-SPEC-01",
1440        severity: Severity::Fatal,
1441        text: "Unrecognised specification identifier (BT-24).",
1442        source: Source::Crate,
1443        eval: spec_lookup,
1444    },
1445    Rule {
1446        id: "CORE-PROCESS-01",
1447        severity: Severity::Fatal,
1448        text: "Self-billing (and other) process URNs are not validated as billing.",
1449        source: Source::Crate,
1450        eval: core_process_01,
1451    },
1452    Rule {
1453        id: "IBR-SR-63",
1454        severity: Severity::Fatal,
1455        text: "BT-24 must not contain '*'.",
1456        source: Source::Crate,
1457        eval: ibr_sr_63,
1458    },
1459    Rule {
1460        id: "BR-01",
1461        severity: Severity::Fatal,
1462        text: "An Invoice shall have a Specification identifier (BT-24).",
1463        source: Source::Both,
1464        eval: br_01,
1465    },
1466    Rule {
1467        id: "BR-02",
1468        severity: Severity::Fatal,
1469        text: "Invoice number (BT-1) shall be present.",
1470        source: Source::Both,
1471        eval: br_02,
1472    },
1473    Rule {
1474        id: "BR-03",
1475        severity: Severity::Fatal,
1476        text: "An Invoice shall have an Invoice issue date (BT-2).",
1477        source: Source::Both,
1478        eval: br_03,
1479    },
1480    Rule {
1481        id: "BR-04",
1482        severity: Severity::Fatal,
1483        text: "An Invoice shall have an Invoice type code (BT-3).",
1484        source: Source::Both,
1485        eval: br_04,
1486    },
1487    Rule {
1488        id: "BR-08",
1489        severity: Severity::Fatal,
1490        text: "The Seller shall have a Seller postal address (BG-5).",
1491        source: Source::Both,
1492        eval: br_08,
1493    },
1494    Rule {
1495        id: "BR-09",
1496        severity: Severity::Fatal,
1497        text: "The Seller postal address shall contain a Seller country code (BT-40).",
1498        source: Source::Both,
1499        eval: br_09,
1500    },
1501    Rule {
1502        id: "BR-10",
1503        severity: Severity::Fatal,
1504        text: "The Buyer shall have a Buyer postal address (BG-8).",
1505        source: Source::Both,
1506        eval: br_10,
1507    },
1508    Rule {
1509        id: "BR-11",
1510        severity: Severity::Fatal,
1511        text: "The Buyer postal address shall contain a Buyer country code (BT-55).",
1512        source: Source::Both,
1513        eval: br_11,
1514    },
1515    Rule {
1516        id: "BR-21",
1517        severity: Severity::Fatal,
1518        text: "Each Invoice line shall have an Invoice line identifier (BT-126).",
1519        source: Source::Both,
1520        eval: br_21,
1521    },
1522    Rule {
1523        id: "BR-25",
1524        severity: Severity::Fatal,
1525        text: "Each Invoice line shall have an Item name (BT-153).",
1526        source: Source::Both,
1527        eval: br_25,
1528    },
1529    Rule {
1530        id: "BR-22",
1531        severity: Severity::Fatal,
1532        text: "Each Invoice line shall have an Invoiced quantity (BT-129).",
1533        source: Source::Both,
1534        eval: br_22,
1535    },
1536    Rule {
1537        id: "BR-23",
1538        severity: Severity::Fatal,
1539        text: "An Invoice line shall have an Invoiced quantity unit of measure code (BT-130).",
1540        source: Source::Both,
1541        eval: br_23,
1542    },
1543    Rule {
1544        id: "BR-24",
1545        severity: Severity::Fatal,
1546        text: "Each Invoice line shall have an Invoice line net amount (BT-131).",
1547        source: Source::Both,
1548        eval: br_24,
1549    },
1550    Rule {
1551        id: "BR-26",
1552        severity: Severity::Fatal,
1553        text: "Each Invoice line shall contain the Item net price (BT-146).",
1554        source: Source::Both,
1555        eval: br_26,
1556    },
1557    Rule {
1558        id: "BR-27",
1559        severity: Severity::Fatal,
1560        text: "The Item net price (BT-146) shall NOT be negative.",
1561        source: Source::Both,
1562        eval: br_27,
1563    },
1564    Rule {
1565        id: "BR-28",
1566        severity: Severity::Fatal,
1567        text: "The Item gross price (BT-148) shall NOT be negative.",
1568        source: Source::Both,
1569        eval: br_28,
1570    },
1571    Rule {
1572        id: "BR-51",
1573        severity: Severity::Warning,
1574        text: "An invoice should never include a full card primary account number (BT-87).",
1575        source: Source::Both,
1576        eval: br_51,
1577    },
1578    Rule {
1579        id: "BR-17",
1580        severity: Severity::Fatal,
1581        text: "Payee name (BT-59) shall be provided if Payee (BG-10) is used.",
1582        source: Source::Both,
1583        eval: br_17,
1584    },
1585    Rule {
1586        id: "BR-18",
1587        severity: Severity::Fatal,
1588        text: "Seller tax representative name (BT-62) shall be provided if BG-11 is used.",
1589        source: Source::Both,
1590        eval: br_18,
1591    },
1592    Rule {
1593        id: "BR-20",
1594        severity: Severity::Fatal,
1595        text: "Tax representative country (BT-69) shall be provided if BG-11 is used.",
1596        source: Source::Both,
1597        eval: br_20,
1598    },
1599    Rule {
1600        id: "BR-56",
1601        severity: Severity::Fatal,
1602        text: "Seller tax representative VAT identifier (BT-63) shall be provided if BG-11 is used.",
1603        source: Source::Both,
1604        eval: br_56,
1605    },
1606    Rule {
1607        id: "BR-29",
1608        severity: Severity::Fatal,
1609        text: "Invoicing period end date shall be on or after start date.",
1610        source: Source::Both,
1611        eval: br_29,
1612    },
1613    Rule {
1614        id: "BR-30",
1615        severity: Severity::Fatal,
1616        text: "Invoice line period end date shall be on or after start date.",
1617        source: Source::Both,
1618        eval: br_30,
1619    },
1620    Rule {
1621        id: "BR-52",
1622        severity: Severity::Fatal,
1623        text: "Each additional supporting document shall contain a reference (BT-122).",
1624        source: Source::Both,
1625        eval: br_52,
1626    },
1627    Rule {
1628        id: "BR-54",
1629        severity: Severity::Fatal,
1630        text: "Each item attribute (BG-32) shall contain name (BT-160) and value (BT-161).",
1631        source: Source::Both,
1632        eval: br_54,
1633    },
1634    Rule {
1635        id: "BR-55",
1636        severity: Severity::Fatal,
1637        text: "Each preceding invoice reference (BG-3) shall contain BT-25.",
1638        source: Source::Both,
1639        eval: br_55,
1640    },
1641    Rule {
1642        id: "BR-57",
1643        severity: Severity::Fatal,
1644        text: "Each deliver-to address (BG-15) shall contain country (BT-80).",
1645        source: Source::Both,
1646        eval: br_57,
1647    },
1648    Rule {
1649        id: "BR-62",
1650        severity: Severity::Fatal,
1651        text: "Seller electronic address (BT-34) shall have a scheme.",
1652        source: Source::Both,
1653        eval: br_62,
1654    },
1655    Rule {
1656        id: "BR-63",
1657        severity: Severity::Fatal,
1658        text: "Buyer electronic address (BT-49) shall have a scheme.",
1659        source: Source::Both,
1660        eval: br_63,
1661    },
1662    Rule {
1663        id: "BR-64",
1664        severity: Severity::Fatal,
1665        text: "Item standard identifier (BT-157) shall have a scheme.",
1666        source: Source::Both,
1667        eval: br_64,
1668    },
1669    Rule {
1670        id: "BR-65",
1671        severity: Severity::Fatal,
1672        text: "Item classification identifier (BT-158) shall have a scheme (listID).",
1673        source: Source::Both,
1674        eval: br_65,
1675    },
1676    Rule {
1677        id: "BR-CO-09",
1678        severity: Severity::Fatal,
1679        text: "VAT identifiers shall have an ISO 3166-1 alpha-2 prefix (Greece EL). Not PINT-MY TIN.",
1680        source: Source::Both,
1681        eval: br_co_09,
1682    },
1683    Rule {
1684        id: "BR-CO-19",
1685        severity: Severity::Fatal,
1686        text: "If invoicing period (BG-14) is used, start or end shall be present.",
1687        source: Source::Both,
1688        eval: br_co_19,
1689    },
1690    Rule {
1691        id: "BR-CO-20",
1692        severity: Severity::Fatal,
1693        text: "If invoice line period (BG-26) is used, start or end shall be present.",
1694        source: Source::Both,
1695        eval: br_co_20,
1696    },
1697    Rule {
1698        id: "BR-CO-21",
1699        severity: Severity::Fatal,
1700        text: "Document level allowance shall have a reason or reason code.",
1701        source: Source::Both,
1702        eval: br_co_21,
1703    },
1704    Rule {
1705        id: "BR-CO-22",
1706        severity: Severity::Fatal,
1707        text: "Document level charge shall have a reason or reason code.",
1708        source: Source::Both,
1709        eval: br_co_22,
1710    },
1711    Rule {
1712        id: "BR-CO-23",
1713        severity: Severity::Fatal,
1714        text: "Invoice line allowance shall have a reason or reason code.",
1715        source: Source::Both,
1716        eval: br_co_23,
1717    },
1718    Rule {
1719        id: "BR-CO-24",
1720        severity: Severity::Fatal,
1721        text: "Invoice line charge shall have a reason or reason code.",
1722        source: Source::Both,
1723        eval: br_co_24,
1724    },
1725    Rule {
1726        id: "BR-12",
1727        severity: Severity::Fatal,
1728        text: "An Invoice shall have the Sum of Invoice line net amount (BT-106).",
1729        source: Source::Both,
1730        eval: br_12,
1731    },
1732    Rule {
1733        id: "BR-13",
1734        severity: Severity::Fatal,
1735        text: "An Invoice shall have the Invoice total amount without VAT (BT-109).",
1736        source: Source::Both,
1737        eval: br_13,
1738    },
1739    Rule {
1740        id: "BR-14",
1741        severity: Severity::Fatal,
1742        text: "An Invoice shall have the Invoice total amount with VAT (BT-112).",
1743        source: Source::Both,
1744        eval: br_14,
1745    },
1746    Rule {
1747        id: "BR-15",
1748        severity: Severity::Fatal,
1749        text: "An Invoice shall have the Amount due for payment (BT-115).",
1750        source: Source::Both,
1751        eval: br_15,
1752    },
1753    Rule {
1754        id: "BR-19",
1755        severity: Severity::Fatal,
1756        text: "The Seller tax representative postal address (BG-12) shall be provided if BG-11 is used.",
1757        source: Source::Both,
1758        eval: br_19,
1759    },
1760    Rule {
1761        id: "BR-31",
1762        severity: Severity::Fatal,
1763        text: "Each Document level allowance (BG-20) shall have a Document level allowance amount (BT-92).",
1764        source: Source::Both,
1765        eval: br_31,
1766    },
1767    Rule {
1768        id: "BR-32",
1769        severity: Severity::Fatal,
1770        text: "Each Document level allowance (BG-20) shall have a VAT category code (BT-95).",
1771        source: Source::Both,
1772        eval: br_32,
1773    },
1774    Rule {
1775        id: "BR-33",
1776        severity: Severity::Fatal,
1777        text: "Each Document level allowance (BG-20) shall have a reason (BT-97) or reason code (BT-98).",
1778        source: Source::Both,
1779        eval: br_33,
1780    },
1781    Rule {
1782        id: "BR-36",
1783        severity: Severity::Fatal,
1784        text: "Each Document level charge (BG-21) shall have a Document level charge amount (BT-99).",
1785        source: Source::Both,
1786        eval: br_36,
1787    },
1788    Rule {
1789        id: "BR-37",
1790        severity: Severity::Fatal,
1791        text: "Each Document level charge (BG-21) shall have a VAT category code (BT-102).",
1792        source: Source::Both,
1793        eval: br_37,
1794    },
1795    Rule {
1796        id: "BR-38",
1797        severity: Severity::Fatal,
1798        text: "Each Document level charge (BG-21) shall have a reason (BT-104) or reason code (BT-105).",
1799        source: Source::Both,
1800        eval: br_38,
1801    },
1802    Rule {
1803        id: "BR-41",
1804        severity: Severity::Fatal,
1805        text: "Each Invoice line allowance (BG-27) shall have an Invoice line allowance amount (BT-136).",
1806        source: Source::Both,
1807        eval: br_41,
1808    },
1809    Rule {
1810        id: "BR-42",
1811        severity: Severity::Fatal,
1812        text: "Each Invoice line allowance (BG-27) shall have a reason or reason code.",
1813        source: Source::Both,
1814        eval: br_42,
1815    },
1816    Rule {
1817        id: "BR-43",
1818        severity: Severity::Fatal,
1819        text: "Each Invoice line charge (BG-28) shall have an Invoice line charge amount (BT-141).",
1820        source: Source::Both,
1821        eval: br_43,
1822    },
1823    Rule {
1824        id: "BR-44",
1825        severity: Severity::Fatal,
1826        text: "Each Invoice line charge shall have a reason or reason code.",
1827        source: Source::Both,
1828        eval: br_44,
1829    },
1830    Rule {
1831        id: "BR-45",
1832        severity: Severity::Fatal,
1833        text: "Each VAT breakdown (BG-23) shall have a VAT category taxable amount (BT-116).",
1834        source: Source::Both,
1835        eval: br_45,
1836    },
1837    Rule {
1838        id: "BR-46",
1839        severity: Severity::Fatal,
1840        text: "Each VAT breakdown (BG-23) shall have a VAT category tax amount (BT-117).",
1841        source: Source::Both,
1842        eval: br_46,
1843    },
1844    Rule {
1845        id: "BR-47",
1846        severity: Severity::Fatal,
1847        text: "Each VAT breakdown (BG-23) shall be defined through a VAT category code (BT-118).",
1848        source: Source::Both,
1849        eval: br_47,
1850    },
1851    Rule {
1852        id: "BR-48",
1853        severity: Severity::Fatal,
1854        text: "Each VAT breakdown (BG-23) shall have a VAT category rate (BT-119), except if not subject to VAT.",
1855        source: Source::Both,
1856        eval: br_48,
1857    },
1858    Rule {
1859        id: "BR-49",
1860        severity: Severity::Fatal,
1861        text: "A Payment instruction (BG-16) shall specify the Payment means type code (BT-81).",
1862        source: Source::Both,
1863        eval: br_49,
1864    },
1865    Rule {
1866        id: "BR-50",
1867        severity: Severity::Fatal,
1868        text: "A Payment account identifier (BT-84) shall be present if Credit transfer (BG-17) is used.",
1869        source: Source::Both,
1870        eval: br_50,
1871    },
1872    Rule {
1873        id: "BR-61",
1874        severity: Severity::Fatal,
1875        text: "If BT-81 is 30 or 58, the Payment account identifier (BT-84) shall be present.",
1876        source: Source::Both,
1877        eval: br_61,
1878    },
1879    Rule {
1880        id: "BR-CO-26",
1881        severity: Severity::Fatal,
1882        text: "Seller identifier (BT-29), legal registration (BT-30) and/or VAT identifier (BT-31) shall be present.",
1883        source: Source::Both,
1884        eval: br_co_26,
1885    },
1886    Rule {
1887        id: "BR-CO-03",
1888        severity: Severity::Fatal,
1889        text: "Value added tax point date (BT-7) and Value added tax point date code (BT-8) are mutually exclusive.",
1890        source: Source::Both,
1891        eval: br_co_03,
1892    },
1893    Rule {
1894        id: "BR-CO-05",
1895        severity: Severity::Fatal,
1896        text: "Document level allowance reason code and reason shall indicate the same type of allowance. Artefact test is true() (NLP).",
1897        source: Source::ArtefactOnly,
1898        eval: br_co_nlp,
1899    },
1900    Rule {
1901        id: "BR-CO-06",
1902        severity: Severity::Fatal,
1903        text: "Document level charge reason code and reason shall indicate the same type of charge. Artefact test is true() (NLP).",
1904        source: Source::ArtefactOnly,
1905        eval: br_co_nlp,
1906    },
1907    Rule {
1908        id: "BR-CO-07",
1909        severity: Severity::Fatal,
1910        text: "Invoice line allowance reason code and reason shall indicate the same type. Artefact test is true() (NLP).",
1911        source: Source::ArtefactOnly,
1912        eval: br_co_nlp,
1913    },
1914    Rule {
1915        id: "BR-CO-08",
1916        severity: Severity::Fatal,
1917        text: "Invoice line charge reason code and reason shall indicate the same type. Artefact test is true() (NLP).",
1918        source: Source::ArtefactOnly,
1919        eval: br_co_nlp,
1920    },
1921    Rule {
1922        id: "BR-05",
1923        severity: Severity::Fatal,
1924        text: "Invoice currency code (BT-5) shall be present.",
1925        source: Source::Both,
1926        eval: br_05,
1927    },
1928    Rule {
1929        id: "BR-53",
1930        severity: Severity::Fatal,
1931        text: "If BT-6 is present, a TaxAmount in that currency shall exist (BT-110 when BT-6=BT-5, else BT-111). Never derived.",
1932        source: Source::Both,
1933        eval: br_53,
1934    },
1935    Rule {
1936        id: "BR-06",
1937        severity: Severity::Fatal,
1938        text: "Seller name (BT-27) shall be present.",
1939        source: Source::Both,
1940        eval: br_06,
1941    },
1942    Rule {
1943        id: "BR-07",
1944        severity: Severity::Fatal,
1945        text: "Buyer name (BT-44) shall be present.",
1946        source: Source::Both,
1947        eval: br_07,
1948    },
1949    Rule {
1950        id: "BR-16",
1951        severity: Severity::Fatal,
1952        text: "An Invoice shall have at least one Invoice line (BG-25).",
1953        source: Source::Both,
1954        eval: br_16,
1955    },
1956    Rule {
1957        id: "BR-CO-04",
1958        severity: Severity::Fatal,
1959        text: "Each Invoice line shall have an Invoiced item VAT category code (BT-151).",
1960        source: Source::Both,
1961        eval: br_co_04,
1962    },
1963    Rule {
1964        id: "BR-CO-10",
1965        severity: Severity::Fatal,
1966        text: "Sum of Invoice line net amount (BT-106) = Σ Invoice line net amount (BT-131).",
1967        source: Source::Both,
1968        eval: br_co_10,
1969    },
1970    Rule {
1971        id: "BR-CO-11",
1972        severity: Severity::Fatal,
1973        text: "Sum of allowances on document level (BT-107) = Σ Document level allowance amount (BT-92). Present 0 with no BG-20 is valid (empty sum).",
1974        source: Source::Both,
1975        eval: br_co_11,
1976    },
1977    Rule {
1978        id: "BR-CO-12",
1979        severity: Severity::Fatal,
1980        text: "Sum of charges on document level (BT-108) = Σ Document level charge amount (BT-99). Present 0 with no BG-21 is valid (empty sum).",
1981        source: Source::Both,
1982        eval: br_co_12,
1983    },
1984    Rule {
1985        id: "BR-CO-13",
1986        severity: Severity::Fatal,
1987        text: "Invoice total amount without VAT (BT-109) = BT-106 − BT-107 + BT-108 (four presence branches; absent ≠ 0).",
1988        source: Source::Both,
1989        eval: br_co_13,
1990    },
1991    Rule {
1992        id: "BR-CO-14",
1993        severity: Severity::Fatal,
1994        text: "Invoice total tax amount (BT-110) = Σ tax category tax amount (BT-117). Exact. PINT IBR-CO-14 sums every IBG-23 row, including TTX/AAL.",
1995        source: Source::Both,
1996        eval: br_co_14,
1997    },
1998    Rule {
1999        id: "BR-CO-15",
2000        severity: Severity::Fatal,
2001        text: "Invoice total amount with VAT (BT-112) = BT-109 + BT-110.",
2002        source: Source::Both,
2003        eval: br_co_15,
2004    },
2005    Rule {
2006        id: "BR-CO-16",
2007        severity: Severity::Fatal,
2008        text: "Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) − Paid amount (BT-113) + Rounding amount (BT-114).",
2009        source: Source::Both,
2010        eval: br_co_16,
2011    },
2012    Rule {
2013        id: "BR-CO-17",
2014        severity: Severity::Fatal,
2015        text: "VAT category tax amount (BT-117) = VAT category taxable amount (BT-116) × (VAT category rate (BT-119) / 100), rounded to two decimals. Artefact slack ±1.00 exclusive on abs; zero-rate branch has no slack.",
2016        source: Source::Both,
2017        eval: br_co_17,
2018    },
2019    Rule {
2020        id: "PINT-TAX",
2021        severity: Severity::Fatal,
2022        // PINT-TAX: sibling profiles; PintMy.tax_systems is SST only.
2023        text: "Tax system on a line must be allowed by the profile. EN 16931 / Peppol BIS 3.0: VAT only. PINT: VAT, GST, SST, consumption. PINT-MY: SST only.",
2024        source: Source::Crate,
2025        eval: pint_tax,
2026    },
2027    Rule {
2028        id: "IBR-02-MY",
2029        severity: Severity::Fatal,
2030        text: "Seller legal registration identifier (BRN / IBT-030) shall be present.",
2031        source: Source::Crate,
2032        eval: ibr_02_my,
2033    },
2034    Rule {
2035        id: "IBR-03-MY",
2036        severity: Severity::Fatal,
2037        text: "Buyer legal registration identifier (BRN / IBT-047) shall be present.",
2038        source: Source::Crate,
2039        eval: ibr_03_my,
2040    },
2041    Rule {
2042        id: "IBR-04-MY",
2043        severity: Severity::Fatal,
2044        text: "Seller TIN (IBT-032) shall be present.",
2045        source: Source::Crate,
2046        eval: ibr_04_my,
2047    },
2048    Rule {
2049        id: "IBR-CL-05-MY",
2050        severity: Severity::Fatal,
2051        text: "If tax accounting currency (IBT-006 / BT-6) is present, it shall be MYR. Invoice currency (BT-5) is not forced to MYR.",
2052        source: Source::Crate,
2053        eval: ibr_cl_05_my,
2054    },
2055    Rule {
2056        id: "ALIGNED-IBRP-CL-01-MY",
2057        severity: Severity::Fatal,
2058        text: "Malaysian invoice tax categories shall be SA, SE, HVG, LVG, TTX, E or O.",
2059        source: Source::Crate,
2060        eval: aligned_ibrp_cl_01_my,
2061    },
2062];
2063
2064/// BR-DEC-* are Amount.Type. InvoiceAmount refuses a third digit; these rows exist so explain() resolves artefact ids.
2065fn br_dec_pass(_invoice: &Invoice, _report: &mut Report) {}
2066
2067macro_rules! dec {
2068    ($id:literal, $text:literal) => {
2069        Rule {
2070            id: $id,
2071            severity: Severity::Fatal,
2072            text: $text,
2073            source: Source::Both,
2074            eval: br_dec_pass,
2075        }
2076    };
2077}
2078
2079/// Amount.Type decimal rows. Eval is a no-op so [`explain`] resolves artefact ids.
2080pub static DEC: &[Rule] = &[
2081    dec!(
2082        "BR-DEC-01",
2083        "Document level allowance amount (BT-92) has at most 2 decimals."
2084    ),
2085    dec!(
2086        "BR-DEC-02",
2087        "Document level allowance base amount (BT-93) has at most 2 decimals."
2088    ),
2089    dec!(
2090        "BR-DEC-05",
2091        "Document level charge amount (BT-99) has at most 2 decimals."
2092    ),
2093    dec!(
2094        "BR-DEC-06",
2095        "Document level charge base amount (BT-100) has at most 2 decimals."
2096    ),
2097    dec!(
2098        "BR-DEC-09",
2099        "Sum of invoice line net amount (BT-106) has at most 2 decimals."
2100    ),
2101    dec!(
2102        "BR-DEC-10",
2103        "Sum of allowances on document level (BT-107) has at most 2 decimals."
2104    ),
2105    dec!(
2106        "BR-DEC-11",
2107        "Sum of charges on document level (BT-108) has at most 2 decimals."
2108    ),
2109    dec!(
2110        "BR-DEC-12",
2111        "Invoice total amount without VAT (BT-109) has at most 2 decimals."
2112    ),
2113    dec!(
2114        "BR-DEC-13",
2115        "Invoice total VAT amount (BT-110) has at most 2 decimals."
2116    ),
2117    dec!(
2118        "BR-DEC-14",
2119        "Invoice total amount with VAT (BT-112) has at most 2 decimals."
2120    ),
2121    dec!(
2122        "BR-DEC-15",
2123        "Invoice total VAT amount in accounting currency (BT-111) has at most 2 decimals."
2124    ),
2125    dec!("BR-DEC-16", "Paid amount (BT-113) has at most 2 decimals."),
2126    dec!(
2127        "BR-DEC-17",
2128        "Rounding amount (BT-114) has at most 2 decimals."
2129    ),
2130    dec!(
2131        "BR-DEC-18",
2132        "Amount due for payment (BT-115) has at most 2 decimals."
2133    ),
2134    dec!(
2135        "BR-DEC-19",
2136        "VAT category taxable amount (BT-116) has at most 2 decimals."
2137    ),
2138    dec!(
2139        "BR-DEC-20",
2140        "VAT category tax amount (BT-117) has at most 2 decimals."
2141    ),
2142    dec!(
2143        "BR-DEC-23",
2144        "Invoice line net amount (BT-131) has at most 2 decimals."
2145    ),
2146    dec!(
2147        "BR-DEC-24",
2148        "Invoice line allowance amount (BT-136) has at most 2 decimals."
2149    ),
2150    dec!(
2151        "BR-DEC-25",
2152        "Invoice line charge amount (BT-141) has at most 2 decimals."
2153    ),
2154    dec!(
2155        "BR-DEC-27",
2156        "Item net price (BT-146) — Amount.Type is two decimals on InvoiceAmount only; unit price is not this row."
2157    ),
2158    dec!(
2159        "BR-DEC-28",
2160        "Item gross price (BT-148) — Amount.Type is two decimals on InvoiceAmount only."
2161    ),
2162];
2163
2164#[cfg(test)]
2165mod tests {
2166    use super::*;
2167    use crate::code::Code;
2168
2169    #[test]
2170    fn matrix_lists_catalogue_ids() {
2171        let matrix = crate::conformance_matrix();
2172        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/matrix.md");
2173        let on_disk = std::fs::read_to_string(&path).expect("docs/matrix.md");
2174        assert_eq!(
2175            on_disk, matrix,
2176            "docs/matrix.md is stale; replace it with core_invoice::conformance_matrix()"
2177        );
2178        for rule in catalogue() {
2179            assert!(
2180                matrix.contains(rule.id),
2181                "{} missing from generated matrix",
2182                rule.id
2183            );
2184        }
2185        assert!(matrix.contains("Not IRBM Valid"));
2186        assert!(matrix.contains("CORE"));
2187        assert!(
2188            matrix.contains("| id | en16931 | peppol | pint | pint-my |"),
2189            "{matrix}"
2190        );
2191        assert!(
2192            !matrix.contains("| xrechnung |"),
2193            "XRechnung is not a matrix column"
2194        );
2195        for id in ["BR-DE-1", "BR-DE-15", "BR-DE-18", "BR-TMP-2"] {
2196            assert!(
2197                !matrix.contains(&format!("| {id} |")),
2198                "{id} is overlay, not a catalogue row"
2199            );
2200        }
2201        assert_eq!(crate::profile::Profile::parse("xrechnung"), None);
2202        assert!(!crate::profile::Profile::known_slugs().contains("xrechnung"));
2203    }
2204
2205    #[cfg(feature = "xrechnung")]
2206    #[test]
2207    fn explain_br_de_15_with_feature() {
2208        assert!(explain("BR-DE-15").is_some());
2209        assert!(explain("BR-DE-18").is_some());
2210        assert!(explain("BR-TMP-2").is_some());
2211    }
2212
2213    #[cfg(not(feature = "xrechnung"))]
2214    #[test]
2215    fn explain_br_de_15_without_feature() {
2216        assert!(explain("BR-DE-15").is_none());
2217        assert!(explain("BR-DE-18").is_none());
2218        assert!(explain("BR-TMP-2").is_none());
2219    }
2220
2221    #[test]
2222    fn padding_matches() {
2223        assert!(matches_id("BR-02", "br-2"));
2224        assert!(matches_id("BR-CO-16", "BR-CO-16"));
2225        assert!(explain("br-02").unwrap().contains("BT-1"));
2226        assert!(explain("nope").is_none());
2227        assert!(explain("BR-DEC-12").unwrap().contains("BT-109"));
2228        assert!(
2229            explain("BR-CO-16")
2230                .unwrap()
2231                .contains("BT-115) = Invoice total amount with VAT (BT-112)")
2232        );
2233        assert!(!explain("BR-CO-16").unwrap().contains("line net + tax"));
2234    }
2235
2236    #[test]
2237    fn ibr_03_my_eval_fires_on_missing_buyer_brn() {
2238        let mut inv = crate::invoice::Invoice::blank(
2239            crate::profile::Profile::PintMy,
2240            "MY-1",
2241            "MYR",
2242            {
2243                let mut p = crate::invoice::Party::new("S", "MY");
2244                p.legal_registration = Some(crate::identifier::Identifier::new("2023010000001"));
2245                p.tax_registration = Some(crate::identifier::Identifier::new("C12345678901"));
2246                p
2247            },
2248            crate::invoice::Party::new("B", "MY"),
2249        );
2250        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
2251        inv.type_code = Some(Code::new("380"));
2252        let report = crate::validate::validate(&inv);
2253        assert!(
2254            report.findings.iter().any(|f| f.id == "IBR-03-MY"),
2255            "{report}"
2256        );
2257        let eval = catalogue()
2258            .iter()
2259            .find(|r| r.id == "IBR-03-MY")
2260            .unwrap()
2261            .eval;
2262        let mut from_eval = crate::report::Report {
2263            profile_slug: "pint-my",
2264            ..crate::report::Report::default()
2265        };
2266        eval(&inv, &mut from_eval);
2267        assert!(from_eval.findings.iter().any(|f| f.id == "IBR-03-MY"));
2268    }
2269
2270    #[test]
2271    fn present_zero_allowance_total_without_bg20_is_not_br_co_11() {
2272        let mut inv = crate::invoice::Invoice::blank(
2273            crate::profile::Profile::En16931,
2274            "1",
2275            "EUR",
2276            {
2277                let mut p = crate::invoice::Party::new("S", "DE");
2278                p.vat_identifier = Some(crate::identifier::Identifier::new("DE1"));
2279                p
2280            },
2281            crate::invoice::Party::new("B", "FR"),
2282        );
2283        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
2284        inv.type_code = Some(Code::new("380"));
2285        inv.payment_terms = Some("Net 30".into());
2286        let mut line = crate::invoice::Line::new(
2287            "1",
2288            "A",
2289            crate::amount::InvoiceAmount::parse("100.00").unwrap(),
2290            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
2291        );
2292        line.quantity = Some(crate::numeric::Quantity::parse("1").unwrap());
2293        line.unit = Some(Code::new("C62"));
2294        line.price = Some(crate::invoice::Price {
2295            net: crate::amount::UnitPriceAmount::parse("100.00").unwrap(),
2296            discount: None,
2297            gross: None,
2298            base_qty: None,
2299            base_unit: None,
2300        });
2301        inv.lines = vec![line];
2302        crate::reconcile::reconcile(&mut inv).unwrap();
2303        let t = inv.totals.as_mut().unwrap();
2304        t.allowance_total = Some(crate::amount::InvoiceAmount::ZERO);
2305        let report = crate::validate::validate(&inv);
2306        assert!(
2307            report.findings.iter().all(|f| f.id != "BR-CO-11"),
2308            "{report}"
2309        );
2310    }
2311
2312    #[test]
2313    fn catalogue_ids_are_tested_or_uncovered() {
2314        let uncovered = include_str!("../../../docs/UNCOVERED.md");
2315        let tests = [
2316            include_str!("rules.rs"),
2317            include_str!("peppol.rs"),
2318            include_str!("category.rs"),
2319            include_str!("codes.rs"),
2320            include_str!("xrechnung.rs"),
2321        ]
2322        .concat();
2323        for rule in catalogue() {
2324            let id = rule.id;
2325            let ok = tests.contains(id) || uncovered.contains(id) || id.starts_with("BR-DEC-");
2326            assert!(ok, "{id} is neither in tests nor UNCOVERED.md");
2327        }
2328    }
2329
2330    #[test]
2331    fn br_23_fires_without_quantity() {
2332        let mut inv = crate::invoice::Invoice::blank(
2333            crate::profile::Profile::En16931,
2334            "1",
2335            "EUR",
2336            crate::invoice::Party::new("S", "DE"),
2337            crate::invoice::Party::new("B", "FR"),
2338        );
2339        inv.lines = vec![crate::invoice::Line::new(
2340            "1",
2341            "A",
2342            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
2343            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
2344        )];
2345        let report = crate::validate::validate(&inv);
2346        assert!(report.findings.iter().any(|f| f.id == "BR-22"), "{report}");
2347        assert!(report.findings.iter().any(|f| f.id == "BR-23"), "{report}");
2348    }
2349
2350    #[test]
2351    fn br_24_is_explainable() {
2352        assert!(crate::explain("BR-24").unwrap().contains("BT-131"));
2353    }
2354
2355    #[test]
2356    fn ibr_cl_05_my_bt6_must_be_myr() {
2357        let mut inv = crate::invoice::Invoice::blank(
2358            crate::profile::Profile::PintMy,
2359            "MY-1",
2360            "MYR",
2361            {
2362                let mut p = crate::invoice::Party::new("S", "MY");
2363                p.legal_registration = Some(crate::identifier::Identifier::new("2023010000001"));
2364                p.tax_registration = Some(crate::identifier::Identifier::new("C12345678901"));
2365                p
2366            },
2367            {
2368                let mut b = crate::invoice::Party::new("B", "MY");
2369                b.legal_registration = Some(crate::identifier::Identifier::new("1999010000001"));
2370                b
2371            },
2372        );
2373        inv.issue_date = crate::date::Date::parse("2026-01-15").ok();
2374        inv.type_code = Some(Code::new("380"));
2375        inv.tax_currency = Some(Code::new("USD"));
2376        let report = crate::validate::validate(&inv);
2377        assert!(
2378            report.findings.iter().any(|f| f.id == "IBR-CL-05-MY"),
2379            "{report}"
2380        );
2381        inv.tax_currency = Some(Code::new("MYR"));
2382        assert!(
2383            crate::validate::validate(&inv)
2384                .findings
2385                .iter()
2386                .all(|f| f.id != "IBR-CL-05-MY")
2387        );
2388    }
2389
2390    #[test]
2391    fn recargo_half_percent_does_not_take_zero_branch() {
2392        use crate::amount::InvoiceAmount;
2393        use crate::date::Date;
2394        use crate::invoice::{Invoice, Line, Party, TaxBreakdown};
2395        use crate::profile::Profile;
2396        use crate::tax::{TaxCategory, TaxSystem};
2397        use crate::validate;
2398        use rust_decimal::Decimal;
2399        use std::str::FromStr;
2400
2401        let mut inv = Invoice::blank(
2402            Profile::En16931,
2403            "INV-R",
2404            "EUR",
2405            {
2406                let mut p = Party::new("S", "ES");
2407                p.vat_identifier = Some(crate::identifier::Identifier::new("ESA12345678"));
2408                p
2409            },
2410            Party::new("B", "ES"),
2411        );
2412        inv.issue_date = Date::parse("2026-01-15").ok();
2413        inv.type_code = Some(Code::new("380"));
2414        let rate = Percentage::new(Decimal::from_str("0.5").unwrap());
2415        inv.lines = vec![Line::new(
2416            "1",
2417            "Recargo",
2418            InvoiceAmount::parse("1000.00").unwrap(),
2419            TaxCategory::vat("S", rate),
2420        )];
2421        inv.tax_breakdown = vec![TaxBreakdown {
2422            system: TaxSystem::Vat,
2423            scheme: "VAT".into(),
2424            category: Code::new("S"),
2425            rate: Some(rate),
2426            taxable: InvoiceAmount::parse("1000.00").unwrap(),
2427            tax: InvoiceAmount::parse("5.00").unwrap(),
2428            exemption_reason: None,
2429            exemption_code: None,
2430        }];
2431        crate::reconcile::reconcile(&mut inv).unwrap();
2432        let report = validate(&inv);
2433        assert!(
2434            report.findings.iter().all(|f| f.id != "BR-CO-17"),
2435            "{report}"
2436        );
2437    }
2438
2439    #[test]
2440    fn br_12_15_fire_when_totals_absent() {
2441        let inv = crate::invoice::Invoice::blank(
2442            crate::profile::Profile::En16931,
2443            "1",
2444            "EUR",
2445            {
2446                let mut p = crate::invoice::Party::new("S", "DE");
2447                p.vat_identifier = Some(crate::identifier::Identifier::new("DE123456789"));
2448                p
2449            },
2450            crate::invoice::Party::new("B", "FR"),
2451        );
2452        let report = crate::validate::validate(&inv);
2453        for id in ["BR-12", "BR-13", "BR-14", "BR-15"] {
2454            assert!(report.findings.iter().any(|f| f.id == id), "{id}: {report}");
2455            assert!(explain(id).is_some());
2456        }
2457    }
2458
2459    #[test]
2460    fn br_19_tax_rep_needs_address() {
2461        let mut inv = crate::invoice::Invoice::blank(
2462            crate::profile::Profile::En16931,
2463            "1",
2464            "EUR",
2465            crate::invoice::Party::new("S", "DE"),
2466            crate::invoice::Party::new("B", "FR"),
2467        );
2468        inv.tax_representative = Some(crate::invoice::TaxRepresentative {
2469            name: "R".into(),
2470            vat_identifier: Some(crate::identifier::Identifier::new("DE1")),
2471            address: None,
2472        });
2473        let report = crate::validate::validate(&inv);
2474        assert!(report.findings.iter().any(|f| f.id == "BR-19"), "{report}");
2475    }
2476
2477    #[test]
2478    fn br_32_33_on_document_allowance() {
2479        let mut inv = crate::invoice::Invoice::blank(
2480            crate::profile::Profile::En16931,
2481            "1",
2482            "EUR",
2483            crate::invoice::Party::new("S", "DE"),
2484            crate::invoice::Party::new("B", "FR"),
2485        );
2486        inv.document_allowances
2487            .push(crate::invoice::AllowanceCharge {
2488                amount: crate::amount::InvoiceAmount::parse("1.00").unwrap(),
2489                base: None,
2490                percent: None,
2491                reason: None,
2492                reason_code: None,
2493                tax: None,
2494            });
2495        let report = crate::validate::validate(&inv);
2496        assert!(report.findings.iter().any(|f| f.id == "BR-32"), "{report}");
2497        assert!(report.findings.iter().any(|f| f.id == "BR-33"), "{report}");
2498        assert!(explain("BR-31").unwrap().contains("BT-92"));
2499        assert!(explain("BR-36").unwrap().contains("BT-99"));
2500        assert!(explain("BR-41").unwrap().contains("BT-136"));
2501        assert!(explain("BR-43").unwrap().contains("BT-141"));
2502        assert!(explain("BR-45").unwrap().contains("BT-116"));
2503        assert!(explain("BR-46").unwrap().contains("BT-117"));
2504    }
2505
2506    #[test]
2507    fn br_48_skips_o_and_ttx() {
2508        use crate::invoice::TaxBreakdown;
2509        let mut inv = crate::invoice::Invoice::blank(
2510            crate::profile::Profile::En16931,
2511            "1",
2512            "EUR",
2513            crate::invoice::Party::new("S", "DE"),
2514            crate::invoice::Party::new("B", "FR"),
2515        );
2516        inv.tax_breakdown.push(TaxBreakdown {
2517            system: crate::tax::TaxSystem::Vat,
2518            scheme: "VAT".into(),
2519            category: Code::new("S"),
2520            rate: None,
2521            taxable: crate::amount::InvoiceAmount::parse("1.00").unwrap(),
2522            tax: crate::amount::InvoiceAmount::ZERO,
2523            exemption_reason: None,
2524            exemption_code: None,
2525        });
2526        let report = crate::validate::validate(&inv);
2527        assert!(report.findings.iter().any(|f| f.id == "BR-48"), "{report}");
2528        inv.tax_breakdown[0].category = Code::new("O");
2529        assert!(
2530            crate::validate::validate(&inv)
2531                .findings
2532                .iter()
2533                .all(|f| f.id != "BR-48")
2534        );
2535        inv.tax_breakdown[0].category = Code::new("TTX");
2536        inv.tax_breakdown[0].scheme = "AAL".into();
2537        assert!(
2538            crate::validate::validate(&inv)
2539                .findings
2540                .iter()
2541                .all(|f| f.id != "BR-48")
2542        );
2543        inv.tax_breakdown[0].category = Code::new("");
2544        assert!(
2545            crate::validate::validate(&inv)
2546                .findings
2547                .iter()
2548                .any(|f| f.id == "BR-47")
2549        );
2550    }
2551
2552    #[test]
2553    fn br_49_50_61_payment() {
2554        let mut inv = crate::invoice::Invoice::blank(
2555            crate::profile::Profile::En16931,
2556            "1",
2557            "EUR",
2558            crate::invoice::Party::new("S", "DE"),
2559            crate::invoice::Party::new("B", "FR"),
2560        );
2561        inv.payment = Some(crate::invoice::PaymentInstructions {
2562            means_code: None,
2563            means_text: None,
2564            remittance: None,
2565            means: None,
2566        });
2567        let report = crate::validate::validate(&inv);
2568        assert!(report.findings.iter().any(|f| f.id == "BR-49"), "{report}");
2569        inv.payment = Some(crate::invoice::PaymentInstructions {
2570            means_code: Some(Code::new("30")),
2571            means_text: None,
2572            remittance: None,
2573            means: Some(crate::payment::PaymentMeans::CreditTransfer(vec![
2574                crate::payment::CreditTransfer {
2575                    account_id: crate::identifier::Identifier::new(""),
2576                    account_name: None,
2577                    provider: None,
2578                },
2579            ])),
2580        });
2581        let report = crate::validate::validate(&inv);
2582        assert!(report.findings.iter().any(|f| f.id == "BR-50"), "{report}");
2583        assert!(report.findings.iter().any(|f| f.id == "BR-61"), "{report}");
2584        inv.payment = Some(crate::invoice::PaymentInstructions {
2585            means_code: Some(Code::new("48")),
2586            means_text: None,
2587            remittance: None,
2588            means: Some(crate::payment::PaymentMeans::Card(
2589                crate::payment::PaymentCard {
2590                    pan: "4111".into(),
2591                    holder: None,
2592                },
2593            )),
2594        });
2595        let report = crate::validate::validate(&inv);
2596        assert!(report.findings.iter().all(|f| f.id != "BR-50"), "{report}");
2597        assert!(report.findings.iter().all(|f| f.id != "BR-61"), "{report}");
2598    }
2599
2600    #[test]
2601    fn br_co_26_seller_identifiable() {
2602        let mut inv = crate::invoice::Invoice::blank(
2603            crate::profile::Profile::En16931,
2604            "1",
2605            "EUR",
2606            crate::invoice::Party::new("S", "DE"),
2607            crate::invoice::Party::new("B", "FR"),
2608        );
2609        let report = crate::validate::validate(&inv);
2610        assert!(
2611            report.findings.iter().any(|f| f.id == "BR-CO-26"),
2612            "{report}"
2613        );
2614        inv.seller.vat_identifier = Some(crate::identifier::Identifier::new("DE123"));
2615        assert!(
2616            crate::validate::validate(&inv)
2617                .findings
2618                .iter()
2619                .all(|f| f.id != "BR-CO-26")
2620        );
2621        let my = crate::invoice::Invoice::blank(
2622            crate::profile::Profile::PintMy,
2623            "1",
2624            "MYR",
2625            crate::invoice::Party::new("S", "MY"),
2626            crate::invoice::Party::new("B", "MY"),
2627        );
2628        assert!(
2629            crate::validate::validate(&my)
2630                .findings
2631                .iter()
2632                .all(|f| f.id != "BR-CO-26")
2633        );
2634        assert!(explain("BR-CO-26").unwrap().contains("BT-29"));
2635        assert!(explain("BR-42").is_some());
2636        assert!(explain("BR-44").is_some());
2637        assert!(explain("BR-37").is_some());
2638        assert!(explain("BR-38").is_some());
2639    }
2640
2641    #[test]
2642    fn br_42_44_line_ac_reason() {
2643        let mut inv = crate::invoice::Invoice::blank(
2644            crate::profile::Profile::En16931,
2645            "1",
2646            "EUR",
2647            crate::invoice::Party::new("S", "DE"),
2648            crate::invoice::Party::new("B", "FR"),
2649        );
2650        let mut line = crate::invoice::Line::new(
2651            "1",
2652            "A",
2653            crate::amount::InvoiceAmount::parse("1.00").unwrap(),
2654            crate::tax::TaxCategory::vat("S", rust_decimal::Decimal::from(19)),
2655        );
2656        line.allowances.push(crate::invoice::LineAllowanceCharge {
2657            amount: crate::amount::InvoiceAmount::parse("1.00").unwrap(),
2658            base: None,
2659            percent: None,
2660            reason: None,
2661            reason_code: None,
2662        });
2663        line.charges.push(crate::invoice::LineAllowanceCharge {
2664            amount: crate::amount::InvoiceAmount::parse("1.00").unwrap(),
2665            base: None,
2666            percent: None,
2667            reason: None,
2668            reason_code: None,
2669        });
2670        inv.lines = vec![line];
2671        let report = crate::validate::validate(&inv);
2672        assert!(report.findings.iter().any(|f| f.id == "BR-42"), "{report}");
2673        assert!(report.findings.iter().any(|f| f.id == "BR-44"), "{report}");
2674    }
2675}