pub mod generated;
pub mod guard;
use core::fmt;
#[must_use]
pub fn contains(list: &[&str], code: &str) -> bool {
list.binary_search(&code).is_ok()
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum VatCategory {
Standard,
ZeroRated,
Exempt,
ReverseCharge,
IntraCommunity,
Export,
OutOfScope,
CanaryIslands,
CeutaMelilla,
SplitPayment,
}
impl VatCategory {
pub const ALL: [Self; 10] = [
Self::ReverseCharge,
Self::CanaryIslands,
Self::CeutaMelilla,
Self::Exempt,
Self::Standard,
Self::ZeroRated,
Self::Export,
Self::OutOfScope,
Self::IntraCommunity,
Self::SplitPayment,
];
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Standard => "S",
Self::ZeroRated => "Z",
Self::Exempt => "E",
Self::ReverseCharge => "AE",
Self::IntraCommunity => "K",
Self::Export => "G",
Self::OutOfScope => "O",
Self::CanaryIslands => "L",
Self::CeutaMelilla => "M",
Self::SplitPayment => "B",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
Some(match code {
"S" => Self::Standard,
"Z" => Self::ZeroRated,
"E" => Self::Exempt,
"AE" => Self::ReverseCharge,
"K" => Self::IntraCommunity,
"G" => Self::Export,
"O" => Self::OutOfScope,
"L" => Self::CanaryIslands,
"M" => Self::CeutaMelilla,
"B" => Self::SplitPayment,
_ => return None,
})
}
#[must_use]
pub const fn carries_tax(self) -> bool {
matches!(
self,
Self::Standard | Self::CanaryIslands | Self::CeutaMelilla | Self::SplitPayment
)
}
#[must_use]
pub const fn requires_exemption_reason(self) -> bool {
matches!(
self,
Self::Exempt
| Self::ReverseCharge
| Self::IntraCommunity
| Self::Export
| Self::OutOfScope
)
}
#[must_use]
pub const fn forbids_exemption_reason(self) -> bool {
matches!(
self,
Self::Standard | Self::ZeroRated | Self::CanaryIslands | Self::CeutaMelilla
)
}
#[must_use]
pub const fn states_rate(self) -> bool {
!matches!(self, Self::OutOfScope)
}
#[must_use]
pub const fn is_exclusive(self) -> bool {
matches!(self, Self::OutOfScope)
}
}
impl fmt::Display for VatCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.code())
}
}
#[cfg(test)]
mod tests {
use super::generated::*;
use super::*;
const LISTS: &[(&str, &[&str])] = &[
("ALLOWANCE_REASON_CODES", ALLOWANCE_REASON_CODES),
("CHARGE_REASON_CODES", CHARGE_REASON_CODES),
("COUNTRY_CODES", COUNTRY_CODES),
("CREDIT_NOTE_TYPE_CODES", CREDIT_NOTE_TYPE_CODES),
("CURRENCY_CODES", CURRENCY_CODES),
("EAS_SCHEMES", EAS_SCHEMES),
("ICD_SCHEMES", ICD_SCHEMES),
("INVOICE_TYPE_CODES", INVOICE_TYPE_CODES),
("ITEM_CLASSIFICATION_SCHEMES", ITEM_CLASSIFICATION_SCHEMES),
("NOTE_SUBJECT_CODES", NOTE_SUBJECT_CODES),
("PAYMENT_MEANS_CODES", PAYMENT_MEANS_CODES),
("PEPPOL_EAS_SCHEMES", PEPPOL_EAS_SCHEMES),
("PEPPOL_MIME_CODES", PEPPOL_MIME_CODES),
("REFERENCE_QUALIFIERS", REFERENCE_QUALIFIERS),
("UNIT_CODES", UNIT_CODES),
("VATEX_CODES", VATEX_CODES),
("VAT_CATEGORY_CODES", VAT_CATEGORY_CODES),
("VAT_POINT_DATE_CODES", VAT_POINT_DATE_CODES),
];
#[test]
fn every_generated_list_is_sorted_and_unique() {
for (name, list) in LISTS {
assert!(!list.is_empty(), "{name} is empty");
for w in list.windows(2) {
assert!(w[0] < w[1], "{name} is not sorted/unique at {w:?}");
}
}
}
#[test]
fn no_generated_list_escapes_the_sortedness_check() {
let source = include_str!("generated.rs");
let declared: Vec<&str> = source
.lines()
.filter_map(|l| l.strip_prefix("pub static "))
.filter_map(|l| l.split(':').next())
.collect();
assert_eq!(
declared.len(),
18,
"generated.rs declares {} tables; the module documentation says eighteen",
declared.len()
);
for name in &declared {
assert!(
LISTS.iter().any(|(n, _)| n == name),
"{name} is in generated.rs and not in LISTS, so nothing asserts \
it is sorted — and `contains` binary-searches it"
);
}
assert_eq!(
LISTS.len(),
declared.len(),
"LISTS names a table generated.rs does not declare"
);
}
#[test]
fn vat_category_round_trips_and_is_case_sensitive() {
for c in VatCategory::ALL {
assert_eq!(VatCategory::from_code(c.code()), Some(c));
assert!(
contains(VAT_CATEGORY_CODES, c.code()),
"{c} missing from BR-CL-17"
);
}
assert_eq!(VAT_CATEGORY_CODES.len(), VatCategory::ALL.len());
assert_eq!(VatCategory::from_code("ae"), None);
assert_eq!(VatCategory::from_code("Q"), None);
}
#[test]
fn zero_rated_and_exempt_differ_on_the_reason() {
assert!(!VatCategory::ZeroRated.carries_tax());
assert!(!VatCategory::Exempt.carries_tax());
assert!(VatCategory::ZeroRated.forbids_exemption_reason());
assert!(VatCategory::Exempt.requires_exemption_reason());
}
#[test]
fn split_payment_is_the_only_category_with_neither_reason_rule() {
for c in VatCategory::ALL {
let neither = !c.requires_exemption_reason() && !c.forbids_exemption_reason();
assert_eq!(
neither,
c == VatCategory::SplitPayment,
"{c} should{} be the neither-case",
if c == VatCategory::SplitPayment {
""
} else {
" not"
}
);
}
assert!(
VatCategory::SplitPayment.carries_tax(),
"B is taxed, unlike AE"
);
}
#[test]
fn only_out_of_scope_suppresses_the_line_rate_and_is_exclusive() {
for c in VatCategory::ALL {
assert_eq!(c.states_rate(), c != VatCategory::OutOfScope);
assert_eq!(c.is_exclusive(), c == VatCategory::OutOfScope);
}
}
#[test]
fn the_two_bt_3_lists_are_not_disjoint_but_split_380_from_381() {
assert!(contains(INVOICE_TYPE_CODES, "380"));
assert!(!contains(INVOICE_TYPE_CODES, "381"));
assert!(contains(CREDIT_NOTE_TYPE_CODES, "381"));
assert!(!contains(CREDIT_NOTE_TYPE_CODES, "380"));
let shared: Vec<_> = INVOICE_TYPE_CODES
.iter()
.filter(|c| contains(CREDIT_NOTE_TYPE_CODES, c))
.collect();
assert_eq!(shared, [&"81"], "exactly one overlap: 81");
assert_eq!(
(INVOICE_TYPE_CODES.len(), CREDIT_NOTE_TYPE_CODES.len()),
(50, 13)
);
}
#[test]
fn spot_checks_against_the_standard() {
assert!(contains(UNIT_CODES, "KWH")); assert!(contains(UNIT_CODES, "H87")); assert!(contains(UNIT_CODES, "C62")); assert!(contains(CURRENCY_CODES, "EUR"));
assert!(
contains(CURRENCY_CODES, "XXX"),
"in ISO 4217, rejected by us separately"
);
assert!(contains(COUNTRY_CODES, "DE"));
assert!(contains(PAYMENT_MEANS_CODES, "58")); assert!(contains(VATEX_CODES, "VATEX-EU-AE"));
assert_eq!(VAT_POINT_DATE_CODES, ["3", "35", "432"]);
}
}