Skip to main content

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