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