Skip to main content

librotenado/
tax.rs

1// SPDX-License-Identifier: MIT
2//! Tax codes (spec part 0001): how a posting is treated for value added tax, per country.
3
4use std::{fmt, str::FromStr};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::{Date, Decimal, Object, ParseError};
9
10/// A tax code: the country in lower case, `-`, and a name of `a-z`, `0-9` and `-` (`de-out-19`). It says how a
11/// posting is treated for VAT; what that means is reference data ([`TaxTable`]).
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct TaxCode(String);
14
15impl TaxCode {
16    /// The code as text.
17    pub fn as_str(&self) -> &str {
18        &self.0
19    }
20}
21
22impl FromStr for TaxCode {
23    type Err = ParseError;
24
25    fn from_str(s: &str) -> Result<Self, Self::Err> {
26        let valid = s.len() <= 64
27            && s.as_bytes().get(2) == Some(&b'-')
28            && s[..2].bytes().all(|b| b.is_ascii_lowercase())
29            && s.len() > 3
30            && s[3..].bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
31        if valid { Ok(TaxCode(s.to_owned())) } else { Err(ParseError::TaxCode(s.to_owned())) }
32    }
33}
34
35impl fmt::Display for TaxCode {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(&self.0)
38    }
39}
40
41impl Serialize for TaxCode {
42    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
43        serializer.serialize_str(&self.0)
44    }
45}
46
47impl<'de> Deserialize<'de> for TaxCode {
48    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
49        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
50    }
51}
52
53/// Whether a tax code is used on what the books' owner supplies or on what it receives.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum TaxDirection {
57    /// Supplies and services rendered: sales.
58    Output,
59    /// Supplies and services received: purchases.
60    Input,
61}
62
63/// What a tax code means.
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65pub struct TaxCodeDefinition {
66    /// The code.
67    pub code: TaxCode,
68    /// Sales or purchases.
69    pub direction: TaxDirection,
70    /// The rate in percent (`"19"`), if the code taxes at one; absent for exempt or non-taxable transactions.
71    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
72    pub rate: Option<Decimal>,
73    /// The provisions it rests on, e.g. `UStG § 4 Nr. 1 Buchst. b`.
74    pub law: String,
75}
76
77/// The tax codes of one country, valid from a date – reference data, published with the protocol.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct TaxTable {
80    /// The country, ISO 3166-1 alpha-2.
81    pub country: crate::Country,
82    /// The first day the table applies to.
83    pub valid_from: Date,
84    /// The codes, each once.
85    pub codes: Vec<TaxCodeDefinition>,
86}
87
88impl Object for TaxCodeDefinition {
89    const TYPE: &'static str = "tax_code";
90}
91
92impl Object for TaxTable {
93    const TYPE: &'static str = "tax_table";
94}
95
96impl TaxTable {
97    /// The German tax codes (`spec/data/tax/de.json`).
98    pub fn de() -> Self {
99        crate::from_root(include_bytes!("../../../spec/data/tax/de.json")).expect("the published table is valid")
100    }
101
102    /// The definition of a code.
103    pub fn get(&self, code: &TaxCode) -> Option<&TaxCodeDefinition> {
104        self.codes.iter().find(|definition| definition.code == *code)
105    }
106
107    /// Whether every code occurs once and starts with the table's country.
108    pub fn is_valid(&self) -> bool {
109        let prefix = format!("{}-", self.country.as_str().to_ascii_lowercase());
110        let mut codes: Vec<&TaxCode> = self.codes.iter().map(|d| &d.code).collect();
111        codes.sort();
112        codes.windows(2).all(|pair| pair[0] != pair[1]) && codes.iter().all(|c| c.as_str().starts_with(&prefix))
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn tax_codes_name_their_country() {
122        for s in ["de-out-19", "de-in-13b-eu-service", "fr-tva-20"] {
123            assert_eq!(s.parse::<TaxCode>().unwrap().as_str(), s);
124        }
125        for s in ["DE-19", "de19", "de-", "de-Out", "deu-19", ""] {
126            assert!(s.parse::<TaxCode>().is_err(), "{s}");
127        }
128    }
129
130    #[test]
131    fn the_german_table() {
132        let table = TaxTable::de();
133        assert!(table.is_valid());
134        let rate = |code: &str| table.get(&code.parse().unwrap()).and_then(|d| d.rate);
135        assert_eq!(rate("de-out-19"), Some("19".parse().unwrap()));
136        assert_eq!(rate("de-in-7"), Some("7".parse().unwrap()));
137        assert_eq!(rate("de-out-ig-supply"), None);
138        assert_eq!(table.get(&"de-out-small-business".parse().unwrap()).unwrap().direction, TaxDirection::Output);
139    }
140}