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