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