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