Skip to main content

invoice_cli/
tax.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Tax profiles — jurisdiction-specific invoice behaviour.
3//
4// Each profile knows:
5//   - tax label (GST / VAT / Sales tax / …)
6//   - default rate
7//   - currency + symbol
8//   - whether "Tax Invoice" title is required when registered
9//   - label for the registration number ("GST Reg. No." / "VAT No." / …)
10//   - date format convention
11//   - whether reverse-charge applies for cross-border B2B
12// ═══════════════════════════════════════════════════════════════════════════
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Jurisdiction {
19    Sg,
20    Uk,
21    Us,
22    Eu,
23    Custom,
24}
25
26impl Jurisdiction {
27    pub fn from_str(s: &str) -> Option<Self> {
28        match s.to_lowercase().as_str() {
29            "sg" | "singapore" => Some(Self::Sg),
30            "uk" | "gb" | "gbr" | "united-kingdom" => Some(Self::Uk),
31            "us" | "usa" | "united-states" => Some(Self::Us),
32            "eu" | "de" | "fr" | "nl" | "at" | "ie" => Some(Self::Eu),
33            "custom" | "intl" | "international" => Some(Self::Custom),
34            _ => None,
35        }
36    }
37
38    pub fn profile(&self) -> TaxProfile {
39        match self {
40            Self::Sg => TaxProfile {
41                code: "sg",
42                country: "Singapore",
43                tax_label: "GST",
44                default_rate: 9.0,
45                currency: "SGD",
46                symbol: "S$",
47                tax_invoice_title: "Tax Invoice",
48                non_registered_title: "Invoice",
49                tax_id_label: "GST Reg. No.",
50                company_no_label: "UEN",
51                date_format: "%-d %B %Y",
52                supports_reverse_charge: false,
53                zero_rate_label: "Zero-rated",
54            },
55            Self::Uk => TaxProfile {
56                code: "uk",
57                country: "United Kingdom",
58                tax_label: "VAT",
59                default_rate: 20.0,
60                currency: "GBP",
61                symbol: "£",
62                tax_invoice_title: "VAT Invoice",
63                non_registered_title: "Invoice",
64                tax_id_label: "VAT No.",
65                company_no_label: "Company No.",
66                date_format: "%-d %B %Y",
67                supports_reverse_charge: true,
68                zero_rate_label: "Zero-rated",
69            },
70            Self::Us => TaxProfile {
71                code: "us",
72                country: "United States",
73                tax_label: "Sales tax",
74                default_rate: 0.0,
75                currency: "USD",
76                symbol: "$",
77                tax_invoice_title: "Invoice",
78                non_registered_title: "Invoice",
79                tax_id_label: "EIN",
80                company_no_label: "State ID",
81                date_format: "%B %-d, %Y",
82                supports_reverse_charge: false,
83                zero_rate_label: "Exempt",
84            },
85            Self::Eu => TaxProfile {
86                code: "eu",
87                country: "European Union",
88                tax_label: "VAT",
89                default_rate: 19.0, // Germany default; users override
90                currency: "EUR",
91                symbol: "€",
92                tax_invoice_title: "Rechnung / Invoice",
93                non_registered_title: "Invoice",
94                tax_id_label: "VAT ID",
95                company_no_label: "Reg. No.",
96                date_format: "%-d %B %Y",
97                supports_reverse_charge: true,
98                zero_rate_label: "Reverse charge",
99            },
100            Self::Custom => TaxProfile {
101                code: "custom",
102                country: "International",
103                tax_label: "Tax",
104                default_rate: 0.0,
105                currency: "USD",
106                symbol: "$",
107                tax_invoice_title: "Invoice",
108                non_registered_title: "Invoice",
109                tax_id_label: "Tax ID",
110                company_no_label: "Reg. No.",
111                date_format: "%Y-%m-%d",
112                supports_reverse_charge: false,
113                zero_rate_label: "Zero-rated",
114            },
115        }
116    }
117
118    pub fn as_str(&self) -> &'static str {
119        match self {
120            Self::Sg => "sg",
121            Self::Uk => "uk",
122            Self::Us => "us",
123            Self::Eu => "eu",
124            Self::Custom => "custom",
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize)]
130pub struct TaxProfile {
131    pub code: &'static str,
132    pub country: &'static str,
133    pub tax_label: &'static str,
134    pub default_rate: f64,
135    pub currency: &'static str,
136    pub symbol: &'static str,
137    pub tax_invoice_title: &'static str,
138    pub non_registered_title: &'static str,
139    pub tax_id_label: &'static str,
140    pub company_no_label: &'static str,
141    pub date_format: &'static str,
142    pub supports_reverse_charge: bool,
143    pub zero_rate_label: &'static str,
144}
145
146impl TaxProfile {
147    pub fn title(&self, tax_registered: bool) -> &'static str {
148        if tax_registered {
149            self.tax_invoice_title
150        } else {
151            self.non_registered_title
152        }
153    }
154}
155
156pub fn all_profiles() -> Vec<TaxProfile> {
157    vec![
158        Jurisdiction::Sg.profile(),
159        Jurisdiction::Uk.profile(),
160        Jurisdiction::Us.profile(),
161        Jurisdiction::Eu.profile(),
162        Jurisdiction::Custom.profile(),
163    ]
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn sg_gst_defaults() {
172        let p = Jurisdiction::Sg.profile();
173        assert_eq!(p.tax_label, "GST");
174        assert_eq!(p.default_rate, 9.0);
175        assert_eq!(p.currency, "SGD");
176        assert_eq!(p.title(true), "Tax Invoice");
177        assert_eq!(p.title(false), "Invoice");
178    }
179
180    #[test]
181    fn uk_vat_defaults() {
182        let p = Jurisdiction::Uk.profile();
183        assert_eq!(p.tax_label, "VAT");
184        assert_eq!(p.default_rate, 20.0);
185        assert_eq!(p.currency, "GBP");
186        assert_eq!(p.title(true), "VAT Invoice");
187    }
188
189    #[test]
190    fn parses_aliases() {
191        assert_eq!(Jurisdiction::from_str("SG"), Some(Jurisdiction::Sg));
192        assert_eq!(Jurisdiction::from_str("united-kingdom"), Some(Jurisdiction::Uk));
193        assert_eq!(Jurisdiction::from_str("gb"), Some(Jurisdiction::Uk));
194        assert_eq!(Jurisdiction::from_str("unknown"), None);
195    }
196}