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
50pub 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
63pub 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
96pub 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 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 }
247
248fn br_17(invoice: &Invoice, report: &mut Report) {
249 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 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 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 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 }
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 }
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 }
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 }
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 }
728
729fn br_46(_invoice: &Invoice, _report: &mut Report) {
730 }
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 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 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 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 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 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 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 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 }
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 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 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
1060fn 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 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 (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 (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 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
2053fn 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}