Skip to main content

core_invoice/
category.rs

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