Skip to main content

core_invoice/
peppol.rs

1//! Peppol BIS Billing 3.0 extra_rules. Not CORE, not inherited by PINT.
2
3use 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    // Profile 01. Profile 02 is Later.
41    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    // PEPPOL-EN16931-R010: Buyer EndpointID. Extra_rule, not CORE. (R020 is seller.)
71    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    // PEPPOL-EN16931-R020: Seller EndpointID. Extra_rule, not CORE.
92    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    // PEPPOL-EN16931-R061: codes 49 and 59 (SEPA DD). Extra_rule, not CORE.
159    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    // PEPPOL-EN16931-R003: BT-10 or BT-13, not BG-3.
257    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
274/// ±0.02 **inclusive**. A rule instance, not a crate constant. R046 is exact.
275fn 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    // PEPPOL-EN16931-R002: at most one Note, unless both parties are DE.
393    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    // PEPPOL-EN16931-R041: base amount MUST be provided when percentage is provided.
416    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    // PEPPOL-EN16931-R042: percentage MUST be provided when base amount is provided.
443    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    // PEPPOL-EN16931-R054: tax total without subtotals (BT-111) iff tax currency (BT-6).
470    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    // PEPPOL-EN16931-F001: dates are YYYY-MM-DD. Date::parse already refuses other shapes.
665}
666
667fn syntax_or_option_pass(_inv: &Invoice, _report: &mut Report) {
668    // Syntax-only or Option-at-most-one. Registered so explain works.
669    // Formats `peppol_syntax` / `walk_currency` walk XML for R006/R008/R043/R044/R051/R053/R080(UBL CN)/R100/CL007.
670    // Model has no XML. CII R080 (TypeCode 50 vs BT-11) stays unwalked.
671}
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
709/// GS1 check digit (PEPPOL-COMMON-R040 / u:gln). Any length ≥ 2; weight 3 from the right of the data digits.
710fn 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
725/// ICD-schemed ids: EndpointID, PartyIdentification, CompanyID (legal registration).
726fn 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                // u:checkPIVAseIT: non-IT prefix passes; IT + 11-digit PIVA is a checksum (not implemented as Fatal).
936                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-R040 eval walks every ICD-schemed slot and emits R040–R053 by scheme.
975    common_checksums(inv, report);
976}
977
978fn common_id_explain(_inv: &Invoice, _report: &mut Report) {
979    // Explain-only. Evaluation is common_checksums (R040 extra_rule).
980}
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
1012/// Peppol BIS Billing 3.0 extra_rules. Not CORE, not inherited by PINT.
1013pub 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}