1use 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
137pub 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#[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 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 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 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 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 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 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 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 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 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 }
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
1585pub 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 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}