use rust_decimal::Decimal;
use crate::bt::{Group, Path};
use crate::invoice::{Invoice, VatBreakdown, terms as bt};
use crate::validation::{Findings, Rule, RuleId, Severity, Source};
use crate::{InvoiceAmount, Percentage, VatCategory};
const TOLERANCE: Decimal = Decimal::ONE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Groups {
AtLeastOne,
ExactlyOne,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateRule {
Positive,
Zero,
ZeroOrPositive,
Absent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaxRule {
Zero,
Derived,
}
#[derive(Debug, Clone, Copy)]
pub struct CategoryProfile {
pub category: VatCategory,
pub groups: Groups,
pub rate: RateRule,
pub tax: TaxRule,
}
impl CategoryProfile {
#[must_use]
pub const fn grouped_by_rate(self) -> bool {
matches!(self.groups, Groups::AtLeastOne)
}
}
#[must_use]
pub const fn profile(category: VatCategory) -> CategoryProfile {
use RateRule::{Absent, Positive, Zero as RZero, ZeroOrPositive};
use TaxRule::{Derived, Zero as TZero};
use VatCategory::*;
let (groups, rate, tax) = match category {
Standard => (Groups::AtLeastOne, Positive, Derived),
CanaryIslands | CeutaMelilla => (Groups::AtLeastOne, ZeroOrPositive, Derived),
ZeroRated | Exempt | ReverseCharge | IntraCommunity | Export => {
(Groups::ExactlyOne, RZero, TZero)
}
OutOfScope => (Groups::ExactlyOne, Absent, TZero),
SplitPayment => (Groups::AtLeastOne, ZeroOrPositive, Derived),
};
CategoryProfile {
category,
groups,
rate,
tax,
}
}
fn used_in_content(inv: &Invoice, cat: VatCategory) -> Vec<Option<Percentage>> {
let mut v: Vec<_> = inv
.lines
.iter()
.map(|l| &l.vat)
.chain(inv.allowances.iter().map(|a| &a.vat))
.chain(inv.charges.iter().map(|c| &c.vat))
.filter(|v| v.semantics() == Some(cat))
.map(|v| v.rate)
.collect();
v.sort();
v.dedup();
v
}
pub fn check_groups(inv: &Invoice, p: CategoryProfile, f: &mut Findings<'_>) {
if used_in_content(inv, p.category).is_empty() {
return;
}
let groups = inv
.vat_breakdown
.iter()
.filter(|e| e.semantics() == Some(p.category))
.count();
let ok = match p.groups {
Groups::AtLeastOne => groups >= 1,
Groups::ExactlyOne => groups == 1,
};
if !ok {
f.arithmetic(
Path::group(Group::VatBreakdown),
match p.groups {
Groups::AtLeastOne => "at least one group",
Groups::ExactlyOne => "exactly one group",
},
groups,
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateContext {
Line,
Allowance,
Charge,
}
pub fn check_rate(inv: &Invoice, p: CategoryProfile, ctx: RateContext, f: &mut Findings<'_>) {
let ok = |rate: Option<Percentage>| match p.rate {
RateRule::Positive => rate.is_some_and(Percentage::is_positive),
RateRule::Zero => rate.is_some_and(Percentage::is_zero),
RateRule::ZeroOrPositive => rate.is_some_and(|r| !r.is_negative()),
RateRule::Absent => rate.is_none(),
};
match ctx {
RateContext::Line => {
for (i, line) in inv.lines.iter().enumerate() {
if line.vat.semantics() == Some(p.category) && !ok(line.vat.rate) {
f.at(Path::at_term(Group::Line, i, bt::LINE_VAT_RATE));
}
}
}
RateContext::Allowance => {
for (i, a) in inv.allowances.iter().enumerate() {
if a.vat.semantics() == Some(p.category) && !ok(a.vat.rate) {
f.at(Path::at_term(
Group::DocumentAllowance,
i,
bt::ALLOWANCE_VAT_RATE,
));
}
}
}
RateContext::Charge => {
for (i, c) in inv.charges.iter().enumerate() {
if c.vat.semantics() == Some(p.category) && !ok(c.vat.rate) {
f.at(Path::at_term(Group::DocumentCharge, i, bt::CHARGE_VAT_RATE));
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentifierRule {
SellerAny,
SellerVatOnly,
SellerAnyAndBuyer,
SellerVatAndBuyerVat,
NoneAllowed,
}
pub fn check_identifiers(
inv: &Invoice,
p: CategoryProfile,
ctx: RateContext,
f: &mut Findings<'_>,
) {
let present = match ctx {
RateContext::Line => inv
.lines
.iter()
.any(|l| l.vat.semantics() == Some(p.category)),
RateContext::Allowance => inv
.allowances
.iter()
.any(|a| a.vat.semantics() == Some(p.category)),
RateContext::Charge => inv
.charges
.iter()
.any(|c| c.vat.semantics() == Some(p.category)),
};
if !present {
return;
}
let seller_vat = inv.seller.vat_identifier.is_some();
let seller_tax = inv.seller.tax_registration.is_some();
let rep_vat = inv
.tax_representative
.as_ref()
.is_some_and(|r| r.vat_identifier.is_some());
let buyer_vat = inv.buyer.vat_identifier.is_some();
let buyer_legal = inv.buyer.legal_registration.is_some();
let ok = match identifier_rule(p.category) {
IdentifierRule::SellerAny => seller_vat || seller_tax || rep_vat,
IdentifierRule::SellerVatOnly => seller_vat || rep_vat,
IdentifierRule::SellerAnyAndBuyer => {
(seller_vat || seller_tax || rep_vat) && (buyer_vat || buyer_legal)
}
IdentifierRule::SellerVatAndBuyerVat => (seller_vat || rep_vat) && buyer_vat,
IdentifierRule::NoneAllowed => !seller_vat && !rep_vat && !buyer_vat,
};
if !ok {
f.at(Path::group_term(Group::Seller, bt::SELLER_VAT_ID));
}
}
const fn identifier_rule(category: VatCategory) -> IdentifierRule {
match category {
VatCategory::Export => IdentifierRule::SellerVatOnly,
VatCategory::ReverseCharge => IdentifierRule::SellerAnyAndBuyer,
VatCategory::IntraCommunity => IdentifierRule::SellerVatAndBuyerVat,
VatCategory::OutOfScope => IdentifierRule::NoneAllowed,
_ => IdentifierRule::SellerAny,
}
}
pub fn check_taxable_amount(inv: &Invoice, p: CategoryProfile, f: &mut Findings<'_>) {
for (i, entry) in inv.vat_breakdown.iter().enumerate() {
if entry.semantics() != Some(p.category) {
continue;
}
let matches = |v: &crate::invoice::LineVat| {
v.semantics() == Some(p.category) && (!p.grouped_by_rate() || v.rate == entry.rate)
};
let lines = inv
.lines
.iter()
.filter(|l| matches(&l.vat))
.map(|l| l.net_amount);
let charges = inv
.charges
.iter()
.filter(|c| matches(&c.vat))
.map(|c| c.amount);
let allowances = inv
.allowances
.iter()
.filter(|a| matches(&a.vat))
.map(|a| a.amount);
let (Ok(pos), Ok(neg)) = (
InvoiceAmount::checked_sum(lines.chain(charges)),
InvoiceAmount::checked_sum(allowances),
) else {
return;
};
let Ok(expected) = pos.checked_sub(neg) else {
return;
};
let diff = (expected.into_decimal() - entry.taxable_amount.into_decimal()).abs();
if diff >= TOLERANCE {
f.arithmetic(
Path::at_term(Group::VatBreakdown, i, bt::VAT_TAXABLE_AMOUNT),
expected,
entry.taxable_amount,
);
}
}
}
pub fn check_tax_amount(inv: &Invoice, p: CategoryProfile, f: &mut Findings<'_>) {
for (i, entry) in inv.vat_breakdown.iter().enumerate() {
if entry.semantics() != Some(p.category) {
continue;
}
let path = Path::at_term(Group::VatBreakdown, i, bt::VAT_TAX_AMOUNT);
match p.tax {
TaxRule::Zero => {
if !entry.tax_amount.is_zero() {
f.arithmetic(path, "0.00", entry.tax_amount);
}
}
TaxRule::Derived => {
let rate = entry.rate.map_or(Decimal::ZERO, Percentage::into_decimal);
let base = entry.taxable_amount.into_decimal().abs();
let Some(exact) = base.checked_mul(rate).map(|v| v / Decimal::ONE_HUNDRED) else {
return;
};
let expected = exact.round_dp(2);
let stated = entry.tax_amount.into_decimal().abs();
if (stated - expected).abs() >= TOLERANCE {
f.arithmetic(path, expected, entry.tax_amount);
}
}
}
}
}
pub fn check_exemption_reason(inv: &Invoice, p: CategoryProfile, f: &mut Findings<'_>) {
for (i, entry) in inv.vat_breakdown.iter().enumerate() {
if entry.semantics() != Some(p.category) {
continue;
}
let has = entry.has_exemption_reason();
let bad = (p.category.requires_exemption_reason() && !has)
|| (p.category.forbids_exemption_reason() && has);
if bad {
f.at(Path::at_term(Group::VatBreakdown, i, bt::EXEMPTION_REASON));
}
}
}
macro_rules! family {
($konst:ident, $id:expr, $cat:ident, $checker:ident, $text:expr) => {
#[doc = $text]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::Fatal,
text: $text,
terms: &[],
source: Source::Both,
eval: |inv, f| $checker(inv, profile(VatCategory::$cat), f),
};
};
($konst:ident, $id:expr, $cat:ident, $checker:ident, $ctx:ident, $text:expr) => {
#[doc = $text]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::Fatal,
text: $text,
terms: &[],
source: Source::Both,
eval: |inv, f| $checker(inv, profile(VatCategory::$cat), RateContext::$ctx, f),
};
};
}
macro_rules! category_family {
($cat:ident, $prefix:literal, $konsts:ident) => {
pub mod $konsts {
use super::*;
family!(R01, concat!($prefix, "-01"), $cat, check_groups, concat!(
"An Invoice that contains an Invoice line (BG-25), a Document level allowance \
(BG-20) or a Document level charge (BG-21) with this VAT category shall contain \
the required number of VAT breakdown (BG-23) groups for it. [", $prefix, "-01]"));
family!(R02, concat!($prefix, "-02"), $cat, check_identifiers, Line, concat!(
"An Invoice that contains an Invoice line (BG-25) in this VAT category shall \
contain the tax identifiers the category requires. [", $prefix, "-02]"));
family!(R03, concat!($prefix, "-03"), $cat, check_identifiers, Allowance, concat!(
"An Invoice that contains a Document level allowance (BG-20) in this VAT category \
shall contain the tax identifiers the category requires. [", $prefix, "-03]"));
family!(R04, concat!($prefix, "-04"), $cat, check_identifiers, Charge, concat!(
"An Invoice that contains a Document level charge (BG-21) in this VAT category \
shall contain the tax identifiers the category requires. [", $prefix, "-04]"));
family!(R05, concat!($prefix, "-05"), $cat, check_rate, Line, concat!(
"In an Invoice line (BG-25) in this VAT category the Invoiced item VAT rate \
(BT-152) shall satisfy the category's rate rule. [", $prefix, "-05]"));
family!(R06, concat!($prefix, "-06"), $cat, check_rate, Allowance, concat!(
"In a Document level allowance (BG-20) in this VAT category the VAT rate (BT-96) \
shall satisfy the category's rate rule. [", $prefix, "-06]"));
family!(R07, concat!($prefix, "-07"), $cat, check_rate, Charge, concat!(
"In a Document level charge (BG-21) in this VAT category the VAT rate (BT-103) \
shall satisfy the category's rate rule. [", $prefix, "-07]"));
family!(R08, concat!($prefix, "-08"), $cat, check_taxable_amount, concat!(
"The VAT category taxable amount (BT-116) shall equal the sum of Invoice line net \
amounts (BT-131) plus document level charges (BT-99) minus document level \
allowances (BT-92) in this VAT category. [", $prefix, "-08]"));
family!(R09, concat!($prefix, "-09"), $cat, check_tax_amount, concat!(
"The VAT category tax amount (BT-117) in a VAT breakdown (BG-23) with this VAT \
category shall satisfy the category's tax rule. [", $prefix, "-09]"));
family!(R10, concat!($prefix, "-10"), $cat, check_exemption_reason, concat!(
"A VAT breakdown (BG-23) with this VAT category shall have, or shall not have, a \
VAT exemption reason code (BT-121) or text (BT-120) as the category requires. [",
$prefix, "-10]"));
pub static ALL: &[&Rule] =
&[&R01, &R02, &R03, &R04, &R05, &R06, &R07, &R08, &R09, &R10];
}
};
}
category_family!(Standard, "BR-S", s);
category_family!(ZeroRated, "BR-Z", z);
category_family!(Exempt, "BR-E", e);
category_family!(ReverseCharge, "BR-AE", ae);
category_family!(IntraCommunity, "BR-IC", ic);
category_family!(Export, "BR-G", g);
category_family!(OutOfScope, "BR-O", o);
category_family!(CanaryIslands, "BR-AF", af);
category_family!(CeutaMelilla, "BR-AG", ag);
pub static BR_B_01: Rule = Rule {
id: RuleId::new("BR-B-01"),
severity: Severity::Fatal,
text: "An Invoice where the VAT category code (BT-151, BT-95 or BT-102) is \"Split payment\" \
shall be a domestic Italian invoice.",
terms: &[bt::SELLER_COUNTRY, bt::BUYER_COUNTRY],
source: Source::ArtefactOnly,
eval: |inv, f| {
if used_in_content(inv, VatCategory::SplitPayment).is_empty() {
return;
}
let italian = |c: Option<&crate::invoice::Code>| c.is_some_and(|c| c.as_str() == "IT");
if !italian(inv.seller.address.country.as_ref())
|| !italian(inv.buyer.address.country.as_ref())
{
f.at(Path::term(bt::TYPE_CODE));
}
},
};
pub static BR_B_02: Rule = Rule {
id: RuleId::new("BR-B-02"),
severity: Severity::Fatal,
text: "An Invoice that contains a line, allowance or charge where the VAT category code is \
\"Split payment\" shall not contain one where it is \"Standard rated\".",
terms: &[bt::LINE_VAT_CATEGORY],
source: Source::ArtefactOnly,
eval: |inv, f| {
let used = inv.categories_used();
if used.contains(&VatCategory::SplitPayment) && used.contains(&VatCategory::Standard) {
f.at(Path::group(Group::VatBreakdown));
}
},
};
pub static BR_O_11: Rule = Rule {
id: RuleId::new("BR-O-11"),
severity: Severity::Fatal,
text: "An Invoice that contains a VAT breakdown group (BG-23) with a VAT category code \
(BT-118) \"Not subject to VAT\" shall not contain other VAT breakdown groups (BG-23).",
terms: &[bt::VAT_CATEGORY],
source: Source::Both,
eval: |inv, f| {
let has_o = |e: &VatBreakdown| e.semantics() == Some(VatCategory::OutOfScope);
if inv.vat_breakdown.iter().any(has_o) && inv.vat_breakdown.len() > 1 {
for (i, e) in inv.vat_breakdown.iter().enumerate() {
if !has_o(e) {
f.at(Path::at_term(Group::VatBreakdown, i, bt::VAT_CATEGORY));
}
}
}
},
};
pub static BR_O_12: Rule = Rule {
id: RuleId::new("BR-O-12"),
severity: Severity::Fatal,
text: "An Invoice that contains a VAT breakdown group (BG-23) with a VAT category code \
(BT-118) \"Not subject to VAT\" shall not contain an Invoice line (BG-25), a Document \
level allowance (BG-20) or a Document level charge (BG-21) in another category.",
terms: &[bt::LINE_VAT_CATEGORY],
source: Source::Both,
eval: |inv, f| {
if !inv
.vat_breakdown
.iter()
.any(|e| e.semantics() == Some(VatCategory::OutOfScope))
{
return;
}
for (i, line) in inv.lines.iter().enumerate() {
if line.vat.semantics() != Some(VatCategory::OutOfScope) {
f.at(Path::at_term(Group::Line, i, bt::LINE_VAT_CATEGORY));
}
}
},
};
pub static BR_O_13: Rule = Rule {
id: RuleId::new("BR-O-13"),
severity: Severity::Fatal,
text: "An Invoice that contains a VAT breakdown group (BG-23) with a VAT category code \
(BT-118) \"Not subject to VAT\" shall not contain Document level allowances (BG-20) \
where the VAT category code (BT-95) is not \"Not subject to VAT\".",
terms: &[bt::ALLOWANCE_VAT_CATEGORY],
source: Source::Both,
eval: |inv, f| {
if !has_out_of_scope_group(inv) {
return;
}
for (i, a) in inv.allowances.iter().enumerate() {
if a.vat.semantics() != Some(VatCategory::OutOfScope) {
f.at(Path::at_term(
Group::DocumentAllowance,
i,
bt::ALLOWANCE_VAT_CATEGORY,
));
}
}
},
};
pub static BR_O_14: Rule = Rule {
id: RuleId::new("BR-O-14"),
severity: Severity::Fatal,
text: "An Invoice that contains a VAT breakdown group (BG-23) with a VAT category code \
(BT-118) \"Not subject to VAT\" shall not contain Document level charges (BG-21) \
where the VAT category code (BT-102) is not \"Not subject to VAT\".",
terms: &[bt::CHARGE_VAT_CATEGORY],
source: Source::Both,
eval: |inv, f| {
if !has_out_of_scope_group(inv) {
return;
}
for (i, c) in inv.charges.iter().enumerate() {
if c.vat.semantics() != Some(VatCategory::OutOfScope) {
f.at(Path::at_term(
Group::DocumentCharge,
i,
bt::CHARGE_VAT_CATEGORY,
));
}
}
},
};
fn has_out_of_scope_group(inv: &Invoice) -> bool {
inv.vat_breakdown
.iter()
.any(|e| e.semantics() == Some(VatCategory::OutOfScope))
}
pub static ALL: std::sync::LazyLock<Vec<&'static Rule>> = std::sync::LazyLock::new(|| {
let families: [&[&'static Rule]; 9] = [
s::ALL,
z::ALL,
e::ALL,
ae::ALL,
ic::ALL,
g::ALL,
o::ALL,
af::ALL,
ag::ALL,
];
let extras: [&'static Rule; 6] = [&BR_B_01, &BR_B_02, &BR_O_11, &BR_O_12, &BR_O_13, &BR_O_14];
families
.into_iter()
.flatten()
.copied()
.chain(extras)
.collect()
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_table_matches_the_standards_nine_tables() {
for c in [
VatCategory::Standard,
VatCategory::CanaryIslands,
VatCategory::CeutaMelilla,
] {
assert_eq!(profile(c).groups, Groups::AtLeastOne, "{c}");
assert_eq!(profile(c).tax, TaxRule::Derived, "{c}");
assert!(profile(c).grouped_by_rate(), "{c}");
}
for c in [
VatCategory::ZeroRated,
VatCategory::Exempt,
VatCategory::ReverseCharge,
VatCategory::IntraCommunity,
VatCategory::Export,
VatCategory::OutOfScope,
] {
assert_eq!(profile(c).groups, Groups::ExactlyOne, "{c}");
assert_eq!(profile(c).tax, TaxRule::Zero, "{c}");
assert!(!profile(c).grouped_by_rate(), "{c}");
}
}
#[test]
fn only_out_of_scope_forbids_the_rate_outright() {
for c in VatCategory::ALL {
if c == VatCategory::SplitPayment {
continue; }
assert_eq!(
profile(c).rate == RateRule::Absent,
c == VatCategory::OutOfScope,
"{c}"
);
}
assert_eq!(profile(VatCategory::Standard).rate, RateRule::Positive);
assert_eq!(profile(VatCategory::ZeroRated).rate, RateRule::Zero);
assert_eq!(
profile(VatCategory::CanaryIslands).rate,
RateRule::ZeroOrPositive
);
}
#[test]
fn every_family_emits_its_own_real_id() {
let ids: Vec<_> = ALL.iter().map(|r| r.id.as_str()).collect();
for expect in [
"BR-S-02", "BR-S-06", "BR-S-07", "BR-S-08", "BR-Z-09", "BR-E-10", "BR-AE-01",
"BR-AE-03", "BR-IC-04", "BR-IC-05", "BR-G-08", "BR-O-05", "BR-O-14", "BR-AF-10",
"BR-AG-09", "BR-B-01",
] {
assert!(ids.contains(&expect), "{expect} missing from {ids:?}");
}
assert_eq!(ids.len(), 9 * 10 + 6);
}
#[test]
fn the_standards_ig_ip_spellings_resolve_to_the_artefacts_af_ag() {
let by = |q: &str| ALL.iter().find(|r| r.id.matches(q)).map(|r| r.id.as_str());
assert_eq!(by("BR-IG-1"), Some("BR-AF-01"));
assert_eq!(by("BR-IP-10"), Some("BR-AG-10"));
}
}