Skip to main content

core_invoice/
rules.rs

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