1use crate::bt::{BtId, Group, Path};
4use crate::invoice::Invoice;
5use crate::profile::Profile;
6use crate::report::{Finding, Report, Severity, Source};
7use crate::rules::Rule;
8use rust_decimal::Decimal;
9
10fn peppol_only(inv: &Invoice) -> bool {
11 inv.profile == Profile::PeppolBis3
12}
13
14fn r001(inv: &Invoice, report: &mut Report) {
15 if !peppol_only(inv) {
16 return;
17 }
18 if inv
19 .business_process
20 .as_deref()
21 .unwrap_or("")
22 .trim()
23 .is_empty()
24 {
25 report.push(Finding::fatal(
26 "PEPPOL-EN16931-R001",
27 Path::term(BtId(23)),
28 "Business process type (BT-23 / ProfileID) shall be present",
29 ));
30 }
31}
32
33fn r007(inv: &Invoice, report: &mut Report) {
34 if !peppol_only(inv) {
35 return;
36 }
37 let Some(id) = inv.business_process.as_deref() else {
38 return;
39 };
40 if !id.starts_with("urn:fdc:peppol.eu:2017:poacc:billing:") || !id.ends_with(":1.0") {
42 report.push(Finding::fatal(
43 "PEPPOL-EN16931-R007",
44 Path::term(BtId(23)),
45 format!("BT-23 {id} is not urn:fdc:peppol.eu:2017:poacc:billing:NN:1.0"),
46 ));
47 }
48}
49
50fn r004(inv: &Invoice, report: &mut Report) {
51 if !peppol_only(inv) {
52 return;
53 }
54 let Some(id) = inv.specification_id.as_deref() else {
55 return;
56 };
57 if !id.starts_with(Profile::PEPPOL_BIS3_PREFIX) {
58 report.push(Finding::fatal(
59 "PEPPOL-EN16931-R004",
60 Path::term(BtId(24)),
61 "BT-24 shall start with the official Peppol BIS Billing 3.0 identifier",
62 ));
63 }
64}
65
66fn r010(inv: &Invoice, report: &mut Report) {
67 if !peppol_only(inv) {
68 return;
69 }
70 if inv
72 .buyer
73 .electronic_address
74 .as_ref()
75 .map(|i| i.value.trim())
76 .unwrap_or("")
77 .is_empty()
78 {
79 report.push(Finding::fatal(
80 "PEPPOL-EN16931-R010",
81 Path::term(BtId(49)),
82 "Buyer electronic address MUST be provided",
83 ));
84 }
85}
86
87fn r020(inv: &Invoice, report: &mut Report) {
88 if !peppol_only(inv) {
89 return;
90 }
91 if inv
93 .seller
94 .electronic_address
95 .as_ref()
96 .map(|i| i.value.trim())
97 .unwrap_or("")
98 .is_empty()
99 {
100 report.push(Finding::fatal(
101 "PEPPOL-EN16931-R020",
102 Path::term(BtId(34)),
103 "Seller electronic address MUST be provided",
104 ));
105 }
106}
107
108fn r005(inv: &Invoice, report: &mut Report) {
109 if !peppol_only(inv) {
110 return;
111 }
112 let Some(tc) = inv.tax_currency.as_ref() else {
113 return;
114 };
115 if tc.as_str().eq_ignore_ascii_case(&inv.currency) {
116 report.push(Finding::fatal(
117 "PEPPOL-EN16931-R005",
118 Path::term(BtId(6)),
119 "VAT accounting currency code MUST be different from invoice currency code when provided",
120 ));
121 }
122}
123
124fn r055(inv: &Invoice, report: &mut Report) {
125 if !peppol_only(inv) {
126 return;
127 }
128 let Some(totals) = inv.totals.as_ref() else {
129 return;
130 };
131 let Some(acct) = totals.tax_total_accounting else {
132 return;
133 };
134 let doc = totals
135 .tax_total
136 .unwrap_or(crate::amount::InvoiceAmount::ZERO);
137 let doc_neg = doc.raw().is_sign_negative();
138 let acct_neg = acct.raw().is_sign_negative();
139 if doc.is_zero() || acct.is_zero() {
140 return;
141 }
142 if doc_neg != acct_neg {
143 report.push(Finding::fatal(
144 "PEPPOL-EN16931-R055",
145 Path::term(BtId(111)),
146 "Invoice total VAT amount and Invoice total VAT amount in accounting currency MUST have the same operational sign",
147 ));
148 }
149}
150
151fn r061(inv: &Invoice, report: &mut Report) {
152 if !peppol_only(inv) {
153 return;
154 }
155 let Some(pay) = inv.payment.as_ref() else {
156 return;
157 };
158 let code = pay.means_code.as_ref().map(|c| c.as_str()).unwrap_or("");
160 let is_dd = code == "49"
161 || code == "59"
162 || matches!(
163 pay.means,
164 Some(crate::payment::PaymentMeans::DirectDebit(_))
165 );
166 if !is_dd {
167 return;
168 }
169 let mandate_ok = match &pay.means {
170 Some(crate::payment::PaymentMeans::DirectDebit(d)) => {
171 d.mandate.as_deref().is_some_and(|m| !m.trim().is_empty())
172 }
173 _ => false,
174 };
175 if !mandate_ok {
176 report.push(Finding::fatal(
177 "PEPPOL-EN16931-R061",
178 Path::term(BtId(89)),
179 "Mandate reference MUST be provided for direct debit",
180 ));
181 }
182}
183
184fn p0100(inv: &Invoice, report: &mut Report) {
185 if !peppol_only(inv) || inv.kind != crate::kind::DocumentKind::Invoice {
186 return;
187 }
188 let Some(code) = inv.type_code.as_ref() else {
189 return;
190 };
191 const ALLOWED: &[&str] = &[
192 "71", "80", "82", "84", "102", "218", "219", "326", "331", "380", "382", "383", "384",
193 "386", "388", "393", "395", "553", "575", "623", "780", "817", "870", "875", "876", "877",
194 ];
195 if !ALLOWED.contains(&code.as_str()) {
196 report.push(Finding::fatal(
197 "PEPPOL-EN16931-P0100",
198 Path::term(BtId(3)),
199 format!(
200 "Invoice type code {} is not allowed for Peppol billing profile 01",
201 code.as_str()
202 ),
203 ));
204 }
205}
206
207fn vatex_pair(inv: &Invoice, report: &mut Report, vatex: &str, cat: &str, id: &'static str) {
208 if !peppol_only(inv) {
209 return;
210 }
211 for (i, row) in inv.tax_breakdown.iter().enumerate() {
212 let Some(code) = row.exemption_code.as_ref() else {
213 continue;
214 };
215 if code.as_str().eq_ignore_ascii_case(vatex)
216 && !row.category.as_str().eq_ignore_ascii_case(cat)
217 {
218 report.push(Finding::fatal(
219 id,
220 Path::at_term(Group::TaxBreakdown, i, BtId(121)),
221 format!("Tax Category {cat} MUST be used when exemption reason code is {vatex}"),
222 ));
223 }
224 }
225}
226
227fn p0104(i: &Invoice, r: &mut Report) {
228 vatex_pair(i, r, "VATEX-EU-G", "G", "PEPPOL-EN16931-P0104");
229}
230fn p0105(i: &Invoice, r: &mut Report) {
231 vatex_pair(i, r, "VATEX-EU-O", "O", "PEPPOL-EN16931-P0105");
232}
233fn p0106(i: &Invoice, r: &mut Report) {
234 vatex_pair(i, r, "VATEX-EU-IC", "K", "PEPPOL-EN16931-P0106");
235}
236fn p0107(i: &Invoice, r: &mut Report) {
237 vatex_pair(i, r, "VATEX-EU-AE", "AE", "PEPPOL-EN16931-P0107");
238}
239fn p0108(i: &Invoice, r: &mut Report) {
240 vatex_pair(i, r, "VATEX-EU-D", "E", "PEPPOL-EN16931-P0108");
241}
242fn p0109(i: &Invoice, r: &mut Report) {
243 vatex_pair(i, r, "VATEX-EU-F", "E", "PEPPOL-EN16931-P0109");
244}
245fn p0110(i: &Invoice, r: &mut Report) {
246 vatex_pair(i, r, "VATEX-EU-I", "E", "PEPPOL-EN16931-P0110");
247}
248fn p0111(i: &Invoice, r: &mut Report) {
249 vatex_pair(i, r, "VATEX-EU-J", "E", "PEPPOL-EN16931-P0111");
250}
251
252fn r003(inv: &Invoice, report: &mut Report) {
253 if !peppol_only(inv) {
254 return;
255 }
256 let has_buyer_ref = inv
258 .buyer_reference
259 .as_ref()
260 .is_some_and(|r| !r.as_str().trim().is_empty());
261 let has_order = inv
262 .purchase_order
263 .as_ref()
264 .is_some_and(|r| !r.as_str().trim().is_empty());
265 if !has_buyer_ref && !has_order {
266 report.push(Finding::fatal(
267 "PEPPOL-EN16931-R003",
268 Path::term(BtId(10)),
269 "Buyer reference (BT-10) or order reference (BT-13) shall be present",
270 ));
271 }
272}
273
274fn r120(inv: &Invoice, report: &mut Report) {
276 if !peppol_only(inv) {
277 return;
278 }
279 let two_cents = Decimal::new(2, 2);
280 for (i, line) in inv.lines.iter().enumerate() {
281 let (Some(qty), Some(price)) = (line.quantity, line.price.as_ref()) else {
282 continue;
283 };
284 let base = price
285 .base_qty
286 .map(|q| q.raw())
287 .filter(|d| !d.is_zero())
288 .unwrap_or(Decimal::ONE);
289 let Some(mut expected) = qty
290 .raw()
291 .checked_mul(price.net.raw())
292 .and_then(|v| v.checked_div(base))
293 else {
294 continue;
295 };
296 for c in &line.charges {
297 let Some(v) = expected.checked_add(c.amount.raw()) else {
298 continue;
299 };
300 expected = v;
301 }
302 for a in &line.allowances {
303 let Some(v) = expected.checked_sub(a.amount.raw()) else {
304 continue;
305 };
306 expected = v;
307 }
308 let delta = (expected - line.net.raw()).abs();
309 if delta > two_cents {
310 report.push(Finding::fatal(
311 "PEPPOL-EN16931-R120",
312 Path::at_term(Group::Line, i, BtId(131)),
313 format!(
314 "BT-131 {} differs from qty×price/base by {delta} (slack ±0.02 inclusive)",
315 line.net
316 ),
317 ));
318 }
319 }
320}
321
322fn r046(inv: &Invoice, report: &mut Report) {
323 if !peppol_only(inv) {
324 return;
325 }
326 for (i, line) in inv.lines.iter().enumerate() {
327 let Some(price) = line.price.as_ref() else {
328 continue;
329 };
330 let Some(gross) = price.gross else {
331 continue;
332 };
333 let discount = price
334 .discount
335 .unwrap_or(crate::amount::UnitPriceAmount::ZERO);
336 let Some(expected) = gross.raw().checked_sub(discount.raw()) else {
337 continue;
338 };
339 if expected != price.net.raw() {
340 report.push(Finding::fatal(
341 "PEPPOL-EN16931-R046",
342 Path::at_term(Group::Line, i, BtId(146)),
343 "net price = gross − discount, exact (not R120 slack)",
344 ));
345 }
346 }
347}
348
349fn r040(inv: &Invoice, report: &mut Report) {
350 if !peppol_only(inv) {
351 return;
352 }
353 let two_cents = Decimal::new(2, 2);
354 for (i, a) in inv
355 .document_allowances
356 .iter()
357 .chain(inv.document_charges.iter())
358 .enumerate()
359 {
360 let (Some(base), Some(pct)) = (a.base, a.percent) else {
361 continue;
362 };
363 let Some(expected) = base
364 .raw()
365 .checked_mul(pct.as_percent())
366 .map(|v| v / Decimal::ONE_HUNDRED)
367 else {
368 continue;
369 };
370 if (expected - a.amount.raw()).abs() > two_cents {
371 report.push(Finding::fatal(
372 "PEPPOL-EN16931-R040",
373 Path::at_term(Group::DocumentAllowance, i, BtId(92)),
374 format!(
375 "allowance/charge amount {} differs from base×percent/100 by more than 0.02",
376 a.amount
377 ),
378 ));
379 }
380 }
381}
382
383fn both_de(inv: &Invoice) -> bool {
384 inv.seller.country().eq_ignore_ascii_case("DE")
385 && inv.buyer.country().eq_ignore_ascii_case("DE")
386}
387
388fn r002(inv: &Invoice, report: &mut Report) {
389 if !peppol_only(inv) {
390 return;
391 }
392 if inv.notes.len() > 1 && !both_de(inv) {
394 report.push(Finding::fatal(
395 "PEPPOL-EN16931-R002",
396 Path::term(BtId(22)),
397 "No more than one note is allowed on document level, unless both buyer and seller are German",
398 ));
399 }
400}
401
402fn each_ac(inv: &Invoice, mut f: impl FnMut(&crate::invoice::AllowanceCharge, Path)) {
403 for (i, a) in inv.document_allowances.iter().enumerate() {
404 f(a, Path::at_term(Group::DocumentAllowance, i, BtId(92)));
405 }
406 for (i, a) in inv.document_charges.iter().enumerate() {
407 f(a, Path::at_term(Group::DocumentCharge, i, BtId(99)));
408 }
409}
410
411fn r041(inv: &Invoice, report: &mut Report) {
412 if !peppol_only(inv) {
413 return;
414 }
415 each_ac(inv, |a, path| {
417 if a.percent.is_some() && a.base.is_none() {
418 report.push(Finding::fatal(
419 "PEPPOL-EN16931-R041",
420 path,
421 "Allowance/charge base amount MUST be provided when allowance/charge percentage is provided",
422 ));
423 }
424 });
425 for (i, line) in inv.lines.iter().enumerate() {
426 for a in line.allowances.iter().chain(line.charges.iter()) {
427 if a.percent.is_some() && a.base.is_none() {
428 report.push(Finding::fatal(
429 "PEPPOL-EN16931-R041",
430 Path::at_term(Group::Line, i, BtId(136)),
431 "Allowance/charge base amount MUST be provided when allowance/charge percentage is provided",
432 ));
433 }
434 }
435 }
436}
437
438fn r042(inv: &Invoice, report: &mut Report) {
439 if !peppol_only(inv) {
440 return;
441 }
442 each_ac(inv, |a, path| {
444 if a.base.is_some() && a.percent.is_none() {
445 report.push(Finding::fatal(
446 "PEPPOL-EN16931-R042",
447 path,
448 "Allowance/charge percentage MUST be provided when allowance/charge base amount is provided",
449 ));
450 }
451 });
452 for (i, line) in inv.lines.iter().enumerate() {
453 for a in line.allowances.iter().chain(line.charges.iter()) {
454 if a.base.is_some() && a.percent.is_none() {
455 report.push(Finding::fatal(
456 "PEPPOL-EN16931-R042",
457 Path::at_term(Group::Line, i, BtId(137)),
458 "Allowance/charge percentage MUST be provided when allowance/charge base amount is provided",
459 ));
460 }
461 }
462 }
463}
464
465fn r054(inv: &Invoice, report: &mut Report) {
466 if !peppol_only(inv) {
467 return;
468 }
469 let has_tax_ccy = inv.tax_currency.is_some();
471 let has_acct = inv
472 .totals
473 .as_ref()
474 .and_then(|t| t.tax_total_accounting)
475 .is_some();
476 if has_tax_ccy != has_acct {
477 report.push(Finding::fatal(
478 "PEPPOL-EN16931-R054",
479 Path::term(BtId(111)),
480 "Only one tax total without tax subtotals MUST be provided when tax currency code is provided",
481 ));
482 }
483}
484
485fn r101(inv: &Invoice, report: &mut Report) {
486 if !peppol_only(inv) {
487 return;
488 }
489 for (i, line) in inv.lines.iter().enumerate() {
490 if line.invoiced_object.is_none() {
491 continue;
492 }
493 let code = line
494 .invoiced_object_code
495 .as_ref()
496 .map(crate::code::Code::as_str)
497 .unwrap_or("130");
498 if code != "130" {
499 report.push(Finding::fatal(
500 "PEPPOL-EN16931-R101",
501 Path::at_term(Group::Line, i, BtId(128)),
502 "Element Document reference can only be used for Invoice line object (code 130)",
503 ));
504 }
505 }
506}
507
508fn r110(inv: &Invoice, report: &mut Report) {
509 if !peppol_only(inv) {
510 return;
511 }
512 let Some(start) = inv.period.as_ref().and_then(|p| p.start) else {
513 return;
514 };
515 for (i, line) in inv.lines.iter().enumerate() {
516 if let Some(ls) = line.period.as_ref().and_then(|p| p.start)
517 && ls < start
518 {
519 report.push(Finding::fatal(
520 "PEPPOL-EN16931-R110",
521 Path::at_term(Group::Line, i, BtId(134)),
522 "Start date of line period MUST be within invoice period",
523 ));
524 }
525 }
526}
527
528fn r111(inv: &Invoice, report: &mut Report) {
529 if !peppol_only(inv) {
530 return;
531 }
532 let Some(end) = inv.period.as_ref().and_then(|p| p.end) else {
533 return;
534 };
535 for (i, line) in inv.lines.iter().enumerate() {
536 if let Some(le) = line.period.as_ref().and_then(|p| p.end)
537 && le > end
538 {
539 report.push(Finding::fatal(
540 "PEPPOL-EN16931-R111",
541 Path::at_term(Group::Line, i, BtId(135)),
542 "End date of line period MUST be within invoice period",
543 ));
544 }
545 }
546}
547
548fn r130(inv: &Invoice, report: &mut Report) {
549 if !peppol_only(inv) {
550 return;
551 }
552 for (i, line) in inv.lines.iter().enumerate() {
553 let Some(price) = line.price.as_ref() else {
554 continue;
555 };
556 let (Some(bu), Some(u)) = (price.base_unit.as_ref(), line.unit.as_ref()) else {
557 continue;
558 };
559 if bu.as_str() != u.as_str() {
560 report.push(Finding::fatal(
561 "PEPPOL-EN16931-R130",
562 Path::at_term(Group::Line, i, BtId(150)),
563 "Unit code of price base quantity MUST be same as invoiced quantity",
564 ));
565 }
566 }
567}
568
569fn cl001(inv: &Invoice, report: &mut Report) {
570 if !peppol_only(inv) {
571 return;
572 }
573 for (i, doc) in inv.supporting_documents.iter().enumerate() {
574 let Some(att) = doc.attachment.as_ref() else {
575 continue;
576 };
577 if !crate::codes::mime(&att.mime) {
578 report.push(Finding::fatal(
579 "PEPPOL-EN16931-CL001",
580 Path::at_term(Group::Attachment, i, BtId(125)),
581 "Mime code must be according to subset of IANA code list",
582 ));
583 }
584 }
585}
586
587fn cl002(inv: &Invoice, report: &mut Report) {
588 if !peppol_only(inv) {
589 return;
590 }
591 for (i, a) in inv.document_allowances.iter().enumerate() {
592 let Some(code) = a.reason_code.as_ref() else {
593 continue;
594 };
595 if !crate::generated_codes::UNCL_5189.contains(&code.as_str()) {
596 report.push(Finding::fatal(
597 "PEPPOL-EN16931-CL002",
598 Path::at_term(Group::DocumentAllowance, i, BtId(98)),
599 "Reason code MUST be according to subset of UNCL 5189 D.16B",
600 ));
601 }
602 }
603}
604
605fn cl003(inv: &Invoice, report: &mut Report) {
606 if !peppol_only(inv) {
607 return;
608 }
609 for (i, a) in inv.document_charges.iter().enumerate() {
610 let Some(code) = a.reason_code.as_ref() else {
611 continue;
612 };
613 if !crate::generated_codes::UNCL_7161.contains(&code.as_str()) {
614 report.push(Finding::fatal(
615 "PEPPOL-EN16931-CL003",
616 Path::at_term(Group::DocumentCharge, i, BtId(105)),
617 "Reason code MUST be according to UNCL 7161 D.16B",
618 ));
619 }
620 }
621}
622
623fn cl006(inv: &Invoice, report: &mut Report) {
624 if !peppol_only(inv) {
625 return;
626 }
627 let Some(code) = inv.tax_point_code.as_ref() else {
628 return;
629 };
630 if !crate::generated_codes::UNCL_2005.contains(&code.as_str()) {
631 report.push(Finding::fatal(
632 "PEPPOL-EN16931-CL006",
633 Path::term(BtId(8)),
634 "Invoice period description code must be according to UNCL 2005 D.16B",
635 ));
636 }
637}
638
639fn cl008(inv: &Invoice, report: &mut Report) {
640 if !peppol_only(inv) {
641 return;
642 }
643 for (party, group, bt) in [
644 (&inv.seller, Group::Seller, 34u16),
645 (&inv.buyer, Group::Buyer, 49u16),
646 ] {
647 let Some(ep) = party.electronic_address.as_ref() else {
648 continue;
649 };
650 let Some(scheme) = ep.scheme.as_deref() else {
651 continue;
652 };
653 if !crate::codes::eas(scheme) {
654 report.push(Finding::fatal(
655 "PEPPOL-EN16931-CL008",
656 Path::group_term(group, BtId(bt)),
657 "Electronic address identifier scheme must be from the Electronic Address Identifier Scheme list",
658 ));
659 }
660 }
661}
662
663fn f001(_inv: &Invoice, _report: &mut Report) {
664 }
666
667fn syntax_or_option_pass(_inv: &Invoice, _report: &mut Report) {
668 }
672
673fn p0101(inv: &Invoice, report: &mut Report) {
674 if !peppol_only(inv) || inv.kind != crate::kind::DocumentKind::CreditNote {
675 return;
676 }
677 let Some(code) = inv.type_code.as_ref() else {
678 return;
679 };
680 const ALLOWED: &[&str] = &["381", "396", "81", "83", "532"];
681 if !ALLOWED.contains(&code.as_str()) {
682 report.push(Finding::fatal(
683 "PEPPOL-EN16931-P0101",
684 Path::term(BtId(3)),
685 format!(
686 "Credit note type code {} is not allowed for Peppol billing profile 01",
687 code.as_str()
688 ),
689 ));
690 }
691}
692
693fn p0112(inv: &Invoice, report: &mut Report) {
694 if !peppol_only(inv) {
695 return;
696 }
697 let Some(code) = inv.type_code.as_ref() else {
698 return;
699 };
700 if matches!(code.as_str(), "326" | "384") && !both_de(inv) {
701 report.push(Finding::fatal(
702 "PEPPOL-EN16931-P0112",
703 Path::term(BtId(3)),
704 "Invoice type code 326 or 384 are only allowed when both buyer and seller are German organizations",
705 ));
706 }
707}
708
709fn gln_ok(s: &str) -> bool {
711 let s = s.trim();
712 if s.len() < 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
713 return false;
714 }
715 let digits: Vec<u32> = s.bytes().map(|b| u32::from(b - b'0')).collect();
716 let n = digits.len();
717 let mut sum = 0u32;
718 for (i, d) in digits[..n - 1].iter().rev().enumerate() {
719 sum += d * if i % 2 == 0 { 3 } else { 1 };
720 }
721 let check = (10 - (sum % 10)) % 10;
722 digits[n - 1] == check
723}
724
725fn peppol_scheme_ids(inv: &Invoice) -> Vec<(String, String, Path, bool)> {
727 let mut out = Vec::new();
728 let mut push = |id: &crate::identifier::Identifier, path: Path, endpoint: bool| {
729 let Some(scheme) = id.scheme.as_deref() else {
730 return;
731 };
732 out.push((scheme.to_owned(), id.value.clone(), path, endpoint));
733 };
734 for (party, group, ep_bt, ident_bt, legal_bt) in [
735 (&inv.seller, Group::Seller, 34u16, 29u16, 30u16),
736 (&inv.buyer, Group::Buyer, 49u16, 46u16, 47u16),
737 ] {
738 if let Some(ep) = party.electronic_address.as_ref() {
739 push(ep, Path::group_term(group, BtId(ep_bt)), true);
740 }
741 for id in &party.identifiers {
742 push(id, Path::group_term(group, BtId(ident_bt)), false);
743 }
744 if let Some(id) = party.legal_registration.as_ref() {
745 push(id, Path::group_term(group, BtId(legal_bt)), false);
746 }
747 }
748 if let Some(p) = inv.payee.as_ref() {
749 if let Some(id) = p.identifier.as_ref() {
750 push(id, Path::term(BtId(60)), false);
751 }
752 if let Some(id) = p.legal_registration.as_ref() {
753 push(id, Path::term(BtId(61)), false);
754 }
755 }
756 out
757}
758
759fn norwegian_mod11(s: &str) -> bool {
760 let s = s.trim();
761 if s.len() != 9 || !s.bytes().all(|b| b.is_ascii_digit()) {
762 return false;
763 }
764 if s.parse::<u64>().unwrap_or(0) == 0 {
765 return false;
766 }
767 let digits: Vec<u32> = s.bytes().map(|b| u32::from(b - b'0')).collect();
768 let mut sum = 0u32;
769 for (i, d) in digits[..8].iter().rev().enumerate() {
770 sum += d * ((i as u32 % 6) + 2);
771 }
772 let check = (11 - (sum % 11)) % 11;
773 digits[8] == check
774}
775
776fn belgian_mod97(s: &str) -> bool {
777 let s = s.trim();
778 if s.len() != 10 || !s.bytes().all(|b| b.is_ascii_digit()) {
779 return false;
780 }
781 let body: u32 = s[..8].parse().unwrap_or(0);
782 let chk: u32 = s[8..].parse().unwrap_or(0);
783 chk == 97 - (body % 97)
784}
785
786fn swedish_orgnr(s: &str) -> bool {
787 let s = s.trim();
788 if s.len() != 10 || !s.bytes().all(|b| b.is_ascii_digit()) {
789 return false;
790 }
791 let main = &s[..9];
792 let mut sum = 0u32;
793 for pos in 1..=9 {
794 let ch = u32::from(main.as_bytes()[9 - pos] - b'0');
795 if pos % 2 == 1 {
796 let d = ch * 2;
797 sum += (d % 10) + (d / 10);
798 } else {
799 sum += ch;
800 }
801 }
802 let check = (10 - (sum % 10)) % 10;
803 u32::from(s.as_bytes()[9] - b'0') == check
804}
805
806fn australian_abn(s: &str) -> bool {
807 let s = s.trim();
808 if s.len() != 11 || !s.bytes().all(|b| b.is_ascii_digit()) {
809 return false;
810 }
811 let w = [10u32, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19];
812 let mut digits: Vec<u32> = s.bytes().map(|b| u32::from(b - b'0')).collect();
813 digits[0] = digits[0].saturating_sub(1);
814 let sum: u32 = digits.iter().zip(w).map(|(d, wt)| d * wt).sum();
815 sum % 89 == 0
816}
817
818fn danish_cvr(s: &str) -> bool {
819 let s = s.trim();
820 let b = s.as_bytes();
821 (s.len() == 10 && b[..2] == *b"DK" && b[2..].iter().all(|c| c.is_ascii_digit()))
822 || (s.len() == 8 && b.iter().all(|c| c.is_ascii_digit()))
823}
824
825fn italian_ipa(s: &str) -> bool {
826 let s = s.trim();
827 s.len() == 6 && s.bytes().all(|b| b.is_ascii_alphanumeric())
828}
829
830fn italian_cf(s: &str) -> bool {
831 let s = s.trim();
832 if s.len() == 11 {
833 return s.bytes().all(|b| b.is_ascii_digit());
834 }
835 if s.len() != 16 {
836 return false;
837 }
838 let b = s.as_bytes();
839 b[..6].iter().all(|c| c.is_ascii_alphabetic())
840 && b[6..8].iter().all(|c| c.is_ascii_digit())
841 && b[8].is_ascii_alphabetic()
842 && b[9..11].iter().all(|c| c.is_ascii_digit())
843 && b[14].is_ascii_digit()
844 && b[15].is_ascii_alphabetic()
845}
846
847fn common_checksums(inv: &Invoice, report: &mut Report) {
848 if !peppol_only(inv) {
849 return;
850 }
851 for (scheme, value, path, endpoint) in peppol_scheme_ids(inv) {
852 match scheme.as_str() {
853 "0088" => {
854 if !gln_ok(&value) {
855 report.push(Finding::fatal(
856 "PEPPOL-COMMON-R040",
857 path,
858 "GLN must have a valid format according to GS1 rules",
859 ));
860 }
861 }
862 "0192" => {
863 if !norwegian_mod11(&value) {
864 report.push(Finding::fatal(
865 "PEPPOL-COMMON-R041",
866 path,
867 "Norwegian organization number MUST be stated in the correct format",
868 ));
869 }
870 }
871 "0184" => {
872 if !danish_cvr(&value) {
873 report.push(Finding::fatal(
874 "PEPPOL-COMMON-R042",
875 path,
876 "Danish organization number (CVR) MUST be stated in the correct format",
877 ));
878 }
879 }
880 "0208" => {
881 if !belgian_mod97(&value) {
882 report.push(Finding::fatal(
883 "PEPPOL-COMMON-R043",
884 path,
885 "Belgian enterprise number MUST be stated in the correct format",
886 ));
887 }
888 }
889 "0007" => {
890 if !swedish_orgnr(&value) {
891 report.push(Finding::fatal(
892 "PEPPOL-COMMON-R049",
893 path,
894 "Swedish organization number MUST be stated in the correct format",
895 ));
896 }
897 }
898 "0151" => {
899 if !australian_abn(&value) {
900 report.push(Finding::fatal(
901 "PEPPOL-COMMON-R050",
902 path,
903 "Australian Business Number (ABN) MUST be stated in the correct format",
904 ));
905 }
906 }
907 "0201" => {
908 if !italian_ipa(&value) {
909 report.push(Finding::warning(
910 "PEPPOL-COMMON-R044",
911 path,
912 "IPA Code must be stated in the correct format",
913 ));
914 }
915 }
916 "0210" => {
917 if !italian_cf(&value) {
918 report.push(Finding::warning(
919 "PEPPOL-COMMON-R045",
920 path,
921 "Tax Code (Codice Fiscale) must be stated in the correct format",
922 ));
923 }
924 }
925 "9907" if endpoint => {
926 if !italian_cf(&value) {
927 report.push(Finding::warning(
928 "PEPPOL-COMMON-R046",
929 path,
930 "Tax Code (Codice Fiscale) must be stated in the correct format",
931 ));
932 }
933 }
934 "0211" => {
935 if value.len() >= 2
937 && value[..2].eq_ignore_ascii_case("IT")
938 && !(value.len() == 13 && value[2..].bytes().all(|b| b.is_ascii_digit()))
939 {
940 report.push(Finding::warning(
941 "PEPPOL-COMMON-R047",
942 path,
943 "Italian VAT Code (Partita Iva) must be stated in the correct format",
944 ));
945 }
946 }
947 "0096" => {
948 if !(value.len() == 10 && value.bytes().all(|b| b.is_ascii_digit())) {
949 report.push(Finding::warning(
950 "PEPPOL-COMMON-R052",
951 path,
952 "Danish chamber of commerce number (P) MUST be stated in the correct format",
953 ));
954 }
955 }
956 "0198" => {
957 let ok = value.len() == 10
958 && value.as_bytes()[..2] == *b"DK"
959 && value.as_bytes()[2..].iter().all(|c| c.is_ascii_digit());
960 if !ok {
961 report.push(Finding::warning(
962 "PEPPOL-COMMON-R053",
963 path,
964 "Danish ERSTORG number (SE) MUST be stated in the correct format",
965 ));
966 }
967 }
968 _ => {}
969 }
970 }
971}
972
973fn common_r040(inv: &Invoice, report: &mut Report) {
974 common_checksums(inv, report);
976}
977
978fn common_id_explain(_inv: &Invoice, _report: &mut Report) {
979 }
981
982fn r121(inv: &Invoice, report: &mut Report) {
983 if !peppol_only(inv) {
984 return;
985 }
986 for (i, line) in inv.lines.iter().enumerate() {
987 let Some(price) = line.price.as_ref() else {
988 continue;
989 };
990 if let Some(q) = price.base_qty
991 && (!q.raw().is_sign_positive() || q.raw().is_zero())
992 {
993 report.push(Finding::fatal(
994 "PEPPOL-EN16931-R121",
995 Path::at_term(Group::Line, i, BtId(149)),
996 "base quantity shall be greater than zero",
997 ));
998 }
999 }
1000}
1001
1002const fn r(id: &'static str, text: &'static str, eval: fn(&Invoice, &mut Report)) -> Rule {
1003 Rule {
1004 id,
1005 severity: Severity::Fatal,
1006 text,
1007 source: Source::Crate,
1008 eval,
1009 }
1010}
1011
1012pub static RULES: &[Rule] = &[
1014 r("PEPPOL-EN16931-R001", "BT-23 shall be present.", r001),
1015 r(
1016 "PEPPOL-EN16931-R007",
1017 "BT-23 shall match urn:fdc:peppol.eu:2017:poacc:billing:NN:1.0.",
1018 r007,
1019 ),
1020 r(
1021 "PEPPOL-EN16931-R004",
1022 "BT-24 shall start with the official Peppol BIS Billing 3.0 id.",
1023 r004,
1024 ),
1025 r(
1026 "PEPPOL-EN16931-R003",
1027 "BT-10 or BT-13 shall be present.",
1028 r003,
1029 ),
1030 r(
1031 "PEPPOL-EN16931-R010",
1032 "Buyer electronic address MUST be provided.",
1033 r010,
1034 ),
1035 r(
1036 "PEPPOL-EN16931-R020",
1037 "Seller electronic address MUST be provided.",
1038 r020,
1039 ),
1040 r(
1041 "PEPPOL-EN16931-R005",
1042 "VAT accounting currency MUST differ from invoice currency when provided.",
1043 r005,
1044 ),
1045 r(
1046 "PEPPOL-EN16931-R055",
1047 "BT-110 and BT-111 MUST have the same operational sign.",
1048 r055,
1049 ),
1050 r(
1051 "PEPPOL-EN16931-R061",
1052 "Mandate reference MUST be provided for direct debit.",
1053 r061,
1054 ),
1055 r(
1056 "PEPPOL-EN16931-P0100",
1057 "Invoice type code must be in the Peppol billing profile 01 list (not 389).",
1058 p0100,
1059 ),
1060 r(
1061 "PEPPOL-EN16931-P0104",
1062 "VATEX-EU-G requires tax category G.",
1063 p0104,
1064 ),
1065 r(
1066 "PEPPOL-EN16931-P0105",
1067 "VATEX-EU-O requires tax category O.",
1068 p0105,
1069 ),
1070 r(
1071 "PEPPOL-EN16931-P0106",
1072 "VATEX-EU-IC requires tax category K.",
1073 p0106,
1074 ),
1075 r(
1076 "PEPPOL-EN16931-P0107",
1077 "VATEX-EU-AE requires tax category AE.",
1078 p0107,
1079 ),
1080 r(
1081 "PEPPOL-EN16931-P0108",
1082 "VATEX-EU-D requires tax category E.",
1083 p0108,
1084 ),
1085 r(
1086 "PEPPOL-EN16931-P0109",
1087 "VATEX-EU-F requires tax category E.",
1088 p0109,
1089 ),
1090 r(
1091 "PEPPOL-EN16931-P0110",
1092 "VATEX-EU-I requires tax category E.",
1093 p0110,
1094 ),
1095 r(
1096 "PEPPOL-EN16931-P0111",
1097 "VATEX-EU-J requires tax category E.",
1098 p0111,
1099 ),
1100 r(
1101 "PEPPOL-EN16931-R120",
1102 "Line net ≈ qty × (price / base qty), slack ±0.02 inclusive.",
1103 r120,
1104 ),
1105 r(
1106 "PEPPOL-EN16931-R040",
1107 "Allowance/charge amount ≈ base × percent/100, slack ±0.02 inclusive.",
1108 r040,
1109 ),
1110 r(
1111 "PEPPOL-EN16931-R046",
1112 "Net price = gross − discount, exact.",
1113 r046,
1114 ),
1115 r(
1116 "PEPPOL-EN16931-R121",
1117 "Base quantity shall be greater than zero.",
1118 r121,
1119 ),
1120 r(
1121 "PEPPOL-EN16931-R002",
1122 "No more than one note on document level unless both parties are German.",
1123 r002,
1124 ),
1125 r(
1126 "PEPPOL-EN16931-R041",
1127 "Allowance/charge base amount MUST be provided when percentage is provided.",
1128 r041,
1129 ),
1130 r(
1131 "PEPPOL-EN16931-R042",
1132 "Allowance/charge percentage MUST be provided when base amount is provided.",
1133 r042,
1134 ),
1135 r(
1136 "PEPPOL-EN16931-R054",
1137 "Tax total without subtotals (BT-111) iff tax currency (BT-6).",
1138 r054,
1139 ),
1140 r(
1141 "PEPPOL-EN16931-R101",
1142 "Line document reference is only for invoiced object (code 130).",
1143 r101,
1144 ),
1145 r(
1146 "PEPPOL-EN16931-R110",
1147 "Line period start MUST be within invoice period.",
1148 r110,
1149 ),
1150 r(
1151 "PEPPOL-EN16931-R111",
1152 "Line period end MUST be within invoice period.",
1153 r111,
1154 ),
1155 r(
1156 "PEPPOL-EN16931-R130",
1157 "Price base quantity unit MUST equal invoiced quantity unit.",
1158 r130,
1159 ),
1160 r(
1161 "PEPPOL-EN16931-CL001",
1162 "Attachment mime code must be from the IANA subset.",
1163 cl001,
1164 ),
1165 r(
1166 "PEPPOL-EN16931-CL002",
1167 "Allowance reason code MUST be UNCL 5189.",
1168 cl002,
1169 ),
1170 r(
1171 "PEPPOL-EN16931-CL003",
1172 "Charge reason code MUST be UNCL 7161.",
1173 cl003,
1174 ),
1175 r(
1176 "PEPPOL-EN16931-CL006",
1177 "Invoice period description code MUST be UNCL 2005.",
1178 cl006,
1179 ),
1180 r(
1181 "PEPPOL-EN16931-CL008",
1182 "Endpoint scheme MUST be from the Electronic Address Identifier Scheme list.",
1183 cl008,
1184 ),
1185 r(
1186 "PEPPOL-EN16931-F001",
1187 "A date MUST be formatted YYYY-MM-DD (enforced by Date).",
1188 f001,
1189 ),
1190 r(
1191 "PEPPOL-EN16931-P0101",
1192 "Credit note type code must be in the Peppol billing profile 01 list.",
1193 p0101,
1194 ),
1195 r(
1196 "PEPPOL-EN16931-P0112",
1197 "Invoice type 326 or 384 only when both parties are German.",
1198 p0112,
1199 ),
1200 r(
1201 "PEPPOL-COMMON-R040",
1202 "GLN (EAS 0088) must have a valid GS1 check digit.",
1203 common_r040,
1204 ),
1205 r(
1206 "PEPPOL-COMMON-R041",
1207 "Norwegian organization number (0192) MUST be 9 digits with mod11.",
1208 common_id_explain,
1209 ),
1210 r(
1211 "PEPPOL-COMMON-R042",
1212 "Danish organization number (0184) MUST be 8 digits or DK+8 digits.",
1213 common_id_explain,
1214 ),
1215 r(
1216 "PEPPOL-COMMON-R043",
1217 "Belgian enterprise number (0208) MUST be 10 digits with mod97.",
1218 common_id_explain,
1219 ),
1220 r(
1221 "PEPPOL-COMMON-R044",
1222 "IPA Code (0201) must be 6 alphanumeric characters (warning).",
1223 common_id_explain,
1224 ),
1225 r(
1226 "PEPPOL-COMMON-R045",
1227 "Italian tax code (0210) must be 11 digits or 16-char CF (warning).",
1228 common_id_explain,
1229 ),
1230 r(
1231 "PEPPOL-COMMON-R046",
1232 "Italian tax code on EndpointID 9907 (warning).",
1233 common_id_explain,
1234 ),
1235 r(
1236 "PEPPOL-COMMON-R047",
1237 "Italian VAT Code on 0211 (warning).",
1238 common_id_explain,
1239 ),
1240 r(
1241 "PEPPOL-COMMON-R049",
1242 "Swedish organization number (0007) MUST be 10 digits with Luhn.",
1243 common_id_explain,
1244 ),
1245 r(
1246 "PEPPOL-COMMON-R050",
1247 "Australian Business Number (0151) MUST be 11 digits with ABN checksum.",
1248 common_id_explain,
1249 ),
1250 r(
1251 "PEPPOL-COMMON-R052",
1252 "Danish chamber of commerce number (0096) MUST be 10 digits (warning).",
1253 common_id_explain,
1254 ),
1255 r(
1256 "PEPPOL-COMMON-R053",
1257 "Danish ERSTORG number (0198) MUST be DK+8 digits (warning).",
1258 common_id_explain,
1259 ),
1260 r(
1261 "PEPPOL-EN16931-R006",
1262 "CII-only: at most one invoiced object. UBL is Invoice.invoiced_object: Option.",
1263 syntax_or_option_pass,
1264 ),
1265 r(
1266 "PEPPOL-EN16931-R008",
1267 "Empty XML elements are forbidden (syntax walk, not the semantic model).",
1268 syntax_or_option_pass,
1269 ),
1270 r(
1271 "PEPPOL-EN16931-R043",
1272 "ChargeIndicator must be true or false. Model uses two vecs; writer emits the boolean.",
1273 syntax_or_option_pass,
1274 ),
1275 r(
1276 "PEPPOL-EN16931-R044",
1277 "Price-level charge is forbidden. Price has discount only.",
1278 syntax_or_option_pass,
1279 ),
1280 r(
1281 "PEPPOL-EN16931-R051",
1282 "@currencyID on amounts must equal BT-5 except BT-111 (wire-only).",
1283 syntax_or_option_pass,
1284 ),
1285 r(
1286 "PEPPOL-EN16931-R053",
1287 "Exactly one TaxTotal with subtotals. Model has one tax_breakdown vec.",
1288 syntax_or_option_pass,
1289 ),
1290 r(
1291 "PEPPOL-EN16931-R080",
1292 "At most one project reference. Invoice.project is Option.",
1293 syntax_or_option_pass,
1294 ),
1295 r(
1296 "PEPPOL-EN16931-R100",
1297 "At most one line DocumentReference. Line.invoiced_object is Option.",
1298 syntax_or_option_pass,
1299 ),
1300 r(
1301 "PEPPOL-EN16931-CL007",
1302 "@currencyID must be ISO 4217 (wire). CORE BR-CL-04 covers BT-5.",
1303 syntax_or_option_pass,
1304 ),
1305];
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310 use crate::amount::InvoiceAmount;
1311 use crate::code::Code;
1312 use crate::date::Date;
1313 use crate::identifier::Identifier;
1314 use crate::invoice::{Line, Party, Price};
1315 use crate::numeric::Quantity;
1316 use crate::reconcile::reconcile;
1317 use crate::tax::TaxCategory;
1318 use crate::validate;
1319 use rust_decimal::Decimal;
1320
1321 fn peppol() -> Invoice {
1322 let mut inv = Invoice::blank(
1323 Profile::PeppolBis3,
1324 "EU-1",
1325 "EUR",
1326 {
1327 let mut p = Party::new("S", "DE");
1328 p.vat_identifier = Some(Identifier::new("DE123456789"));
1329 p
1330 },
1331 {
1332 let mut b = Party::new("B", "FR");
1333 b.vat_identifier = Some(Identifier::new("FR12345678901"));
1334 b
1335 },
1336 );
1337 inv.issue_date = Date::parse("2026-01-15").ok();
1338 inv.type_code = Some(Code::new("380"));
1339 inv.payment_terms = Some("Net 30".into());
1340 inv.business_process = Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0".into());
1341 inv.buyer_reference = Some(crate::identifier::DocumentReference::new("PO-1"));
1342 inv.seller.electronic_address = Some(Identifier::schemed("1234567890128", "0088"));
1343 inv.buyer.electronic_address = Some(Identifier::schemed("1234567890135", "0088"));
1344 inv.lines = vec![{
1345 let mut line = Line::new(
1346 "1",
1347 "A",
1348 InvoiceAmount::parse("100.00").unwrap(),
1349 TaxCategory::vat("S", Decimal::from(19)),
1350 );
1351 line.quantity = Some(Quantity::parse("1").unwrap());
1352 line.unit = Some(Code::new("C62"));
1353 line.price = Some(Price {
1354 net: crate::amount::UnitPriceAmount::parse("100.00").unwrap(),
1355 discount: None,
1356 gross: None,
1357 base_qty: None,
1358 base_unit: None,
1359 });
1360 line
1361 }];
1362 reconcile(&mut inv).unwrap();
1363 inv
1364 }
1365
1366 #[test]
1367 fn r120_fails_at_three_cents_not_on_en16931() {
1368 let mut inv = peppol();
1369 inv.lines[0].quantity = Some(Quantity::parse("1").unwrap());
1370 inv.lines[0].price = Some(Price {
1371 net: crate::amount::UnitPriceAmount::parse("100.03").unwrap(),
1372 discount: None,
1373 gross: None,
1374 base_qty: None,
1375 base_unit: None,
1376 });
1377 let report = validate(&inv);
1378 assert!(
1379 report
1380 .findings
1381 .iter()
1382 .any(|f| f.id == "PEPPOL-EN16931-R120"),
1383 "{report}"
1384 );
1385 inv.profile = Profile::En16931;
1386 inv.specification_id = Some(Profile::En16931.specification_id().into());
1387 let report = validate(&inv);
1388 assert!(
1389 report
1390 .findings
1391 .iter()
1392 .all(|f| f.id != "PEPPOL-EN16931-R120"),
1393 "{report}"
1394 );
1395 }
1396
1397 #[test]
1398 fn r120_passes_at_two_cents() {
1399 let mut inv = peppol();
1400 inv.lines[0].quantity = Some(Quantity::parse("1").unwrap());
1401 inv.lines[0].price = Some(Price {
1402 net: crate::amount::UnitPriceAmount::parse("100.02").unwrap(),
1403 discount: None,
1404 gross: None,
1405 base_qty: None,
1406 base_unit: None,
1407 });
1408 let report = validate(&inv);
1409 assert!(
1410 report
1411 .findings
1412 .iter()
1413 .all(|f| f.id != "PEPPOL-EN16931-R120"),
1414 "{report}"
1415 );
1416 }
1417
1418 #[test]
1419 fn r010_buyer_endpoint_not_on_en() {
1420 let mut inv = peppol();
1421 inv.buyer.electronic_address = None;
1422 let report = validate(&inv);
1423 assert!(
1424 report
1425 .findings
1426 .iter()
1427 .any(|f| f.id == "PEPPOL-EN16931-R010"),
1428 "{report}"
1429 );
1430 inv.profile = Profile::En16931;
1431 inv.specification_id = Some(Profile::En16931.specification_id().into());
1432 let report = validate(&inv);
1433 assert!(
1434 report
1435 .findings
1436 .iter()
1437 .all(|f| f.id != "PEPPOL-EN16931-R010"),
1438 "{report}"
1439 );
1440 }
1441
1442 #[test]
1443 fn p0100_forbids_389_on_peppol_not_en() {
1444 let mut inv = peppol();
1445 inv.type_code = Some(Code::new("389"));
1446 let report = validate(&inv);
1447 assert!(
1448 report
1449 .findings
1450 .iter()
1451 .any(|f| f.id == "PEPPOL-EN16931-P0100"),
1452 "{report}"
1453 );
1454 inv.profile = Profile::En16931;
1455 inv.specification_id = Some(Profile::En16931.specification_id().into());
1456 let report = validate(&inv);
1457 assert!(
1458 report
1459 .findings
1460 .iter()
1461 .all(|f| f.id != "PEPPOL-EN16931-P0100"),
1462 "{report}"
1463 );
1464 }
1465
1466 #[test]
1467 fn r003_bt13_not_preceding() {
1468 let mut inv = peppol();
1469 inv.buyer_reference = None;
1470 inv.preceding.push(crate::invoice::PrecedingInvoice {
1471 reference: crate::identifier::DocumentReference::new("INV-OLD"),
1472 issue_date: None,
1473 });
1474 let report = validate(&inv);
1475 assert!(
1476 report
1477 .findings
1478 .iter()
1479 .any(|f| f.id == "PEPPOL-EN16931-R003"),
1480 "BG-3 alone must not satisfy R003: {report}"
1481 );
1482 inv.purchase_order = Some(crate::identifier::DocumentReference::new("PO-13"));
1483 let report = validate(&inv);
1484 assert!(
1485 report
1486 .findings
1487 .iter()
1488 .all(|f| f.id != "PEPPOL-EN16931-R003"),
1489 "{report}"
1490 );
1491 }
1492
1493 #[test]
1494 fn r120_includes_line_charges_minus_allowances() {
1495 let mut inv = peppol();
1496 inv.lines[0].quantity = Some(Quantity::parse("1").unwrap());
1497 inv.lines[0].price = Some(Price {
1498 net: crate::amount::UnitPriceAmount::parse("100.00").unwrap(),
1499 discount: None,
1500 gross: None,
1501 base_qty: None,
1502 base_unit: None,
1503 });
1504 inv.lines[0]
1505 .charges
1506 .push(crate::invoice::LineAllowanceCharge {
1507 amount: InvoiceAmount::parse("5.00").unwrap(),
1508 base: None,
1509 percent: None,
1510 reason: None,
1511 reason_code: None,
1512 });
1513 inv.lines[0].net = InvoiceAmount::parse("105.00").unwrap();
1514 let _ = reconcile(&mut inv);
1515 let report = validate(&inv);
1516 assert!(
1517 report
1518 .findings
1519 .iter()
1520 .all(|f| f.id != "PEPPOL-EN16931-R120"),
1521 "{report}"
1522 );
1523 }
1524
1525 #[test]
1526 fn pint_my_does_not_run_peppol_r001() {
1527 let mut inv = peppol();
1528 inv.profile = Profile::PintMy;
1529 inv.specification_id = Some(Profile::PintMy.specification_id().into());
1530 inv.business_process = None;
1531 inv.seller.legal_registration = Some(Identifier::new("2023010000001"));
1532 inv.seller.tax_registration = Some(Identifier::new("C12345678901"));
1533 inv.buyer.legal_registration = Some(Identifier::new("1999010000001"));
1534 inv.lines[0].tax = TaxCategory::sst("SA", Decimal::from(10));
1535 let _ = reconcile(&mut inv);
1536 let report = validate(&inv);
1537 assert!(
1538 report
1539 .findings
1540 .iter()
1541 .all(|f| !f.id.starts_with("PEPPOL-EN16931")),
1542 "{report}"
1543 );
1544 }
1545
1546 #[test]
1547 fn r002_two_notes_fail_unless_both_de() {
1548 let mut inv = peppol();
1549 inv.notes = vec![
1550 crate::invoice::InvoiceNote {
1551 subject: None,
1552 text: "a".into(),
1553 },
1554 crate::invoice::InvoiceNote {
1555 subject: None,
1556 text: "b".into(),
1557 },
1558 ];
1559 let report = validate(&inv);
1560 assert!(
1561 report
1562 .findings
1563 .iter()
1564 .any(|f| f.id == "PEPPOL-EN16931-R002"),
1565 "{report}"
1566 );
1567 inv.buyer = {
1568 let mut b = crate::invoice::Party::new("B", "DE");
1569 b.vat_identifier = Some(Identifier::new("DE000"));
1570 b.electronic_address = Some(Identifier::schemed("1234567890135", "0088"));
1571 b
1572 };
1573 let report = validate(&inv);
1574 assert!(
1575 report
1576 .findings
1577 .iter()
1578 .all(|f| f.id != "PEPPOL-EN16931-R002"),
1579 "{report}"
1580 );
1581 }
1582
1583 #[test]
1584 fn r041_percent_without_base() {
1585 let mut inv = peppol();
1586 inv.document_charges.push(crate::invoice::AllowanceCharge {
1587 amount: InvoiceAmount::parse("1.00").unwrap(),
1588 base: None,
1589 percent: Some(crate::numeric::Percentage::new(Decimal::from(10))),
1590 reason: None,
1591 reason_code: None,
1592 tax: Some(TaxCategory::vat("S", Decimal::from(19))),
1593 });
1594 let report = validate(&inv);
1595 assert!(
1596 report
1597 .findings
1598 .iter()
1599 .any(|f| f.id == "PEPPOL-EN16931-R041"),
1600 "{report}"
1601 );
1602 }
1603
1604 #[test]
1605 fn r101_rejects_non_130() {
1606 let mut inv = peppol();
1607 inv.lines[0].invoiced_object = Some(Identifier::new("OBJ"));
1608 inv.lines[0].invoiced_object_code = Some(Code::new("50"));
1609 let report = validate(&inv);
1610 assert!(
1611 report
1612 .findings
1613 .iter()
1614 .any(|f| f.id == "PEPPOL-EN16931-R101"),
1615 "{report}"
1616 );
1617 }
1618
1619 #[test]
1620 fn p0101_forbids_380_on_credit_note() {
1621 let mut inv = peppol();
1622 inv.kind = crate::kind::DocumentKind::CreditNote;
1623 inv.type_code = Some(Code::new("380"));
1624 let report = validate(&inv);
1625 assert!(
1626 report
1627 .findings
1628 .iter()
1629 .any(|f| f.id == "PEPPOL-EN16931-P0101"),
1630 "{report}"
1631 );
1632 }
1633
1634 #[test]
1635 fn p0112_326_needs_both_de() {
1636 let mut inv = peppol();
1637 inv.type_code = Some(Code::new("326"));
1638 let report = validate(&inv);
1639 assert!(
1640 report
1641 .findings
1642 .iter()
1643 .any(|f| f.id == "PEPPOL-EN16931-P0112"),
1644 "{report}"
1645 );
1646 }
1647
1648 #[test]
1649 fn common_r040_bad_gln() {
1650 let mut inv = peppol();
1651 inv.seller.electronic_address = Some(Identifier::schemed("1234567890129", "0088"));
1652 let report = validate(&inv);
1653 assert!(
1654 report.findings.iter().any(|f| f.id == "PEPPOL-COMMON-R040"),
1655 "{report}"
1656 );
1657 }
1658
1659 #[test]
1660 fn common_icd_checksums_fatal() {
1661 assert!(norwegian_mod11("123456785"));
1662 assert!(!norwegian_mod11("123456780"));
1663 assert!(belgian_mod97("0123456749"));
1664 assert!(!belgian_mod97("0123456740"));
1665 assert!(swedish_orgnr("5566778899"));
1666 assert!(!swedish_orgnr("5566778890"));
1667 assert!(australian_abn("51824753556"));
1668 assert!(!australian_abn("51824753550"));
1669 assert!(danish_cvr("DK12345678"));
1670 assert!(danish_cvr("12345678"));
1671 assert!(!danish_cvr("123"));
1672 let mut inv = peppol();
1673 inv.seller
1674 .identifiers
1675 .push(Identifier::schemed("123456780", "0192"));
1676 let report = validate(&inv);
1677 assert!(
1678 report.findings.iter().any(|f| f.id == "PEPPOL-COMMON-R041"),
1679 "{report}"
1680 );
1681 inv.seller.identifiers = vec![Identifier::schemed("123456785", "0192")];
1682 assert!(
1683 validate(&inv)
1684 .findings
1685 .iter()
1686 .all(|f| f.id != "PEPPOL-COMMON-R041")
1687 );
1688 inv.seller.identifiers = vec![Identifier::schemed("51824753550", "0151")];
1689 assert!(
1690 validate(&inv)
1691 .findings
1692 .iter()
1693 .any(|f| f.id == "PEPPOL-COMMON-R050")
1694 );
1695 assert!(
1696 crate::explain("PEPPOL-COMMON-R041")
1697 .unwrap()
1698 .contains("0192")
1699 );
1700 assert!(
1701 crate::explain("PEPPOL-COMMON-R049")
1702 .unwrap()
1703 .contains("0007")
1704 );
1705 assert!(
1706 crate::explain("PEPPOL-COMMON-R043")
1707 .unwrap()
1708 .contains("0208")
1709 );
1710 assert!(crate::explain("PEPPOL-COMMON-R042").is_some());
1711 assert!(crate::explain("PEPPOL-COMMON-R044").is_some());
1712 assert!(crate::explain("PEPPOL-COMMON-R045").is_some());
1713 assert!(crate::explain("PEPPOL-COMMON-R046").is_some());
1714 assert!(crate::explain("PEPPOL-COMMON-R047").is_some());
1715 assert!(crate::explain("PEPPOL-COMMON-R052").is_some());
1716 assert!(crate::explain("PEPPOL-COMMON-R053").is_some());
1717 assert!(crate::explain("PEPPOL-COMMON-R050").is_some());
1718 }
1719
1720 #[test]
1721 fn r046_one_cent_fails_exact() {
1722 let mut inv = peppol();
1723 inv.lines[0].price = Some(Price {
1724 net: crate::amount::UnitPriceAmount::parse("100.00").unwrap(),
1725 discount: Some(crate::amount::UnitPriceAmount::parse("1.00").unwrap()),
1726 gross: Some(crate::amount::UnitPriceAmount::parse("100.99").unwrap()),
1727 base_qty: None,
1728 base_unit: None,
1729 });
1730 let report = validate(&inv);
1731 assert!(
1732 report
1733 .findings
1734 .iter()
1735 .any(|f| f.id == "PEPPOL-EN16931-R046"),
1736 "{report}"
1737 );
1738 }
1739
1740 #[test]
1741 fn r061_fires_for_means_code_59_without_mandate() {
1742 let mut inv = peppol();
1743 inv.payment = Some(crate::invoice::PaymentInstructions {
1744 means_code: Some(Code::new("59")),
1745 means_text: None,
1746 remittance: None,
1747 means: None,
1748 });
1749 let report = validate(&inv);
1750 assert!(
1751 report
1752 .findings
1753 .iter()
1754 .any(|f| f.id == "PEPPOL-EN16931-R061"),
1755 "{report}"
1756 );
1757 }
1758
1759 #[test]
1760 fn syntax_extras_are_explainable_constant_pass() {
1761 for id in [
1762 "PEPPOL-EN16931-R006",
1763 "PEPPOL-EN16931-R008",
1764 "PEPPOL-EN16931-R043",
1765 "PEPPOL-EN16931-R044",
1766 "PEPPOL-EN16931-R051",
1767 "PEPPOL-EN16931-R053",
1768 "PEPPOL-EN16931-R080",
1769 "PEPPOL-EN16931-R100",
1770 "PEPPOL-EN16931-CL007",
1771 ] {
1772 assert!(crate::explain(id).is_some(), "{id}");
1773 }
1774 }
1775}