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