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