Skip to main content

core_invoice/
category.rs

1//! Nine UNCL 5305 VAT category families plus PINT-MY aligned tables.
2//!
3//! Finding ids are real (`BR-S-08`, `ALIGNED-IBRP-SA-09`). SST never emits
4//! `BR-S-*`. Reconcile groups with [`grouped_by_rate`] from this table.
5
6use rust_decimal::Decimal;
7
8use crate::amount::InvoiceAmount;
9use crate::arith::{derived_vat, within_vat_tolerance};
10use crate::bt::{BtId, Group, Path};
11use crate::invoice::Invoice;
12use crate::numeric::Percentage;
13use crate::profile::Profile;
14use crate::report::{Finding, Report, Severity, Source};
15use crate::rules::Rule;
16use crate::tax::TaxSystem;
17
18/// UNCL 5305 VAT category. Not PINT-MY SST codes (SA, SE, HVG, LVG, TTX).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum VatCategory {
21    /// `S` — standard rate.
22    Standard,
23    /// `Z` — zero rated.
24    ZeroRated,
25    /// `E` — exempt.
26    Exempt,
27    /// `AE` — reverse charge.
28    ReverseCharge,
29    /// `K` — intra-community supply.
30    IntraCommunity,
31    /// `G` — export.
32    Export,
33    /// `O` — not subject to VAT.
34    OutOfScope,
35    /// `L` — Canary Islands IGIC.
36    CanaryIslands,
37    /// `M` — Ceuta and Melilla IPSI.
38    CeutaMelilla,
39    /// `B` — split payment.
40    SplitPayment,
41}
42
43impl VatCategory {
44    /// UNCL 5305 letter code (`S`, `AE`).
45    pub fn code(self) -> &'static str {
46        match self {
47            Self::Standard => "S",
48            Self::ZeroRated => "Z",
49            Self::Exempt => "E",
50            Self::ReverseCharge => "AE",
51            Self::IntraCommunity => "K",
52            Self::Export => "G",
53            Self::OutOfScope => "O",
54            Self::CanaryIslands => "L",
55            Self::CeutaMelilla => "M",
56            Self::SplitPayment => "B",
57        }
58    }
59
60    /// Parse a UNCL 5305 code, case-insensitive. `None` if not in this enum.
61    pub fn parse(code: &str) -> Option<Self> {
62        Some(match code {
63            "S" | "s" => Self::Standard,
64            "Z" | "z" => Self::ZeroRated,
65            "E" | "e" => Self::Exempt,
66            "AE" | "ae" => Self::ReverseCharge,
67            "K" | "k" => Self::IntraCommunity,
68            "G" | "g" => Self::Export,
69            "O" | "o" => Self::OutOfScope,
70            "L" | "l" => Self::CanaryIslands,
71            "M" | "m" => Self::CeutaMelilla,
72            "B" | "b" => Self::SplitPayment,
73            _ => return None,
74        })
75    }
76
77    /// Whether BG-23 must carry an exemption reason or code.
78    pub fn requires_exemption_reason(self) -> bool {
79        matches!(
80            self,
81            Self::Exempt
82                | Self::ReverseCharge
83                | Self::IntraCommunity
84                | Self::Export
85                | Self::OutOfScope
86        )
87    }
88
89    /// Whether BG-23 must not carry an exemption reason or code.
90    pub fn forbids_exemption_reason(self) -> bool {
91        matches!(
92            self,
93            Self::Standard | Self::ZeroRated | Self::CanaryIslands | Self::CeutaMelilla
94        )
95    }
96}
97
98/// How many BG-23 groups a used category must have.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum Groups {
101    /// One group per used rate (`S`, `L`, `M`, `B`).
102    AtLeastOne,
103    /// One group for the category (`Z`, `E`, `AE`, `K`, `G`, `O`).
104    ExactlyOne,
105}
106
107/// Allowed BT-119 / line rate for the family.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum RateRule {
110    /// Rate must be greater than zero.
111    Positive,
112    /// Rate must be present and zero.
113    Zero,
114    /// Rate must be present and not negative.
115    ZeroOrPositive,
116    /// Rate must be absent (`O`).
117    Absent,
118}
119
120/// Allowed BT-117 for the family.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum TaxRule {
123    /// BT-117 must be 0.
124    Zero,
125    /// BT-117 derived from BT-116 × rate.
126    Derived,
127}
128
129/// Per-family grouping, rate, and tax constraints.
130#[derive(Debug, Clone, Copy)]
131pub struct CategoryProfile {
132    /// UNCL 5305 family.
133    pub category: VatCategory,
134    /// Required BG-23 group count.
135    pub groups: Groups,
136    /// Allowed rate.
137    pub rate: RateRule,
138    /// Allowed BT-117.
139    pub tax: TaxRule,
140}
141
142impl CategoryProfile {
143    /// Whether BG-23 is keyed on rate as well as category.
144    pub const fn grouped_by_rate(self) -> bool {
145        matches!(self.groups, Groups::AtLeastOne)
146    }
147}
148
149/// UNCL 5305 family table: group count, rate, and tax for `category`.
150pub const fn profile(category: VatCategory) -> CategoryProfile {
151    use Groups::{AtLeastOne, ExactlyOne};
152    use RateRule::{Absent, Positive, Zero as RZero, ZeroOrPositive};
153    use TaxRule::{Derived, Zero as TZero};
154    use VatCategory::*;
155    let (groups, rate, tax) = match category {
156        Standard => (AtLeastOne, Positive, Derived),
157        CanaryIslands | CeutaMelilla => (AtLeastOne, ZeroOrPositive, Derived),
158        ZeroRated | Exempt | ReverseCharge | IntraCommunity | Export => (ExactlyOne, RZero, TZero),
159        OutOfScope => (ExactlyOne, Absent, TZero),
160        SplitPayment => (AtLeastOne, ZeroOrPositive, Derived),
161    };
162    CategoryProfile {
163        category,
164        groups,
165        rate,
166        tax,
167    }
168}
169
170/// Shared with [`crate::reconcile()`]: which families key BG-23 on rate.
171pub fn grouped_by_rate(profile_id: Profile, category: &str) -> bool {
172    if profile_id == Profile::PintMy {
173        return matches!(
174            category,
175            "SA" | "SE" | "HVG" | "LVG" | "sa" | "se" | "hvg" | "lvg"
176        );
177    }
178    if let Some(c) = VatCategory::parse(category) {
179        return profile(c).grouped_by_rate();
180    }
181    !matches!(category, "O" | "Z" | "E" | "ZR" | "o" | "z" | "e" | "zr")
182}
183
184fn families_ready(inv: &Invoice) -> bool {
185    inv.totals.is_some() || !inv.tax_breakdown.is_empty()
186}
187
188fn vat_families_apply(inv: &Invoice) -> bool {
189    // Identifier / rate / group rows apply as soon as the category appears on a
190    // line, allowance or charge. CEN unit-test fragments often have neither
191    // BG-22 nor BG-23.
192    !matches!(inv.profile, Profile::PintMy | Profile::Unknown)
193}
194
195fn my_families_apply(inv: &Invoice) -> bool {
196    families_ready(inv) && inv.profile == Profile::PintMy
197}
198
199/// Which repeating group a family row applies to. Artefacts number
200/// line (`-02`/`-05`), allowance (`-03`/`-06`), and charge (`-04`/`-07`) separately.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202enum RateContext {
203    Line,
204    Allowance,
205    Charge,
206}
207
208fn uses_category(inv: &Invoice, cat: VatCategory) -> bool {
209    uses_in(inv, cat, RateContext::Line)
210        || uses_in(inv, cat, RateContext::Allowance)
211        || uses_in(inv, cat, RateContext::Charge)
212        || breakdown_of(inv, cat).next().is_some()
213}
214
215fn uses_in(inv: &Invoice, cat: VatCategory, ctx: RateContext) -> bool {
216    let code = cat.code();
217    match ctx {
218        RateContext::Line => inv
219            .lines
220            .iter()
221            .any(|l| l.tax.system == TaxSystem::Vat && l.tax.code.eq_ignore_ascii_case(code)),
222        RateContext::Allowance => inv.document_allowances.iter().any(|a| {
223            a.tax
224                .as_ref()
225                .is_some_and(|t| t.system == TaxSystem::Vat && t.code.eq_ignore_ascii_case(code))
226        }),
227        RateContext::Charge => inv.document_charges.iter().any(|c| {
228            c.tax
229                .as_ref()
230                .is_some_and(|t| t.system == TaxSystem::Vat && t.code.eq_ignore_ascii_case(code))
231        }),
232    }
233}
234
235fn breakdown_of(
236    inv: &Invoice,
237    cat: VatCategory,
238) -> impl Iterator<Item = (usize, &crate::invoice::TaxBreakdown)> {
239    let code = cat.code();
240    inv.tax_breakdown
241        .iter()
242        .enumerate()
243        .filter(move |(_, e)| e.category.as_str().eq_ignore_ascii_case(code))
244}
245
246fn check_groups(inv: &Invoice, report: &mut Report, p: CategoryProfile, id: &'static str) {
247    if !vat_families_apply(inv) || !uses_category(inv, p.category) {
248        return;
249    }
250    let n = breakdown_of(inv, p.category).count();
251    let ok = match p.groups {
252        Groups::AtLeastOne => n >= 1,
253        Groups::ExactlyOne => n == 1,
254    };
255    if !ok {
256        report.push(Finding::fatal(
257            id,
258            Path::group(Group::TaxBreakdown),
259            format!(
260                "category {} requires {:?} BG-23 group(s), found {n}",
261                p.category.code(),
262                p.groups
263            ),
264        ));
265    }
266}
267
268fn rate_ok(rule: RateRule, rate: Option<Percentage>) -> bool {
269    match rule {
270        RateRule::Positive => rate.is_some_and(Percentage::is_positive),
271        RateRule::Zero => rate.is_some_and(Percentage::is_zero),
272        RateRule::ZeroOrPositive => rate.is_some_and(|r| !r.is_negative()),
273        RateRule::Absent => rate.is_none(),
274    }
275}
276
277fn check_rate_line(inv: &Invoice, report: &mut Report, p: CategoryProfile, id: &'static str) {
278    if !vat_families_apply(inv) {
279        return;
280    }
281    let code = p.category.code();
282    for (i, line) in inv.lines.iter().enumerate() {
283        if line.tax.system != TaxSystem::Vat || !line.tax.code.eq_ignore_ascii_case(code) {
284            continue;
285        }
286        if !rate_ok(p.rate, line.tax.percent) {
287            report.push(Finding::fatal(
288                id,
289                Path::at_term(Group::Line, i, BtId(152)),
290                format!(
291                    "BT-152 rate {:?} is not valid for {}",
292                    line.tax.percent, code
293                ),
294            ));
295        }
296    }
297}
298
299fn check_rate_ac(
300    inv: &Invoice,
301    report: &mut Report,
302    p: CategoryProfile,
303    id: &'static str,
304    ctx: RateContext,
305) {
306    if !vat_families_apply(inv) {
307        return;
308    }
309    let code = p.category.code();
310    let rows: Vec<(usize, Option<Percentage>)> = match ctx {
311        RateContext::Allowance => inv
312            .document_allowances
313            .iter()
314            .enumerate()
315            .filter_map(|(i, a)| {
316                let t = a.tax.as_ref()?;
317                (t.system == TaxSystem::Vat && t.code.eq_ignore_ascii_case(code))
318                    .then_some((i, t.percent))
319            })
320            .collect(),
321        RateContext::Charge => inv
322            .document_charges
323            .iter()
324            .enumerate()
325            .filter_map(|(i, a)| {
326                let t = a.tax.as_ref()?;
327                (t.system == TaxSystem::Vat && t.code.eq_ignore_ascii_case(code))
328                    .then_some((i, t.percent))
329            })
330            .collect(),
331        RateContext::Line => return,
332    };
333    let group = match ctx {
334        RateContext::Allowance => Group::DocumentAllowance,
335        RateContext::Charge => Group::DocumentCharge,
336        RateContext::Line => Group::Line,
337    };
338    for (i, rate) in rows {
339        if !rate_ok(p.rate, rate) {
340            report.push(Finding::fatal(
341                id,
342                Path::at_term(group, i, BtId(96)),
343                format!("rate {rate:?} is not valid for {code} in this context"),
344            ));
345        }
346    }
347}
348
349fn seller_vat(inv: &Invoice) -> bool {
350    inv.seller.vat_identifier.is_some()
351}
352fn seller_tax(inv: &Invoice) -> bool {
353    inv.seller.tax_registration.is_some()
354}
355fn rep_vat(inv: &Invoice) -> bool {
356    inv.tax_representative
357        .as_ref()
358        .is_some_and(|r| r.vat_identifier.is_some())
359}
360fn buyer_vat(inv: &Invoice) -> bool {
361    inv.buyer.vat_identifier.is_some()
362}
363
364fn check_identifiers(inv: &Invoice, report: &mut Report, p: CategoryProfile, id: &'static str) {
365    check_identifiers_in(inv, report, p, id, RateContext::Line);
366}
367
368fn check_identifiers_in(
369    inv: &Invoice,
370    report: &mut Report,
371    p: CategoryProfile,
372    id: &'static str,
373    ctx: RateContext,
374) {
375    if !vat_families_apply(inv) || !uses_in(inv, p.category, ctx) {
376        return;
377    }
378    let ok = match p.category {
379        VatCategory::Export => seller_vat(inv) || rep_vat(inv),
380        VatCategory::ReverseCharge => {
381            (seller_vat(inv) || seller_tax(inv) || rep_vat(inv))
382                && (buyer_vat(inv) || inv.buyer.legal_registration.is_some())
383        }
384        VatCategory::IntraCommunity => (seller_vat(inv) || rep_vat(inv)) && buyer_vat(inv),
385        VatCategory::OutOfScope => !seller_vat(inv) && !rep_vat(inv) && !buyer_vat(inv),
386        _ => seller_vat(inv) || seller_tax(inv) || rep_vat(inv),
387    };
388    if !ok {
389        report.push(Finding::fatal(
390            id,
391            Path::group_term(Group::Seller, BtId(31)),
392            format!(
393                "tax identifier requirement for category {} is not met",
394                p.category.code()
395            ),
396        ));
397    }
398}
399
400fn line_matches(
401    inv: &Invoice,
402    e: &crate::invoice::TaxBreakdown,
403    p: CategoryProfile,
404) -> impl Fn(&crate::tax::TaxCategory) -> bool {
405    let cat = p.category;
406    let grouped = p.grouped_by_rate();
407    let entry_rate = e.rate;
408    let _ = inv;
409    move |t: &crate::tax::TaxCategory| {
410        t.system == TaxSystem::Vat
411            && t.code.eq_ignore_ascii_case(cat.code())
412            && (!grouped || t.percent == entry_rate)
413    }
414}
415
416fn check_taxable(inv: &Invoice, report: &mut Report, p: CategoryProfile, id: &'static str) {
417    if !vat_families_apply(inv) {
418        return;
419    }
420    for (i, e) in breakdown_of(inv, p.category) {
421        let matches = line_matches(inv, e, p);
422        let lines = inv.lines.iter().filter(|l| matches(&l.tax)).map(|l| l.net);
423        let charges = inv
424            .document_charges
425            .iter()
426            .filter(|c| c.tax.as_ref().is_some_and(&matches))
427            .map(|c| c.amount);
428        let allowances = inv
429            .document_allowances
430            .iter()
431            .filter(|a| a.tax.as_ref().is_some_and(&matches))
432            .map(|a| a.amount);
433        let Some(pos) = InvoiceAmount::checked_sum(lines.chain(charges)) else {
434            continue;
435        };
436        let Some(neg) = InvoiceAmount::checked_sum(allowances) else {
437            continue;
438        };
439        let Some(expected) = pos.checked_sub(neg) else {
440            continue;
441        };
442        if !within_vat_tolerance(e.taxable.raw(), expected.raw()) {
443            report.push(Finding::fatal(
444                id,
445                Path::at_term(Group::TaxBreakdown, i, BtId(116)),
446                format!(
447                    "BT-116 {} is not within ±1.00 of group sum {expected}",
448                    e.taxable
449                ),
450            ));
451        }
452    }
453}
454
455fn check_tax(inv: &Invoice, report: &mut Report, p: CategoryProfile, id: &'static str) {
456    if !vat_families_apply(inv) {
457        return;
458    }
459    for (i, e) in breakdown_of(inv, p.category) {
460        let path = Path::at_term(Group::TaxBreakdown, i, BtId(117));
461        match p.tax {
462            TaxRule::Zero => {
463                if !e.tax.is_zero() {
464                    report.push(Finding::fatal(
465                        id,
466                        path,
467                        format!("BT-117 shall be 0 for category {}", p.category.code()),
468                    ));
469                }
470            }
471            TaxRule::Derived => {
472                let rate = e.rate.map_or(Decimal::ZERO, Percentage::as_percent);
473                let Some(expected) = derived_vat(e.taxable.raw(), rate) else {
474                    continue;
475                };
476                if !within_vat_tolerance(e.tax.raw().abs(), expected) {
477                    report.push(Finding::fatal(
478                        id,
479                        path,
480                        format!("BT-117 {} is not derived from BT-116 × rate", e.tax),
481                    ));
482                }
483            }
484        }
485    }
486}
487
488fn check_exemption(inv: &Invoice, report: &mut Report, p: CategoryProfile, id: &'static str) {
489    if !vat_families_apply(inv) {
490        return;
491    }
492    for (i, e) in breakdown_of(inv, p.category) {
493        let has = e
494            .exemption_reason
495            .as_ref()
496            .is_some_and(|s| !s.trim().is_empty())
497            || e.exemption_code.as_ref().is_some_and(|c| !c.is_empty());
498        let bad = (p.category.requires_exemption_reason() && !has)
499            || (p.category.forbids_exemption_reason() && has);
500        if bad {
501            report.push(Finding::fatal(
502                id,
503                Path::at_term(Group::TaxBreakdown, i, BtId(120)),
504                format!("exemption reason rule {id} failed"),
505            ));
506        }
507    }
508}
509
510fn o_group_present(inv: &Invoice) -> bool {
511    inv.tax_breakdown
512        .iter()
513        .any(|e| e.category.as_str().eq_ignore_ascii_case("O"))
514}
515
516fn br_o_11(inv: &Invoice, report: &mut Report) {
517    if !vat_families_apply(inv) || !o_group_present(inv) {
518        return;
519    }
520    // BR-O-11: O group forbids other BG-23 groups.
521    let other_groups = inv
522        .tax_breakdown
523        .iter()
524        .any(|e| !e.category.as_str().eq_ignore_ascii_case("O"));
525    if other_groups {
526        report.push(Finding::fatal(
527            "BR-O-11",
528            Path::group(Group::TaxBreakdown),
529            "An Invoice with VAT category O shall not contain other VAT breakdown groups",
530        ));
531    }
532}
533
534fn br_o_12(inv: &Invoice, report: &mut Report) {
535    if !vat_families_apply(inv) || !o_group_present(inv) {
536        return;
537    }
538    // BR-O-12: O group forbids non-O lines.
539    if inv
540        .lines
541        .iter()
542        .any(|l| l.tax.system == TaxSystem::Vat && !l.tax.code.eq_ignore_ascii_case("O"))
543    {
544        report.push(Finding::fatal(
545            "BR-O-12",
546            Path::group(Group::Line),
547            "An Invoice with VAT category O shall not contain a line that is not O",
548        ));
549    }
550}
551
552fn br_o_13(inv: &Invoice, report: &mut Report) {
553    if !vat_families_apply(inv) || !o_group_present(inv) {
554        return;
555    }
556    if inv.document_allowances.iter().any(|a| {
557        a.tax
558            .as_ref()
559            .is_some_and(|t| t.system == TaxSystem::Vat && !t.code.eq_ignore_ascii_case("O"))
560    }) {
561        report.push(Finding::fatal(
562            "BR-O-13",
563            Path::group(Group::DocumentAllowance),
564            "An Invoice with VAT category O shall not contain a document allowance that is not O",
565        ));
566    }
567}
568
569fn br_o_14(inv: &Invoice, report: &mut Report) {
570    if !vat_families_apply(inv) || !o_group_present(inv) {
571        return;
572    }
573    if inv.document_charges.iter().any(|a| {
574        a.tax
575            .as_ref()
576            .is_some_and(|t| t.system == TaxSystem::Vat && !t.code.eq_ignore_ascii_case("O"))
577    }) {
578        report.push(Finding::fatal(
579            "BR-O-14",
580            Path::group(Group::DocumentCharge),
581            "An Invoice with VAT category O shall not contain a document charge that is not O",
582        ));
583    }
584}
585
586fn check_b_not_with_s(inv: &Invoice, report: &mut Report) {
587    if !vat_families_apply(inv) {
588        return;
589    }
590    if uses_category(inv, VatCategory::SplitPayment) && uses_category(inv, VatCategory::Standard) {
591        report.push(Finding::fatal(
592            "BR-B-02",
593            Path::group(Group::TaxBreakdown),
594            "category B cannot coexist with S",
595        ));
596    }
597}
598
599fn br_co_18(inv: &Invoice, report: &mut Report) {
600    // BR-CO-18: at least one BG-23. A TaxTotal without TaxSubtotal still counts
601    // as "this invoice used tax" once the reader materialises BG-22 from it.
602    if inv.tax_breakdown.is_empty()
603        && (!inv.lines.is_empty()
604            || inv.totals.is_some()
605            || !inv.document_allowances.is_empty()
606            || !inv.document_charges.is_empty())
607    {
608        report.push(Finding::fatal(
609            "BR-CO-18",
610            Path::group(Group::TaxBreakdown),
611            "An Invoice shall at least have one tax breakdown group (BG-23)",
612        ));
613    }
614}
615
616fn my_uses(inv: &Invoice, code: &str) -> bool {
617    inv.lines
618        .iter()
619        .any(|l| l.tax.code.eq_ignore_ascii_case(code))
620}
621
622fn check_my_groups(inv: &Invoice, report: &mut Report, code: &str, id: &'static str) {
623    if !my_families_apply(inv) || !my_uses(inv, code) {
624        return;
625    }
626    let n = inv
627        .tax_breakdown
628        .iter()
629        .filter(|e| e.category.as_str().eq_ignore_ascii_case(code))
630        .count();
631    if n == 0 {
632        report.push(Finding::fatal(
633            id,
634            Path::group(Group::TaxBreakdown),
635            format!("PINT-MY category {code} needs at least one IBG-23 group"),
636        ));
637    }
638}
639
640fn line_has_ttx(line: &crate::invoice::Line) -> bool {
641    line.tax.code.eq_ignore_ascii_case("TTX")
642        || line
643            .extra_tax
644            .iter()
645            .any(|t| t.code.eq_ignore_ascii_case("TTX"))
646}
647
648fn ttx_line_tax_sum(inv: &Invoice) -> Decimal {
649    inv.lines
650        .iter()
651        .filter(|l| line_has_ttx(l))
652        .filter_map(|l| l.tax_total)
653        .map(|a| a.raw())
654        .fold(Decimal::ZERO, |acc, v| acc + v)
655}
656
657fn check_my_taxable(inv: &Invoice, report: &mut Report, code: &str, id: &'static str) {
658    if !my_families_apply(inv) {
659        return;
660    }
661    for (i, e) in inv
662        .tax_breakdown
663        .iter()
664        .enumerate()
665        .filter(|(_, e)| e.category.as_str().eq_ignore_ascii_case(code))
666    {
667        // ALIGNED-IBRP-*-08-MY: exact IBT-116 vs lines + charges − allowances (same as reconcile).
668        let Ok(expected) = crate::reconcile::taxable_for_breakdown(inv, e) else {
669            continue;
670        };
671        if e.taxable != expected {
672            report.push(Finding::fatal(
673                id,
674                Path::at_term(Group::TaxBreakdown, i, BtId(116)),
675                format!(
676                    "IBT-116 {} ≠ Σ lines + charges − allowances {expected}",
677                    e.taxable
678                ),
679            ));
680        }
681    }
682}
683
684fn check_my_tax(inv: &Invoice, report: &mut Report, code: &str, id: &'static str, derived: bool) {
685    if !my_families_apply(inv) {
686        return;
687    }
688    for (i, e) in inv
689        .tax_breakdown
690        .iter()
691        .enumerate()
692        .filter(|(_, e)| e.category.as_str().eq_ignore_ascii_case(code))
693    {
694        let path = Path::at_term(Group::TaxBreakdown, i, BtId(117));
695        if !derived {
696            if !e.tax.is_zero() && !code.eq_ignore_ascii_case("TTX") {
697                report.push(Finding::fatal(
698                    id,
699                    path,
700                    format!("IBT-117 shall be 0 for {code}"),
701                ));
702            }
703            if code.eq_ignore_ascii_case("TTX")
704                && inv
705                    .lines
706                    .iter()
707                    .any(|l| line_has_ttx(l) && l.tax_total.is_some())
708            {
709                // ALIGNED-IBRP-TTX-09-MY: IBT-117 = Σ line TaxTotal on lines with TTX (±0.02).
710                let expected = ttx_line_tax_sum(inv);
711                let two = Decimal::new(2, 2);
712                if (e.tax.raw() - expected).abs() > two {
713                    report.push(Finding::fatal(
714                        id,
715                        path,
716                        format!(
717                            "TTX IBT-117 {} ≠ Σ line TaxTotal on TTX lines {expected}",
718                            e.tax
719                        ),
720                    ));
721                }
722            }
723            continue;
724        }
725        let rate = e.rate.map_or(Decimal::ZERO, Percentage::as_percent);
726        let Some(expected) = derived_vat(e.taxable.raw(), rate) else {
727            continue;
728        };
729        if !within_vat_tolerance(e.tax.raw().abs(), expected) {
730            report.push(Finding::fatal(
731                id,
732                path,
733                format!("IBT-117 {} ≠ IBT-116 × IBT-119 / 100", e.tax),
734            ));
735        }
736    }
737}
738
739fn check_my_no_exemption(inv: &Invoice, report: &mut Report, code: &str, id: &'static str) {
740    if !my_families_apply(inv) {
741        return;
742    }
743    for (i, e) in inv
744        .tax_breakdown
745        .iter()
746        .enumerate()
747        .filter(|(_, e)| e.category.as_str().eq_ignore_ascii_case(code))
748    {
749        if e.exemption_reason.is_some() || e.exemption_code.is_some() {
750            report.push(Finding::fatal(
751                id,
752                Path::at_term(Group::TaxBreakdown, i, BtId(120)),
753                format!("{code} shall not carry an exemption reason"),
754            ));
755        }
756    }
757}
758
759fn check_my_o_exclusive(inv: &Invoice, report: &mut Report) {
760    if !my_families_apply(inv) || !my_uses(inv, "O") {
761        return;
762    }
763    if inv
764        .lines
765        .iter()
766        .any(|l| !l.tax.code.eq_ignore_ascii_case("O"))
767    {
768        report.push(Finding::fatal(
769            "ALIGNED-IBRP-O-11-MY",
770            Path::group(Group::TaxBreakdown),
771            "PINT-MY category O is exclusive",
772        ));
773    }
774}
775
776macro_rules! vat_row {
777    ($fn:ident, $id:literal, $cat:ident, $checker:ident) => {
778        fn $fn(inv: &Invoice, report: &mut Report) {
779            $checker(inv, report, profile(VatCategory::$cat), $id);
780        }
781    };
782}
783
784vat_row!(br_s_01, "BR-S-01", Standard, check_groups);
785vat_row!(br_s_02, "BR-S-02", Standard, check_identifiers);
786vat_row!(br_s_05, "BR-S-05", Standard, check_rate_line);
787vat_row!(br_s_08, "BR-S-08", Standard, check_taxable);
788vat_row!(br_s_09, "BR-S-09", Standard, check_tax);
789vat_row!(br_s_10, "BR-S-10", Standard, check_exemption);
790
791vat_row!(br_z_01, "BR-Z-01", ZeroRated, check_groups);
792vat_row!(br_z_02, "BR-Z-02", ZeroRated, check_identifiers);
793vat_row!(br_z_05, "BR-Z-05", ZeroRated, check_rate_line);
794vat_row!(br_z_08, "BR-Z-08", ZeroRated, check_taxable);
795vat_row!(br_z_09, "BR-Z-09", ZeroRated, check_tax);
796vat_row!(br_z_10, "BR-Z-10", ZeroRated, check_exemption);
797
798vat_row!(br_e_01, "BR-E-01", Exempt, check_groups);
799vat_row!(br_e_02, "BR-E-02", Exempt, check_identifiers);
800vat_row!(br_e_05, "BR-E-05", Exempt, check_rate_line);
801vat_row!(br_e_08, "BR-E-08", Exempt, check_taxable);
802vat_row!(br_e_09, "BR-E-09", Exempt, check_tax);
803vat_row!(br_e_10, "BR-E-10", Exempt, check_exemption);
804
805vat_row!(br_ae_01, "BR-AE-01", ReverseCharge, check_groups);
806vat_row!(br_ae_02, "BR-AE-02", ReverseCharge, check_identifiers);
807vat_row!(br_ae_05, "BR-AE-05", ReverseCharge, check_rate_line);
808vat_row!(br_ae_08, "BR-AE-08", ReverseCharge, check_taxable);
809vat_row!(br_ae_09, "BR-AE-09", ReverseCharge, check_tax);
810vat_row!(br_ae_10, "BR-AE-10", ReverseCharge, check_exemption);
811
812vat_row!(br_ic_01, "BR-IC-01", IntraCommunity, check_groups);
813vat_row!(br_ic_02, "BR-IC-02", IntraCommunity, check_identifiers);
814vat_row!(br_ic_05, "BR-IC-05", IntraCommunity, check_rate_line);
815vat_row!(br_ic_08, "BR-IC-08", IntraCommunity, check_taxable);
816vat_row!(br_ic_09, "BR-IC-09", IntraCommunity, check_tax);
817vat_row!(br_ic_10, "BR-IC-10", IntraCommunity, check_exemption);
818
819vat_row!(br_g_01, "BR-G-01", Export, check_groups);
820vat_row!(br_g_02, "BR-G-02", Export, check_identifiers);
821vat_row!(br_g_05, "BR-G-05", Export, check_rate_line);
822vat_row!(br_g_08, "BR-G-08", Export, check_taxable);
823vat_row!(br_g_09, "BR-G-09", Export, check_tax);
824vat_row!(br_g_10, "BR-G-10", Export, check_exemption);
825
826vat_row!(br_o_01, "BR-O-01", OutOfScope, check_groups);
827vat_row!(br_o_02, "BR-O-02", OutOfScope, check_identifiers);
828vat_row!(br_o_05, "BR-O-05", OutOfScope, check_rate_line);
829vat_row!(br_o_08, "BR-O-08", OutOfScope, check_taxable);
830vat_row!(br_o_09, "BR-O-09", OutOfScope, check_tax);
831vat_row!(br_o_10, "BR-O-10", OutOfScope, check_exemption);
832
833vat_row!(br_af_01, "BR-AF-01", CanaryIslands, check_groups);
834vat_row!(br_af_02, "BR-AF-02", CanaryIslands, check_identifiers);
835vat_row!(br_af_05, "BR-AF-05", CanaryIslands, check_rate_line);
836vat_row!(br_af_08, "BR-AF-08", CanaryIslands, check_taxable);
837vat_row!(br_af_09, "BR-AF-09", CanaryIslands, check_tax);
838vat_row!(br_af_10, "BR-AF-10", CanaryIslands, check_exemption);
839
840vat_row!(br_ag_01, "BR-AG-01", CeutaMelilla, check_groups);
841vat_row!(br_ag_02, "BR-AG-02", CeutaMelilla, check_identifiers);
842vat_row!(br_ag_05, "BR-AG-05", CeutaMelilla, check_rate_line);
843vat_row!(br_ag_08, "BR-AG-08", CeutaMelilla, check_taxable);
844vat_row!(br_ag_09, "BR-AG-09", CeutaMelilla, check_tax);
845vat_row!(br_ag_10, "BR-AG-10", CeutaMelilla, check_exemption);
846
847fn br_s_03(inv: &Invoice, report: &mut Report) {
848    check_identifiers_in(
849        inv,
850        report,
851        profile(VatCategory::Standard),
852        "BR-S-03",
853        RateContext::Allowance,
854    );
855}
856fn br_s_04(inv: &Invoice, report: &mut Report) {
857    check_identifiers_in(
858        inv,
859        report,
860        profile(VatCategory::Standard),
861        "BR-S-04",
862        RateContext::Charge,
863    );
864}
865fn br_s_06(inv: &Invoice, report: &mut Report) {
866    check_rate_ac(
867        inv,
868        report,
869        profile(VatCategory::Standard),
870        "BR-S-06",
871        RateContext::Allowance,
872    );
873}
874fn br_s_07(inv: &Invoice, report: &mut Report) {
875    check_rate_ac(
876        inv,
877        report,
878        profile(VatCategory::Standard),
879        "BR-S-07",
880        RateContext::Charge,
881    );
882}
883
884macro_rules! family_ac {
885    ($cat:expr, $f03:ident, $f04:ident, $f06:ident, $f07:ident, $i03:literal, $i04:literal, $i06:literal, $i07:literal) => {
886        fn $f03(inv: &Invoice, report: &mut Report) {
887            check_identifiers_in(inv, report, profile($cat), $i03, RateContext::Allowance);
888        }
889        fn $f04(inv: &Invoice, report: &mut Report) {
890            check_identifiers_in(inv, report, profile($cat), $i04, RateContext::Charge);
891        }
892        fn $f06(inv: &Invoice, report: &mut Report) {
893            check_rate_ac(inv, report, profile($cat), $i06, RateContext::Allowance);
894        }
895        fn $f07(inv: &Invoice, report: &mut Report) {
896            check_rate_ac(inv, report, profile($cat), $i07, RateContext::Charge);
897        }
898    };
899}
900
901family_ac!(
902    VatCategory::ZeroRated,
903    br_z_03,
904    br_z_04,
905    br_z_06,
906    br_z_07,
907    "BR-Z-03",
908    "BR-Z-04",
909    "BR-Z-06",
910    "BR-Z-07"
911);
912family_ac!(
913    VatCategory::Exempt,
914    br_e_03,
915    br_e_04,
916    br_e_06,
917    br_e_07,
918    "BR-E-03",
919    "BR-E-04",
920    "BR-E-06",
921    "BR-E-07"
922);
923family_ac!(
924    VatCategory::ReverseCharge,
925    br_ae_03,
926    br_ae_04,
927    br_ae_06,
928    br_ae_07,
929    "BR-AE-03",
930    "BR-AE-04",
931    "BR-AE-06",
932    "BR-AE-07"
933);
934family_ac!(
935    VatCategory::IntraCommunity,
936    br_ic_03,
937    br_ic_04,
938    br_ic_06,
939    br_ic_07,
940    "BR-IC-03",
941    "BR-IC-04",
942    "BR-IC-06",
943    "BR-IC-07"
944);
945family_ac!(
946    VatCategory::Export,
947    br_g_03,
948    br_g_04,
949    br_g_06,
950    br_g_07,
951    "BR-G-03",
952    "BR-G-04",
953    "BR-G-06",
954    "BR-G-07"
955);
956family_ac!(
957    VatCategory::OutOfScope,
958    br_o_03,
959    br_o_04,
960    br_o_06,
961    br_o_07,
962    "BR-O-03",
963    "BR-O-04",
964    "BR-O-06",
965    "BR-O-07"
966);
967family_ac!(
968    VatCategory::CanaryIslands,
969    br_af_03,
970    br_af_04,
971    br_af_06,
972    br_af_07,
973    "BR-AF-03",
974    "BR-AF-04",
975    "BR-AF-06",
976    "BR-AF-07"
977);
978family_ac!(
979    VatCategory::CeutaMelilla,
980    br_ag_03,
981    br_ag_04,
982    br_ag_06,
983    br_ag_07,
984    "BR-AG-03",
985    "BR-AG-04",
986    "BR-AG-06",
987    "BR-AG-07"
988);
989
990fn br_ic_11(inv: &Invoice, report: &mut Report) {
991    // BR-IC-11: intra-community invoices need BT-72 or BG-14 dates.
992    if !vat_families_apply(inv) || !uses_category(inv, VatCategory::IntraCommunity) {
993        return;
994    }
995    let has_delivery = inv.delivery.as_ref().and_then(|d| d.date).is_some();
996    let has_period = inv
997        .period
998        .as_ref()
999        .is_some_and(|p| p.start.is_some() || p.end.is_some());
1000    if !has_delivery && !has_period {
1001        report.push(Finding::fatal(
1002            "BR-IC-11",
1003            Path::term(BtId(72)),
1004            "Intra-community: actual delivery date (BT-72) or invoicing period (BG-14) shall not be blank",
1005        ));
1006    }
1007}
1008
1009fn br_ic_12(inv: &Invoice, report: &mut Report) {
1010    // BR-IC-12: intra-community deliver-to country (BT-80) shall not be blank.
1011    if !vat_families_apply(inv) || !uses_category(inv, VatCategory::IntraCommunity) {
1012        return;
1013    }
1014    let country = inv
1015        .delivery
1016        .as_ref()
1017        .and_then(|d| d.address.as_ref())
1018        .and_then(|a| a.country.as_ref())
1019        .map(|c| c.as_str().trim())
1020        .unwrap_or("");
1021    if country.is_empty() {
1022        report.push(Finding::fatal(
1023            "BR-IC-12",
1024            Path::term(BtId(80)),
1025            "Intra-community: deliver-to country (BT-80) shall not be blank",
1026        ));
1027    }
1028}
1029
1030fn br_b_01(inv: &Invoice, report: &mut Report) {
1031    // BR-B-01: split payment (B) shall be a domestic Italian invoice.
1032    if !vat_families_apply(inv) || !uses_category(inv, VatCategory::SplitPayment) {
1033        return;
1034    }
1035    let seller_it = inv.seller.country().eq_ignore_ascii_case("IT");
1036    let buyer_it = inv.buyer.country().eq_ignore_ascii_case("IT");
1037    if !(seller_it && buyer_it) {
1038        report.push(Finding::fatal(
1039            "BR-B-01",
1040            Path::term(BtId(118)),
1041            "Split payment (B) shall be a domestic Italian invoice",
1042        ));
1043    }
1044}
1045
1046fn my_sa_01(i: &Invoice, r: &mut Report) {
1047    check_my_groups(i, r, "SA", "ALIGNED-IBRP-SA-01-MY");
1048}
1049fn my_sa_08(i: &Invoice, r: &mut Report) {
1050    check_my_taxable(i, r, "SA", "ALIGNED-IBRP-SA-08-MY");
1051}
1052fn my_sa_09(i: &Invoice, r: &mut Report) {
1053    check_my_tax(i, r, "SA", "ALIGNED-IBRP-SA-09-MY", true);
1054}
1055fn my_sa_10(i: &Invoice, r: &mut Report) {
1056    check_my_no_exemption(i, r, "SA", "ALIGNED-IBRP-SA-10-MY");
1057}
1058fn my_se_01(i: &Invoice, r: &mut Report) {
1059    check_my_groups(i, r, "SE", "ALIGNED-IBRP-SE-01-MY");
1060}
1061fn my_se_08(i: &Invoice, r: &mut Report) {
1062    check_my_taxable(i, r, "SE", "ALIGNED-IBRP-SE-08-MY");
1063}
1064fn my_se_09(i: &Invoice, r: &mut Report) {
1065    check_my_tax(i, r, "SE", "ALIGNED-IBRP-SE-09-MY", true);
1066}
1067fn my_se_10(i: &Invoice, r: &mut Report) {
1068    check_my_no_exemption(i, r, "SE", "ALIGNED-IBRP-SE-10-MY");
1069}
1070fn my_hvg_08(i: &Invoice, r: &mut Report) {
1071    check_my_taxable(i, r, "HVG", "ALIGNED-IBRP-HVG-08-MY");
1072}
1073fn my_hvg_09(i: &Invoice, r: &mut Report) {
1074    check_my_tax(i, r, "HVG", "ALIGNED-IBRP-HVG-09-MY", true);
1075}
1076fn my_lvg_08(i: &Invoice, r: &mut Report) {
1077    check_my_taxable(i, r, "LVG", "ALIGNED-IBRP-LVG-08-MY");
1078}
1079fn my_lvg_09(i: &Invoice, r: &mut Report) {
1080    check_my_tax(i, r, "LVG", "ALIGNED-IBRP-LVG-09-MY", true);
1081}
1082fn my_e_09(i: &Invoice, r: &mut Report) {
1083    check_my_tax(i, r, "E", "ALIGNED-IBRP-E-09-MY", false);
1084}
1085fn my_ttx_09(i: &Invoice, r: &mut Report) {
1086    check_my_tax(i, r, "TTX", "ALIGNED-IBRP-TTX-09-MY", false);
1087}
1088fn my_hvg_10(i: &Invoice, r: &mut Report) {
1089    check_my_no_exemption(i, r, "HVG", "ALIGNED-IBRP-HVG-10-MY");
1090}
1091fn my_lvg_10(i: &Invoice, r: &mut Report) {
1092    check_my_no_exemption(i, r, "LVG", "ALIGNED-IBRP-LVG-10-MY");
1093}
1094fn my_e_05(inv: &Invoice, report: &mut Report) {
1095    if !my_families_apply(inv) {
1096        return;
1097    }
1098    for (i, line) in inv.lines.iter().enumerate() {
1099        if line.tax.code.eq_ignore_ascii_case("E")
1100            && line
1101                .tax
1102                .percent
1103                .is_some_and(|p| p.as_percent() != Decimal::ZERO)
1104        {
1105            report.push(Finding::fatal(
1106                "ALIGNED-IBRP-E-05-MY",
1107                Path::at_term(Group::Line, i, BtId(152)),
1108                "PINT-MY E line rate MUST be 0",
1109            ));
1110        }
1111    }
1112}
1113fn my_e_08(i: &Invoice, r: &mut Report) {
1114    check_my_taxable(i, r, "E", "ALIGNED-IBRP-E-08-MY");
1115}
1116fn my_o_09(i: &Invoice, r: &mut Report) {
1117    check_my_tax(i, r, "O", "ALIGNED-IBRP-O-09-MY", false);
1118}
1119fn my_ttx_08(inv: &Invoice, report: &mut Report) {
1120    if !my_families_apply(inv) {
1121        return;
1122    }
1123    for (i, e) in inv.tax_breakdown.iter().enumerate() {
1124        let aal =
1125            e.scheme.eq_ignore_ascii_case("AAL") || e.category.as_str().eq_ignore_ascii_case("TTX");
1126        if aal && e.rate.is_some() {
1127            report.push(Finding::fatal(
1128                "ALIGNED-IBRP-TTX-08-MY",
1129                Path::at_term(Group::TaxBreakdown, i, BtId(119)),
1130                "TTX/AAL MUST NOT include a tax percentage",
1131            ));
1132        }
1133    }
1134}
1135fn my_002(inv: &Invoice, report: &mut Report) {
1136    if !my_families_apply(inv) {
1137        return;
1138    }
1139    // Writer stamps process_id(); empty in-memory BT-23 is not this id. Wrong present value is.
1140    let Some(p) = inv
1141        .business_process
1142        .as_deref()
1143        .map(str::trim)
1144        .filter(|s| !s.is_empty())
1145    else {
1146        return;
1147    };
1148    if !p.starts_with("urn:peppol:bis:billing") {
1149        report.push(Finding::fatal(
1150            "ALIGNED-IBRP-002",
1151            Path::term(BtId(23)),
1152            "PINT-MY BT-23 must be urn:peppol:bis:billing",
1153        ));
1154    }
1155}
1156fn my_046(_inv: &Invoice, _report: &mut Report) {
1157    // ALIGNED-IBRP-046: IBT-117 is InvoiceAmount on TaxBreakdown; type-retired. explain works.
1158}
1159fn my_047(inv: &Invoice, report: &mut Report) {
1160    if !my_families_apply(inv) {
1161        return;
1162    }
1163    for (i, e) in inv.tax_breakdown.iter().enumerate() {
1164        if e.category.as_str().trim().is_empty() {
1165            report.push(Finding::fatal(
1166                "ALIGNED-IBRP-047",
1167                Path::at_term(Group::TaxBreakdown, i, BtId(118)),
1168                "Each IBG-23 must have a category code",
1169            ));
1170        }
1171        if e.scheme.eq_ignore_ascii_case("AAL") && !e.category.as_str().eq_ignore_ascii_case("TTX")
1172        {
1173            report.push(Finding::fatal(
1174                "ALIGNED-IBRP-047",
1175                Path::at_term(Group::TaxBreakdown, i, BtId(118)),
1176                "AAL subtotals must be category TTX",
1177            ));
1178        }
1179    }
1180}
1181fn my_048(inv: &Invoice, report: &mut Report) {
1182    if !my_families_apply(inv) {
1183        return;
1184    }
1185    for (i, e) in inv.tax_breakdown.iter().enumerate() {
1186        let ttx =
1187            e.scheme.eq_ignore_ascii_case("AAL") || e.category.as_str().eq_ignore_ascii_case("TTX");
1188        let o = e.category.as_str().eq_ignore_ascii_case("O");
1189        if ttx && e.rate.is_some() {
1190            report.push(Finding::fatal(
1191                "ALIGNED-IBRP-048",
1192                Path::at_term(Group::TaxBreakdown, i, BtId(119)),
1193                "AAL/TTX must not have a rate",
1194            ));
1195        }
1196        if !ttx && !o && e.rate.is_none() {
1197            report.push(Finding::fatal(
1198                "ALIGNED-IBRP-048",
1199                Path::at_term(Group::TaxBreakdown, i, BtId(119)),
1200                "VAT subtotals must have a rate except O",
1201            ));
1202        }
1203    }
1204}
1205
1206const fn r(id: &'static str, text: &'static str, eval: fn(&Invoice, &mut Report)) -> Rule {
1207    Rule {
1208        id,
1209        severity: Severity::Fatal,
1210        text,
1211        source: Source::Both,
1212        eval,
1213    }
1214}
1215
1216const fn my(id: &'static str, text: &'static str, eval: fn(&Invoice, &mut Report)) -> Rule {
1217    Rule {
1218        id,
1219        severity: Severity::Fatal,
1220        text,
1221        source: Source::Crate,
1222        eval,
1223    }
1224}
1225
1226/// VAT family rules plus PINT-MY aligned rows. Fed into [`crate::rules::core_rules`].
1227pub static RULES: &[Rule] = &[
1228    r(
1229        "BR-CO-18",
1230        "An Invoice shall at least have one tax breakdown group (BG-23).",
1231        br_co_18,
1232    ),
1233    r(
1234        "BR-S-01",
1235        "Standard VAT: at least one BG-23 group per used rate.",
1236        br_s_01,
1237    ),
1238    r(
1239        "BR-S-02",
1240        "Standard VAT: seller tax identifier (BT-31, BT-32 or BT-63).",
1241        br_s_02,
1242    ),
1243    r(
1244        "BR-S-03",
1245        "Standard VAT: identifier requirement on document allowance.",
1246        br_s_03,
1247    ),
1248    r(
1249        "BR-S-04",
1250        "Standard VAT: identifier requirement on document charge.",
1251        br_s_04,
1252    ),
1253    r(
1254        "BR-S-05",
1255        "Standard VAT: line rate (BT-152) greater than zero.",
1256        br_s_05,
1257    ),
1258    r(
1259        "BR-S-06",
1260        "Standard VAT: allowance rate greater than zero.",
1261        br_s_06,
1262    ),
1263    r(
1264        "BR-S-07",
1265        "Standard VAT: charge rate greater than zero.",
1266        br_s_07,
1267    ),
1268    r(
1269        "BR-S-08",
1270        "Standard VAT: BT-116 = Σ line net + charges − allowances in the group (±1.00 signed).",
1271        br_s_08,
1272    ),
1273    r(
1274        "BR-S-09",
1275        "Standard VAT: BT-117 derived from BT-116 × rate (±1.00 abs).",
1276        br_s_09,
1277    ),
1278    r(
1279        "BR-S-10",
1280        "Standard VAT: exemption reason forbidden.",
1281        br_s_10,
1282    ),
1283    r(
1284        "BR-Z-01",
1285        "Zero-rated VAT: exactly one BG-23 group.",
1286        br_z_01,
1287    ),
1288    r("BR-Z-02", "Zero-rated VAT: seller tax identifier.", br_z_02),
1289    r(
1290        "BR-Z-03",
1291        "Zero-rated VAT: identifier on document allowance.",
1292        br_z_03,
1293    ),
1294    r(
1295        "BR-Z-04",
1296        "Zero-rated VAT: identifier on document charge.",
1297        br_z_04,
1298    ),
1299    r("BR-Z-05", "Zero-rated VAT: rate = 0.", br_z_05),
1300    r("BR-Z-06", "Zero-rated VAT: allowance rate.", br_z_06),
1301    r("BR-Z-07", "Zero-rated VAT: charge rate.", br_z_07),
1302    r("BR-Z-08", "Zero-rated VAT: BT-116 group sum.", br_z_08),
1303    r("BR-Z-09", "Zero-rated VAT: BT-117 = 0.", br_z_09),
1304    r(
1305        "BR-Z-10",
1306        "Zero-rated VAT: exemption reason forbidden.",
1307        br_z_10,
1308    ),
1309    r("BR-E-01", "Exempt VAT: exactly one BG-23 group.", br_e_01),
1310    r("BR-E-02", "Exempt VAT: seller tax identifier.", br_e_02),
1311    r(
1312        "BR-E-03",
1313        "Exempt VAT: identifier on document allowance.",
1314        br_e_03,
1315    ),
1316    r(
1317        "BR-E-04",
1318        "Exempt VAT: identifier on document charge.",
1319        br_e_04,
1320    ),
1321    r("BR-E-05", "Exempt VAT: rate = 0.", br_e_05),
1322    r("BR-E-06", "Exempt VAT: allowance rate.", br_e_06),
1323    r("BR-E-07", "Exempt VAT: charge rate.", br_e_07),
1324    r("BR-E-08", "Exempt VAT: BT-116 group sum.", br_e_08),
1325    r("BR-E-09", "Exempt VAT: BT-117 = 0.", br_e_09),
1326    r("BR-E-10", "Exempt VAT: exemption reason required.", br_e_10),
1327    r(
1328        "BR-AE-01",
1329        "Reverse charge: exactly one BG-23 group.",
1330        br_ae_01,
1331    ),
1332    r(
1333        "BR-AE-02",
1334        "Reverse charge: seller and buyer identifiers.",
1335        br_ae_02,
1336    ),
1337    r(
1338        "BR-AE-03",
1339        "Reverse charge: identifier on document allowance.",
1340        br_ae_03,
1341    ),
1342    r(
1343        "BR-AE-04",
1344        "Reverse charge: identifier on document charge.",
1345        br_ae_04,
1346    ),
1347    r("BR-AE-05", "Reverse charge: rate = 0.", br_ae_05),
1348    r("BR-AE-06", "Reverse charge: allowance rate.", br_ae_06),
1349    r("BR-AE-07", "Reverse charge: charge rate.", br_ae_07),
1350    r("BR-AE-08", "Reverse charge: BT-116 group sum.", br_ae_08),
1351    r("BR-AE-09", "Reverse charge: BT-117 = 0.", br_ae_09),
1352    r(
1353        "BR-AE-10",
1354        "Reverse charge: exemption reason required.",
1355        br_ae_10,
1356    ),
1357    r(
1358        "BR-IC-01",
1359        "Intra-community: exactly one BG-23 group.",
1360        br_ic_01,
1361    ),
1362    r(
1363        "BR-IC-02",
1364        "Intra-community: seller VAT and buyer VAT.",
1365        br_ic_02,
1366    ),
1367    r(
1368        "BR-IC-03",
1369        "Intra-community: identifier on document allowance.",
1370        br_ic_03,
1371    ),
1372    r(
1373        "BR-IC-04",
1374        "Intra-community: identifier on document charge.",
1375        br_ic_04,
1376    ),
1377    r("BR-IC-05", "Intra-community: rate = 0.", br_ic_05),
1378    r("BR-IC-06", "Intra-community: allowance rate.", br_ic_06),
1379    r("BR-IC-07", "Intra-community: charge rate.", br_ic_07),
1380    r(
1381        "BR-IC-11",
1382        "Intra-community: actual delivery date (BT-72) or invoicing period (BG-14).",
1383        br_ic_11,
1384    ),
1385    r(
1386        "BR-IC-12",
1387        "Intra-community: deliver-to country (BT-80).",
1388        br_ic_12,
1389    ),
1390    r("BR-IC-08", "Intra-community: BT-116 group sum.", br_ic_08),
1391    r("BR-IC-09", "Intra-community: BT-117 = 0.", br_ic_09),
1392    r(
1393        "BR-IC-10",
1394        "Intra-community: exemption reason required.",
1395        br_ic_10,
1396    ),
1397    r("BR-G-01", "Export: exactly one BG-23 group.", br_g_01),
1398    r(
1399        "BR-G-02",
1400        "Export: seller VAT identifier (BT-31 or BT-63).",
1401        br_g_02,
1402    ),
1403    r(
1404        "BR-G-03",
1405        "Export: identifier on document allowance.",
1406        br_g_03,
1407    ),
1408    r("BR-G-04", "Export: identifier on document charge.", br_g_04),
1409    r("BR-G-05", "Export: rate = 0.", br_g_05),
1410    r("BR-G-06", "Export: allowance rate.", br_g_06),
1411    r("BR-G-07", "Export: charge rate.", br_g_07),
1412    r("BR-G-08", "Export: BT-116 group sum.", br_g_08),
1413    r("BR-G-09", "Export: BT-117 = 0.", br_g_09),
1414    r("BR-G-10", "Export: exemption reason required.", br_g_10),
1415    r("BR-O-01", "Out of scope: exactly one BG-23 group.", br_o_01),
1416    r(
1417        "BR-O-02",
1418        "Out of scope: VAT identifiers shall not be present.",
1419        br_o_02,
1420    ),
1421    r(
1422        "BR-O-03",
1423        "Out of scope: identifier on document allowance.",
1424        br_o_03,
1425    ),
1426    r(
1427        "BR-O-04",
1428        "Out of scope: identifier on document charge.",
1429        br_o_04,
1430    ),
1431    r("BR-O-05", "Out of scope: rate absent.", br_o_05),
1432    r("BR-O-06", "Out of scope: allowance rate.", br_o_06),
1433    r("BR-O-07", "Out of scope: charge rate.", br_o_07),
1434    r("BR-O-08", "Out of scope: BT-116 group sum.", br_o_08),
1435    r("BR-O-09", "Out of scope: BT-117 = 0.", br_o_09),
1436    r(
1437        "BR-O-10",
1438        "Out of scope: exemption reason required.",
1439        br_o_10,
1440    ),
1441    r(
1442        "BR-O-11",
1443        "Out of scope VAT breakdown forbids other BG-23 groups.",
1444        br_o_11,
1445    ),
1446    r(
1447        "BR-O-12",
1448        "Out of scope VAT breakdown forbids non-O invoice lines.",
1449        br_o_12,
1450    ),
1451    r(
1452        "BR-O-13",
1453        "Out of scope VAT breakdown forbids non-O document allowances.",
1454        br_o_13,
1455    ),
1456    r(
1457        "BR-O-14",
1458        "Out of scope VAT breakdown forbids non-O document charges.",
1459        br_o_14,
1460    ),
1461    r("BR-AF-01", "IGIC: at least one BG-23 group.", br_af_01),
1462    r("BR-AF-02", "IGIC: seller tax identifier.", br_af_02),
1463    r(
1464        "BR-AF-03",
1465        "IGIC: identifier on document allowance.",
1466        br_af_03,
1467    ),
1468    r("BR-AF-04", "IGIC: identifier on document charge.", br_af_04),
1469    r("BR-AF-05", "IGIC: rate ≥ 0.", br_af_05),
1470    r("BR-AF-06", "IGIC: allowance rate.", br_af_06),
1471    r("BR-AF-07", "IGIC: charge rate.", br_af_07),
1472    r("BR-AF-08", "IGIC: BT-116 group sum.", br_af_08),
1473    r("BR-AF-09", "IGIC: derived tax.", br_af_09),
1474    r("BR-AF-10", "IGIC: exemption reason forbidden.", br_af_10),
1475    r("BR-AG-01", "IPSI: at least one BG-23 group.", br_ag_01),
1476    r("BR-AG-02", "IPSI: seller tax identifier.", br_ag_02),
1477    r(
1478        "BR-AG-03",
1479        "IPSI: identifier on document allowance.",
1480        br_ag_03,
1481    ),
1482    r("BR-AG-04", "IPSI: identifier on document charge.", br_ag_04),
1483    r("BR-AG-05", "IPSI: rate ≥ 0.", br_ag_05),
1484    r("BR-AG-06", "IPSI: allowance rate.", br_ag_06),
1485    r("BR-AG-07", "IPSI: charge rate.", br_ag_07),
1486    r("BR-AG-08", "IPSI: BT-116 group sum.", br_ag_08),
1487    r("BR-AG-09", "IPSI: derived tax.", br_ag_09),
1488    r("BR-AG-10", "IPSI: exemption reason forbidden.", br_ag_10),
1489    r(
1490        "BR-B-01",
1491        "Split payment (B) shall be a domestic Italian invoice.",
1492        br_b_01,
1493    ),
1494    r(
1495        "BR-B-02",
1496        "Split payment cannot coexist with standard rated S.",
1497        check_b_not_with_s,
1498    ),
1499    my(
1500        "ALIGNED-IBRP-SA-01-MY",
1501        "PINT-MY SA: at least one IBG-23 group.",
1502        my_sa_01,
1503    ),
1504    my(
1505        "ALIGNED-IBRP-SA-08-MY",
1506        "PINT-MY SA: IBT-116 = Σ SA lines.",
1507        my_sa_08,
1508    ),
1509    my(
1510        "ALIGNED-IBRP-SA-09-MY",
1511        "PINT-MY SA: IBT-117 = IBT-116 × IBT-119 / 100.",
1512        my_sa_09,
1513    ),
1514    my(
1515        "ALIGNED-IBRP-SA-10-MY",
1516        "PINT-MY SA: exemption reason forbidden.",
1517        my_sa_10,
1518    ),
1519    my(
1520        "ALIGNED-IBRP-SE-01-MY",
1521        "PINT-MY SE: at least one IBG-23 group.",
1522        my_se_01,
1523    ),
1524    my(
1525        "ALIGNED-IBRP-SE-08-MY",
1526        "PINT-MY SE: IBT-116 = Σ SE lines + charges − allowances.",
1527        my_se_08,
1528    ),
1529    my(
1530        "ALIGNED-IBRP-SE-09-MY",
1531        "PINT-MY SE: tax from rate.",
1532        my_se_09,
1533    ),
1534    my(
1535        "ALIGNED-IBRP-SE-10-MY",
1536        "PINT-MY SE: exemption reason forbidden.",
1537        my_se_10,
1538    ),
1539    my(
1540        "ALIGNED-IBRP-HVG-08-MY",
1541        "PINT-MY HVG: IBT-116 group sum.",
1542        my_hvg_08,
1543    ),
1544    my(
1545        "ALIGNED-IBRP-HVG-09-MY",
1546        "PINT-MY HVG: tax from rate.",
1547        my_hvg_09,
1548    ),
1549    my(
1550        "ALIGNED-IBRP-LVG-08-MY",
1551        "PINT-MY LVG: IBT-116 group sum.",
1552        my_lvg_08,
1553    ),
1554    my(
1555        "ALIGNED-IBRP-LVG-09-MY",
1556        "PINT-MY LVG: tax from rate.",
1557        my_lvg_09,
1558    ),
1559    my("ALIGNED-IBRP-E-09-MY", "PINT-MY E: tax = 0.", my_e_09),
1560    my(
1561        "ALIGNED-IBRP-TTX-09-MY",
1562        "PINT-MY TTX: amount = Σ TTX lines.",
1563        my_ttx_09,
1564    ),
1565    my(
1566        "ALIGNED-IBRP-O-11-MY",
1567        "PINT-MY O is exclusive.",
1568        check_my_o_exclusive,
1569    ),
1570    my(
1571        "ALIGNED-IBRP-002",
1572        "PINT-MY BT-23 must be urn:peppol:bis:billing.",
1573        my_002,
1574    ),
1575    my("ALIGNED-IBRP-046", "Each IBG-23 must have IBT-117.", my_046),
1576    my(
1577        "ALIGNED-IBRP-047",
1578        "VAT subtotals need a category; AAL subtotals must be TTX.",
1579        my_047,
1580    ),
1581    my(
1582        "ALIGNED-IBRP-048",
1583        "VAT subtotals must have a rate except O; TTX/AAL must not.",
1584        my_048,
1585    ),
1586    my(
1587        "ALIGNED-IBRP-HVG-10-MY",
1588        "PINT-MY HVG: exemption reason forbidden.",
1589        my_hvg_10,
1590    ),
1591    my(
1592        "ALIGNED-IBRP-LVG-10-MY",
1593        "PINT-MY LVG: exemption reason forbidden.",
1594        my_lvg_10,
1595    ),
1596    my(
1597        "ALIGNED-IBRP-TTX-08-MY",
1598        "TTX/AAL MUST NOT include a tax percentage.",
1599        my_ttx_08,
1600    ),
1601    my(
1602        "ALIGNED-IBRP-E-05-MY",
1603        "PINT-MY E line rate MUST be 0.",
1604        my_e_05,
1605    ),
1606    my(
1607        "ALIGNED-IBRP-E-08-MY",
1608        "PINT-MY E: IBT-116 group sum.",
1609        my_e_08,
1610    ),
1611    my("ALIGNED-IBRP-O-09-MY", "PINT-MY O: tax = 0.", my_o_09),
1612];
1613
1614/// Crate convenience for GST category codes. **Not** a PINT Billing 1.1.2 list.
1615///
1616/// Shared 1.1.2 has no tax-category genericode (`UNCL5305.gc` was removed from
1617/// general PINT in v1.1.1). `SR` / `ZR` are SG-shaped extras on this helper
1618/// only. No `validate()` eval calls this function.
1619pub fn pint_gst_category(code: &str) -> bool {
1620    matches!(code, "S" | "Z" | "AA" | "O" | "SR" | "ZR")
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625    use super::*;
1626    use crate::amount::InvoiceAmount;
1627    use crate::code::Code;
1628    use crate::date::Date;
1629    use crate::identifier::Identifier;
1630    use crate::invoice::{Invoice, Line, Party, TaxBreakdown};
1631    use crate::reconcile::reconcile;
1632    use crate::tax::TaxCategory;
1633    use crate::validate;
1634
1635    fn amt(s: &str) -> InvoiceAmount {
1636        InvoiceAmount::parse(s).unwrap()
1637    }
1638
1639    #[test]
1640    fn pint_gst_category_is_helper_membership_only() {
1641        // No rule eval calls this. SR/ZR are SG-shaped, not 1.1.2 genericode.
1642        assert!(pint_gst_category("S"));
1643        assert!(pint_gst_category("Z"));
1644        assert!(pint_gst_category("AA"));
1645        assert!(pint_gst_category("O"));
1646        assert!(pint_gst_category("SR"));
1647        assert!(pint_gst_category("ZR"));
1648        assert!(!pint_gst_category("SA"));
1649        assert!(!pint_gst_category("E"));
1650    }
1651
1652    fn en_s() -> Invoice {
1653        let mut inv = Invoice::blank(
1654            Profile::En16931,
1655            "INV-1",
1656            "EUR",
1657            {
1658                let mut p = Party::new("S", "DE");
1659                p.vat_identifier = Some(Identifier::new("DE123456789"));
1660                p
1661            },
1662            Party::new("B", "FR"),
1663        );
1664        inv.issue_date = Date::parse("2026-01-15").ok();
1665        inv.type_code = Some(Code::new("380"));
1666        inv.payment_terms = Some("Net 30".into());
1667        inv.lines = vec![Line::new(
1668            "1",
1669            "A",
1670            amt("100.00"),
1671            TaxCategory::vat("S", Decimal::from(19)),
1672        )];
1673        reconcile(&mut inv).unwrap();
1674        inv
1675    }
1676
1677    #[test]
1678    fn wrong_bt116_fails_br_s_08() {
1679        let mut inv = en_s();
1680        inv.tax_breakdown[0].taxable = amt("1.00");
1681        let report = validate(&inv);
1682        assert!(
1683            report.findings.iter().any(|f| f.id == "BR-S-08"),
1684            "{report}"
1685        );
1686    }
1687
1688    #[test]
1689    fn exempt_without_reason_fails_br_e_10() {
1690        let mut inv = en_s();
1691        inv.lines[0].tax = TaxCategory::vat("E", Decimal::from(0));
1692        reconcile(&mut inv).unwrap();
1693        let report = validate(&inv);
1694        assert!(
1695            report.findings.iter().any(|f| f.id == "BR-E-10"),
1696            "{report}"
1697        );
1698    }
1699
1700    #[test]
1701    fn zero_rated_with_exemption_fails_br_z_10() {
1702        let mut inv = en_s();
1703        inv.lines[0].tax = TaxCategory::vat("Z", Decimal::from(0));
1704        reconcile(&mut inv).unwrap();
1705        inv.tax_breakdown[0].exemption_reason = Some("no".into());
1706        let report = validate(&inv);
1707        assert!(
1708            report.findings.iter().any(|f| f.id == "BR-Z-10"),
1709            "{report}"
1710        );
1711    }
1712
1713    #[test]
1714    fn o_mixed_with_s_fails_exclusivity() {
1715        let mut inv = en_s();
1716        inv.lines.push(Line::new(
1717            "2",
1718            "Out",
1719            amt("10.00"),
1720            TaxCategory::vat("O", Decimal::from(0)),
1721        ));
1722        reconcile(&mut inv).unwrap();
1723        let report = validate(&inv);
1724        assert!(
1725            report.findings.iter().any(|f| f.id == "BR-O-11"),
1726            "{report}"
1727        );
1728    }
1729
1730    #[test]
1731    fn sst_does_not_emit_br_s_08() {
1732        let mut inv = Invoice::blank(
1733            Profile::PintMy,
1734            "MY-1",
1735            "MYR",
1736            {
1737                let mut p = Party::new("Kedai", "MY");
1738                p.tax_registration = Some(Identifier::new("C12345678901"));
1739                p.legal_registration = Some(Identifier::new("2023010000001"));
1740                p
1741            },
1742            {
1743                let mut b = Party::new("Pembeli", "MY");
1744                b.legal_registration = Some(Identifier::new("1999010000001"));
1745                b
1746            },
1747        );
1748        inv.issue_date = Date::parse("2026-01-15").ok();
1749        inv.type_code = Some(Code::new("380"));
1750        inv.lines = vec![Line::new(
1751            "1",
1752            "W",
1753            amt("100.00"),
1754            TaxCategory::sst("SA", Decimal::from(10)),
1755        )];
1756        inv.tax_breakdown = vec![TaxBreakdown {
1757            system: TaxSystem::Sst,
1758            scheme: "VAT".into(),
1759            category: Code::new("SA"),
1760            rate: Some(Percentage::new(Decimal::from(10))),
1761            taxable: amt("1.00"),
1762            tax: amt("10.00"),
1763            exemption_reason: None,
1764            exemption_code: None,
1765        }];
1766        inv.totals = Some(crate::invoice::DocumentTotals {
1767            line_net: Some(amt("100.00")),
1768            allowance_total: None,
1769            charge_total: None,
1770            without_tax: Some(amt("100.00")),
1771            tax_total: Some(amt("10.00")),
1772            tax_total_accounting: None,
1773            with_tax: Some(amt("110.00")),
1774            paid: None,
1775            rounding: None,
1776            payable: Some(amt("110.00")),
1777        });
1778        let report = validate(&inv);
1779        assert!(
1780            report.findings.iter().all(|f| f.id != "BR-S-08"),
1781            "{report}"
1782        );
1783        assert!(
1784            report
1785                .findings
1786                .iter()
1787                .any(|f| f.id == "ALIGNED-IBRP-SA-08-MY"),
1788            "{report}"
1789        );
1790    }
1791
1792    #[test]
1793    fn s_line_missing_vat_is_only_br_s_02() {
1794        let mut inv = en_s();
1795        inv.seller.vat_identifier = None;
1796        let report = validate(&inv);
1797        let ids: Vec<_> = report.findings.iter().map(|f| f.id).collect();
1798        assert!(ids.contains(&"BR-S-02"), "{report}");
1799        assert!(!ids.contains(&"BR-S-03"), "{report}");
1800        assert!(!ids.contains(&"BR-S-04"), "{report}");
1801    }
1802
1803    #[test]
1804    fn s_charge_missing_vat_is_only_br_s_04() {
1805        let mut inv = en_s();
1806        inv.seller.vat_identifier = None;
1807        inv.lines[0].tax = TaxCategory::vat("Z", Decimal::from(0));
1808        inv.document_charges.push(crate::invoice::AllowanceCharge {
1809            amount: amt("10.00"),
1810            base: None,
1811            percent: None,
1812            reason: None,
1813            reason_code: None,
1814            tax: Some(TaxCategory::vat("S", Decimal::from(19))),
1815        });
1816        let _ = reconcile(&mut inv);
1817        let report = validate(&inv);
1818        let ids: Vec<_> = report.findings.iter().map(|f| f.id).collect();
1819        assert!(ids.contains(&"BR-S-04"), "{report}");
1820        assert!(!ids.contains(&"BR-S-02"), "{report}");
1821        assert!(!ids.contains(&"BR-S-03"), "{report}");
1822    }
1823
1824    #[test]
1825    fn o_group_plus_s_group_is_o_11() {
1826        let mut inv = en_s();
1827        inv.lines[0].tax = TaxCategory {
1828            system: TaxSystem::Vat,
1829            code: "O".into(),
1830            percent: None,
1831        };
1832        reconcile(&mut inv).unwrap();
1833        inv.tax_breakdown.push(crate::invoice::TaxBreakdown {
1834            system: TaxSystem::Vat,
1835            scheme: "VAT".into(),
1836            category: Code::new("S"),
1837            rate: Some(Percentage::new(Decimal::from(19))),
1838            taxable: amt("0.00"),
1839            tax: amt("0.00"),
1840            exemption_reason: None,
1841            exemption_code: None,
1842        });
1843        let report = validate(&inv);
1844        assert!(
1845            report.findings.iter().any(|f| f.id == "BR-O-11"),
1846            "{report}"
1847        );
1848    }
1849
1850    #[test]
1851    fn o_group_plus_s_line_is_o_12() {
1852        let mut inv = en_s();
1853        inv.lines[0].tax = TaxCategory {
1854            system: TaxSystem::Vat,
1855            code: "O".into(),
1856            percent: None,
1857        };
1858        inv.lines.push(Line::new(
1859            "2",
1860            "Std",
1861            amt("10.00"),
1862            TaxCategory::vat("S", Decimal::from(19)),
1863        ));
1864        let _ = reconcile(&mut inv);
1865        // Keep only the O group so O-12 is the mix on lines, not extra groups.
1866        inv.tax_breakdown
1867            .retain(|e| e.category.as_str().eq_ignore_ascii_case("O"));
1868        let report = validate(&inv);
1869        assert!(
1870            report.findings.iter().any(|f| f.id == "BR-O-12"),
1871            "{report}"
1872        );
1873    }
1874}