Skip to main content

stateset_core/models/
tax.rs

1//! Tax calculation engine types
2//!
3//! Provides comprehensive tax support including:
4//! - Multi-jurisdiction tax rates (US sales tax, EU VAT, etc.)
5//! - Product tax categories (taxable, exempt, reduced rate)
6//! - Customer tax exemptions (B2B, non-profits)
7//! - Tax-inclusive vs tax-exclusive pricing
8//! - Compound and tiered tax rules
9
10use chrono::{DateTime, NaiveDate, Utc};
11use rust_decimal::{Decimal, RoundingStrategy};
12use serde::{Deserialize, Serialize};
13use stateset_primitives::{CurrencyCode, ProductId};
14use strum::{Display, EnumString};
15use uuid::Uuid;
16
17// ============================================================================
18// Tax Types and Enums
19// ============================================================================
20
21/// Types of taxes supported
22#[derive(
23    Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, Default,
24)]
25#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
26#[serde(rename_all = "snake_case")]
27#[non_exhaustive]
28pub enum TaxType {
29    /// US Sales Tax (state/local)
30    #[default]
31    SalesTax,
32    /// Value Added Tax (EU, UK, etc.)
33    Vat,
34    /// Goods and Services Tax (Canada, Australia, India)
35    Gst,
36    /// Harmonized Sales Tax (Canadian provinces)
37    Hst,
38    /// Provincial Sales Tax (Canadian provinces)
39    Pst,
40    /// Quebec Sales Tax
41    Qst,
42    /// Consumption Tax (Japan)
43    ConsumptionTax,
44    /// Custom/Other tax type
45    Custom,
46}
47
48impl TaxType {
49    /// Return the canonical string representation
50    #[must_use]
51    pub const fn as_str(&self) -> &'static str {
52        match self {
53            Self::SalesTax => "sales_tax",
54            Self::Vat => "vat",
55            Self::Gst => "gst",
56            Self::Hst => "hst",
57            Self::Pst => "pst",
58            Self::Qst => "qst",
59            Self::ConsumptionTax => "consumption_tax",
60            Self::Custom => "custom",
61        }
62    }
63}
64
65/// Tax calculation method
66#[derive(
67    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
68)]
69#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum TaxCalculationMethod {
73    /// Tax is calculated on top of the price (US style)
74    #[default]
75    Exclusive,
76    /// Tax is included in the price (EU VAT style)
77    Inclusive,
78}
79
80/// How to apply multiple tax rates
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum TaxCompoundMethod {
85    /// Add all taxes together, apply to subtotal
86    #[default]
87    Combined,
88    /// Apply taxes sequentially (tax on tax)
89    Compound,
90    /// Apply taxes separately to subtotal
91    Separate,
92}
93
94impl std::fmt::Display for TaxCompoundMethod {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::Combined => f.write_str("combined"),
98            Self::Compound => f.write_str("compound"),
99            Self::Separate => f.write_str("separate"),
100        }
101    }
102}
103
104impl std::str::FromStr for TaxCompoundMethod {
105    type Err = String;
106
107    fn from_str(s: &str) -> Result<Self, Self::Err> {
108        match s.trim().to_ascii_lowercase().as_str() {
109            "combined" => Ok(Self::Combined),
110            "compound" => Ok(Self::Compound),
111            "separate" => Ok(Self::Separate),
112            _ => Err(format!("Unknown tax compound method: {s}")),
113        }
114    }
115}
116
117/// Product tax category
118#[derive(
119    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
120)]
121#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
122#[serde(rename_all = "snake_case")]
123#[non_exhaustive]
124pub enum ProductTaxCategory {
125    /// Standard taxable goods
126    #[default]
127    Standard,
128    /// Reduced rate (e.g., food, books in some jurisdictions)
129    Reduced,
130    /// Super-reduced rate (e.g., essential food)
131    SuperReduced,
132    /// Zero-rated (taxable at 0%, still reportable)
133    ZeroRated,
134    /// Exempt from tax entirely
135    Exempt,
136    /// Digital goods/services (special rules in many jurisdictions)
137    Digital,
138    /// Clothing (special rules in some US states)
139    Clothing,
140    /// Food for home consumption
141    Food,
142    /// Prepared food/restaurant
143    PreparedFood,
144    /// Medical/health items
145    Medical,
146    /// Educational materials
147    Educational,
148    /// Luxury goods (higher rate in some places)
149    Luxury,
150}
151
152impl ProductTaxCategory {
153    /// Return the canonical string representation
154    #[must_use]
155    pub const fn as_str(&self) -> &'static str {
156        match self {
157            Self::Standard => "standard",
158            Self::Reduced => "reduced",
159            Self::SuperReduced => "super_reduced",
160            Self::ZeroRated => "zero_rated",
161            Self::Exempt => "exempt",
162            Self::Digital => "digital",
163            Self::Clothing => "clothing",
164            Self::Food => "food",
165            Self::PreparedFood => "prepared_food",
166            Self::Medical => "medical",
167            Self::Educational => "educational",
168            Self::Luxury => "luxury",
169        }
170    }
171}
172
173/// Customer exemption type
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176#[non_exhaustive]
177pub enum ExemptionType {
178    /// Wholesale/resale (has resale certificate)
179    Resale,
180    /// Non-profit organization
181    NonProfit,
182    /// Government entity
183    Government,
184    /// Educational institution
185    Educational,
186    /// Religious organization
187    Religious,
188    /// Medical/healthcare
189    Medical,
190    /// Manufacturing (raw materials)
191    Manufacturing,
192    /// Agricultural
193    Agricultural,
194    /// Export (zero-rated for export)
195    Export,
196    /// Diplomatic (embassy, consulate)
197    Diplomatic,
198    /// Other documented exemption
199    Other,
200}
201
202impl std::fmt::Display for ExemptionType {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            Self::Resale => f.write_str("resale"),
206            Self::NonProfit => f.write_str("non_profit"),
207            Self::Government => f.write_str("government"),
208            Self::Educational => f.write_str("educational"),
209            Self::Religious => f.write_str("religious"),
210            Self::Medical => f.write_str("medical"),
211            Self::Manufacturing => f.write_str("manufacturing"),
212            Self::Agricultural => f.write_str("agricultural"),
213            Self::Export => f.write_str("export"),
214            Self::Diplomatic => f.write_str("diplomatic"),
215            Self::Other => f.write_str("other"),
216        }
217    }
218}
219
220impl std::str::FromStr for ExemptionType {
221    type Err = String;
222
223    fn from_str(s: &str) -> Result<Self, Self::Err> {
224        match s.trim().to_ascii_lowercase().as_str() {
225            "resale" => Ok(Self::Resale),
226            "non_profit" | "nonprofit" | "non-profit" => Ok(Self::NonProfit),
227            "government" => Ok(Self::Government),
228            "educational" => Ok(Self::Educational),
229            "religious" => Ok(Self::Religious),
230            "medical" => Ok(Self::Medical),
231            "manufacturing" => Ok(Self::Manufacturing),
232            "agricultural" => Ok(Self::Agricultural),
233            "export" => Ok(Self::Export),
234            "diplomatic" => Ok(Self::Diplomatic),
235            "other" => Ok(Self::Other),
236            _ => Err(format!("Unknown exemption type: {s}")),
237        }
238    }
239}
240
241// ============================================================================
242// Core Tax Entities
243// ============================================================================
244
245/// A tax jurisdiction (country, state, city, district)
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct TaxJurisdiction {
248    pub id: Uuid,
249    /// Parent jurisdiction (e.g., state is parent of city)
250    pub parent_id: Option<Uuid>,
251    /// Jurisdiction name
252    pub name: String,
253    /// Jurisdiction code (e.g., "US-CA", "US-CA-LA")
254    pub code: String,
255    /// Jurisdiction level
256    pub level: JurisdictionLevel,
257    /// Country code (ISO 3166-1 alpha-2)
258    pub country_code: String,
259    /// State/province code (ISO 3166-2)
260    pub state_code: Option<String>,
261    /// County/region name
262    pub county: Option<String>,
263    /// City name
264    pub city: Option<String>,
265    /// Postal codes covered (can be ranges or patterns)
266    pub postal_codes: Vec<String>,
267    /// Whether this jurisdiction is active
268    pub active: bool,
269    pub created_at: DateTime<Utc>,
270    pub updated_at: DateTime<Utc>,
271}
272
273/// Level of tax jurisdiction
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
275#[serde(rename_all = "snake_case")]
276#[non_exhaustive]
277pub enum JurisdictionLevel {
278    #[default]
279    Country,
280    State,
281    County,
282    City,
283    District,
284    Special,
285}
286
287impl std::fmt::Display for JurisdictionLevel {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            Self::Country => f.write_str("country"),
291            Self::State => f.write_str("state"),
292            Self::County => f.write_str("county"),
293            Self::City => f.write_str("city"),
294            Self::District => f.write_str("district"),
295            Self::Special => f.write_str("special"),
296        }
297    }
298}
299
300impl std::str::FromStr for JurisdictionLevel {
301    type Err = String;
302
303    fn from_str(s: &str) -> Result<Self, Self::Err> {
304        match s.trim().to_ascii_lowercase().as_str() {
305            "country" => Ok(Self::Country),
306            "state" => Ok(Self::State),
307            "county" => Ok(Self::County),
308            "city" => Ok(Self::City),
309            "district" => Ok(Self::District),
310            "special" => Ok(Self::Special),
311            _ => Err(format!("Unknown jurisdiction level: {s}")),
312        }
313    }
314}
315
316/// A tax rate for a specific jurisdiction and category
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct TaxRate {
319    pub id: Uuid,
320    /// Jurisdiction this rate applies to
321    pub jurisdiction_id: Uuid,
322    /// Type of tax
323    pub tax_type: TaxType,
324    /// Product category this rate applies to
325    pub product_category: ProductTaxCategory,
326    /// Tax rate as decimal (e.g., 0.0825 for 8.25%)
327    pub rate: Decimal,
328    /// Rate name for display (e.g., "California State Tax")
329    pub name: String,
330    /// Description of the tax
331    pub description: Option<String>,
332    /// Whether rate is compound (applied after other taxes)
333    pub is_compound: bool,
334    /// Priority for ordering (lower = applied first)
335    pub priority: i32,
336    /// Minimum amount for tax to apply
337    pub threshold_min: Option<Decimal>,
338    /// Maximum amount taxed (cap)
339    pub threshold_max: Option<Decimal>,
340    /// Fixed amount instead of percentage
341    pub fixed_amount: Option<Decimal>,
342    /// Effective date
343    pub effective_from: NaiveDate,
344    /// Expiration date
345    pub effective_to: Option<NaiveDate>,
346    /// Whether this rate is active
347    pub active: bool,
348    pub created_at: DateTime<Utc>,
349    pub updated_at: DateTime<Utc>,
350}
351
352/// Customer tax exemption certificate
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct TaxExemption {
355    pub id: Uuid,
356    /// Customer this exemption belongs to
357    pub customer_id: Uuid,
358    /// Type of exemption
359    pub exemption_type: ExemptionType,
360    /// Exemption certificate number
361    pub certificate_number: Option<String>,
362    /// Issuing authority/state
363    pub issuing_authority: Option<String>,
364    /// Jurisdictions where exemption applies (empty = all)
365    pub jurisdiction_ids: Vec<Uuid>,
366    /// Product categories exempt (empty = all)
367    pub exempt_categories: Vec<ProductTaxCategory>,
368    /// Effective date
369    pub effective_from: NaiveDate,
370    /// Expiration date
371    pub expires_at: Option<NaiveDate>,
372    /// Whether exemption has been verified
373    pub verified: bool,
374    /// Verification date
375    pub verified_at: Option<DateTime<Utc>>,
376    /// Notes about the exemption
377    pub notes: Option<String>,
378    /// Whether this exemption is active
379    pub active: bool,
380    pub created_at: DateTime<Utc>,
381    pub updated_at: DateTime<Utc>,
382}
383
384// ============================================================================
385// Tax Calculation Types
386// ============================================================================
387
388/// Input for tax calculation
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct TaxCalculationRequest {
391    /// Line items to calculate tax for
392    pub line_items: Vec<TaxLineItem>,
393    /// Shipping address (determines jurisdiction)
394    pub shipping_address: TaxAddress,
395    /// Optional billing address (for digital goods)
396    pub billing_address: Option<TaxAddress>,
397    /// Customer ID (for exemption lookup)
398    pub customer_id: Option<Uuid>,
399    /// Shipping amount (may be taxable)
400    pub shipping_amount: Option<Decimal>,
401    /// Currency code
402    #[serde(default = "default_currency")]
403    pub currency: CurrencyCode,
404    /// Transaction date (for rate lookup)
405    pub transaction_date: Option<NaiveDate>,
406    /// Whether prices include tax
407    pub prices_include_tax: bool,
408}
409
410const fn default_currency() -> CurrencyCode {
411    CurrencyCode::USD
412}
413
414/// A line item for tax calculation
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct TaxLineItem {
417    /// Line item identifier
418    pub id: String,
419    /// Product SKU
420    pub sku: Option<String>,
421    /// Product ID
422    pub product_id: Option<ProductId>,
423    /// Quantity
424    pub quantity: Decimal,
425    /// Unit price
426    pub unit_price: Decimal,
427    /// Total discount on this line
428    pub discount_amount: Decimal,
429    /// Product tax category
430    pub tax_category: ProductTaxCategory,
431    /// Override tax code (e.g., Avalara tax code)
432    pub tax_code: Option<String>,
433    /// Description for tax reporting
434    pub description: Option<String>,
435}
436
437impl Default for TaxLineItem {
438    fn default() -> Self {
439        Self {
440            id: String::new(),
441            sku: None,
442            product_id: None,
443            quantity: Decimal::ONE,
444            unit_price: Decimal::ZERO,
445            discount_amount: Decimal::ZERO,
446            tax_category: ProductTaxCategory::Standard,
447            tax_code: None,
448            description: None,
449        }
450    }
451}
452
453/// Address for tax jurisdiction determination
454#[derive(Debug, Clone, Serialize, Deserialize, Default)]
455pub struct TaxAddress {
456    /// Street line 1
457    pub line1: Option<String>,
458    /// Street line 2
459    pub line2: Option<String>,
460    /// City
461    pub city: Option<String>,
462    /// State/Province/Region
463    pub state: Option<String>,
464    /// Postal/ZIP code
465    pub postal_code: Option<String>,
466    /// Country code (ISO 3166-1 alpha-2)
467    pub country: String,
468}
469
470/// Result of tax calculation
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct TaxCalculationResult {
473    /// Unique calculation ID
474    pub id: Uuid,
475    /// Total tax amount
476    pub total_tax: Decimal,
477    /// Subtotal before tax
478    pub subtotal: Decimal,
479    /// Total including tax
480    pub total: Decimal,
481    /// Tax on shipping
482    pub shipping_tax: Decimal,
483    /// Breakdown by jurisdiction
484    pub tax_breakdown: Vec<TaxBreakdown>,
485    /// Per-line-item tax details
486    pub line_item_taxes: Vec<LineItemTax>,
487    /// Whether any exemptions were applied
488    pub exemptions_applied: bool,
489    /// Exemption details if applied
490    pub exemption_details: Option<ExemptionDetails>,
491    /// Jurisdictions involved
492    pub jurisdictions: Vec<JurisdictionSummary>,
493    /// Calculation timestamp
494    pub calculated_at: DateTime<Utc>,
495    /// Whether this is an estimate or committed transaction
496    pub is_estimate: bool,
497}
498
499/// Tax breakdown by jurisdiction
500#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct TaxBreakdown {
502    /// Jurisdiction ID
503    pub jurisdiction_id: Uuid,
504    /// Jurisdiction name
505    pub jurisdiction_name: String,
506    /// Tax type
507    pub tax_type: TaxType,
508    /// Rate name
509    pub rate_name: String,
510    /// Tax rate applied
511    pub rate: Decimal,
512    /// Taxable amount
513    pub taxable_amount: Decimal,
514    /// Tax amount
515    pub tax_amount: Decimal,
516    /// Whether this is a compound tax
517    pub is_compound: bool,
518}
519
520/// Tax for a specific line item
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct LineItemTax {
523    /// Line item ID
524    pub line_item_id: String,
525    /// Taxable amount for this item
526    pub taxable_amount: Decimal,
527    /// Total tax for this item
528    pub tax_amount: Decimal,
529    /// Effective tax rate
530    pub effective_rate: Decimal,
531    /// Whether item was exempt
532    pub is_exempt: bool,
533    /// Reason for exemption if exempt
534    pub exemption_reason: Option<String>,
535    /// Breakdown by tax type
536    pub tax_details: Vec<TaxDetail>,
537}
538
539/// Detailed tax information
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct TaxDetail {
542    pub tax_type: TaxType,
543    pub jurisdiction_name: String,
544    pub rate: Decimal,
545    pub amount: Decimal,
546}
547
548/// Summary of exemptions applied
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct ExemptionDetails {
551    pub exemption_id: Uuid,
552    pub exemption_type: ExemptionType,
553    pub certificate_number: Option<String>,
554    pub amount_exempt: Decimal,
555    pub tax_saved: Decimal,
556}
557
558/// Summary of a jurisdiction involved in calculation
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct JurisdictionSummary {
561    pub id: Uuid,
562    pub name: String,
563    pub code: String,
564    pub level: JurisdictionLevel,
565    pub total_rate: Decimal,
566    pub total_tax: Decimal,
567}
568
569// ============================================================================
570// Tax Configuration
571// ============================================================================
572
573/// Store-level tax configuration
574#[derive(Debug, Clone, Serialize, Deserialize)]
575pub struct TaxSettings {
576    pub id: Uuid,
577    /// Whether tax calculation is enabled
578    pub enabled: bool,
579    /// Default calculation method
580    pub calculation_method: TaxCalculationMethod,
581    /// Default compound method
582    pub compound_method: TaxCompoundMethod,
583    /// Whether to tax shipping
584    pub tax_shipping: bool,
585    /// Whether to tax handling fees
586    pub tax_handling: bool,
587    /// Whether to tax gift wrapping
588    pub tax_gift_wrap: bool,
589    /// Origin address for origin-based tax states
590    pub origin_address: Option<TaxAddress>,
591    /// Default product tax category
592    pub default_product_category: ProductTaxCategory,
593    /// Rounding mode applied to computed tax amounts. One of `half_up`
594    /// (default), `half_even`/`bankers`, `half_down`, `up`, `down`/`truncate`,
595    /// `ceil`, or `floor`. See [`Self::rounding_strategy`].
596    pub rounding_mode: String,
597    /// Decimal places for tax amounts
598    pub decimal_places: i32,
599    /// Whether to validate addresses
600    pub validate_addresses: bool,
601    /// External tax service provider (avalara, taxjar, vertex, none)
602    pub tax_provider: Option<String>,
603    /// Provider API credentials (encrypted)
604    pub provider_credentials: Option<String>,
605    pub created_at: DateTime<Utc>,
606    pub updated_at: DateTime<Utc>,
607}
608
609impl Default for TaxSettings {
610    fn default() -> Self {
611        Self {
612            id: Uuid::new_v4(),
613            enabled: true,
614            calculation_method: TaxCalculationMethod::Exclusive,
615            compound_method: TaxCompoundMethod::Combined,
616            tax_shipping: true,
617            tax_handling: true,
618            tax_gift_wrap: true,
619            origin_address: None,
620            default_product_category: ProductTaxCategory::Standard,
621            rounding_mode: "half_up".to_string(),
622            decimal_places: 2,
623            validate_addresses: false,
624            tax_provider: None,
625            provider_credentials: None,
626            created_at: Utc::now(),
627            updated_at: Utc::now(),
628        }
629    }
630}
631
632impl TaxSettings {
633    /// Resolve the configured [`rounding_mode`](Self::rounding_mode) string to a
634    /// concrete [`RoundingStrategy`] for rounding computed tax amounts.
635    ///
636    /// Recognized modes (case- and surrounding-whitespace-insensitive):
637    /// `half_up` (round half away from zero — the default and conventional
638    /// retail tax rounding), `half_even`/`bankers` (round half to even),
639    /// `half_down`, `up`, `down`/`truncate`, `ceil`, and `floor`. Any
640    /// unrecognized or empty value falls back to `half_up`.
641    #[must_use]
642    pub fn rounding_strategy(&self) -> RoundingStrategy {
643        match self.rounding_mode.trim().to_ascii_lowercase().as_str() {
644            "half_even" | "half_to_even" | "bankers" | "banker" => {
645                RoundingStrategy::MidpointNearestEven
646            }
647            "half_down" => RoundingStrategy::MidpointTowardZero,
648            "up" | "away_from_zero" => RoundingStrategy::AwayFromZero,
649            "down" | "truncate" | "toward_zero" => RoundingStrategy::ToZero,
650            "ceil" | "ceiling" => RoundingStrategy::ToPositiveInfinity,
651            "floor" => RoundingStrategy::ToNegativeInfinity,
652            // "half_up" and any unrecognized value: round half away from zero.
653            _ => RoundingStrategy::MidpointAwayFromZero,
654        }
655    }
656}
657
658// ============================================================================
659// Create/Update DTOs
660// ============================================================================
661
662/// Create a new tax jurisdiction
663#[derive(Debug, Clone, Serialize, Deserialize, Default)]
664pub struct CreateTaxJurisdiction {
665    pub parent_id: Option<Uuid>,
666    pub name: String,
667    pub code: String,
668    pub level: JurisdictionLevel,
669    pub country_code: String,
670    pub state_code: Option<String>,
671    pub county: Option<String>,
672    pub city: Option<String>,
673    pub postal_codes: Vec<String>,
674}
675
676/// Create a new tax rate
677#[derive(Debug, Clone, Serialize, Deserialize)]
678pub struct CreateTaxRate {
679    pub jurisdiction_id: Uuid,
680    pub tax_type: TaxType,
681    pub product_category: ProductTaxCategory,
682    pub rate: Decimal,
683    pub name: String,
684    pub description: Option<String>,
685    pub is_compound: bool,
686    pub priority: i32,
687    pub threshold_min: Option<Decimal>,
688    pub threshold_max: Option<Decimal>,
689    pub fixed_amount: Option<Decimal>,
690    pub effective_from: NaiveDate,
691    pub effective_to: Option<NaiveDate>,
692}
693
694impl Default for CreateTaxRate {
695    fn default() -> Self {
696        Self {
697            jurisdiction_id: Uuid::nil(),
698            tax_type: TaxType::SalesTax,
699            product_category: ProductTaxCategory::Standard,
700            rate: Decimal::ZERO,
701            name: String::new(),
702            description: None,
703            is_compound: false,
704            priority: 0,
705            threshold_min: None,
706            threshold_max: None,
707            fixed_amount: None,
708            effective_from: Utc::now().date_naive(),
709            effective_to: None,
710        }
711    }
712}
713
714/// Create a tax exemption for a customer
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct CreateTaxExemption {
717    pub customer_id: Uuid,
718    pub exemption_type: ExemptionType,
719    pub certificate_number: Option<String>,
720    pub issuing_authority: Option<String>,
721    pub jurisdiction_ids: Vec<Uuid>,
722    pub exempt_categories: Vec<ProductTaxCategory>,
723    pub effective_from: NaiveDate,
724    pub expires_at: Option<NaiveDate>,
725    pub notes: Option<String>,
726}
727
728/// Filter for querying tax rates
729#[derive(Debug, Clone, Default, Serialize, Deserialize)]
730pub struct TaxRateFilter {
731    pub jurisdiction_id: Option<Uuid>,
732    pub tax_type: Option<TaxType>,
733    pub product_category: Option<ProductTaxCategory>,
734    pub active_only: bool,
735    pub effective_date: Option<NaiveDate>,
736    /// Maximum number of rows to return (server default/cap applies when unset)
737    pub limit: Option<u32>,
738    /// Number of rows to skip
739    pub offset: Option<u32>,
740}
741
742/// Filter for querying jurisdictions
743#[derive(Debug, Clone, Default, Serialize, Deserialize)]
744pub struct TaxJurisdictionFilter {
745    pub country_code: Option<String>,
746    pub state_code: Option<String>,
747    pub level: Option<JurisdictionLevel>,
748    pub active_only: bool,
749    /// Maximum number of rows to return (server default/cap applies when unset)
750    pub limit: Option<u32>,
751    /// Number of rows to skip
752    pub offset: Option<u32>,
753}
754
755// ============================================================================
756// US-Specific Tax Helpers
757// ============================================================================
758
759/// US State tax information
760#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct UsStateTaxInfo {
762    pub state_code: String,
763    pub state_name: String,
764    pub state_rate: Decimal,
765    pub has_local_taxes: bool,
766    pub origin_based: bool,
767    pub tax_shipping: bool,
768    pub tax_clothing: bool,
769    pub tax_food: bool,
770    pub tax_digital: bool,
771}
772
773/// Pre-configured US state tax data
774#[must_use]
775pub fn get_us_state_tax_info(state_code: &str) -> Option<UsStateTaxInfo> {
776    match state_code.to_uppercase().as_str() {
777        "AL" => Some(UsStateTaxInfo {
778            state_code: "AL".into(),
779            state_name: "Alabama".into(),
780            state_rate: Decimal::new(4, 2), // 4%
781            has_local_taxes: true,
782            origin_based: false,
783            tax_shipping: true,
784            tax_clothing: true,
785            tax_food: true,
786            tax_digital: true,
787        }),
788        "AK" => Some(UsStateTaxInfo {
789            state_code: "AK".into(),
790            state_name: "Alaska".into(),
791            state_rate: Decimal::ZERO, // No state tax
792            has_local_taxes: true,
793            origin_based: false,
794            tax_shipping: false,
795            tax_clothing: false,
796            tax_food: false,
797            tax_digital: false,
798        }),
799        "AZ" => Some(UsStateTaxInfo {
800            state_code: "AZ".into(),
801            state_name: "Arizona".into(),
802            state_rate: Decimal::new(56, 3), // 5.6%
803            has_local_taxes: true,
804            origin_based: true,
805            tax_shipping: true,
806            tax_clothing: true,
807            tax_food: false,
808            tax_digital: true,
809        }),
810        "CA" => Some(UsStateTaxInfo {
811            state_code: "CA".into(),
812            state_name: "California".into(),
813            state_rate: Decimal::new(725, 4), // 7.25%
814            has_local_taxes: true,
815            origin_based: true,
816            tax_shipping: false,
817            tax_clothing: true,
818            tax_food: false,
819            tax_digital: false,
820        }),
821        "CO" => Some(UsStateTaxInfo {
822            state_code: "CO".into(),
823            state_name: "Colorado".into(),
824            state_rate: Decimal::new(29, 3), // 2.9%
825            has_local_taxes: true,
826            origin_based: false,
827            tax_shipping: true,
828            tax_clothing: true,
829            tax_food: false,
830            tax_digital: true,
831        }),
832        "DE" => Some(UsStateTaxInfo {
833            state_code: "DE".into(),
834            state_name: "Delaware".into(),
835            state_rate: Decimal::ZERO, // No sales tax
836            has_local_taxes: false,
837            origin_based: false,
838            tax_shipping: false,
839            tax_clothing: false,
840            tax_food: false,
841            tax_digital: false,
842        }),
843        "FL" => Some(UsStateTaxInfo {
844            state_code: "FL".into(),
845            state_name: "Florida".into(),
846            state_rate: Decimal::new(6, 2), // 6%
847            has_local_taxes: true,
848            origin_based: false,
849            tax_shipping: true,
850            tax_clothing: true,
851            tax_food: false,
852            tax_digital: true,
853        }),
854        "MT" => Some(UsStateTaxInfo {
855            state_code: "MT".into(),
856            state_name: "Montana".into(),
857            state_rate: Decimal::ZERO, // No sales tax
858            has_local_taxes: false,
859            origin_based: false,
860            tax_shipping: false,
861            tax_clothing: false,
862            tax_food: false,
863            tax_digital: false,
864        }),
865        "NH" => Some(UsStateTaxInfo {
866            state_code: "NH".into(),
867            state_name: "New Hampshire".into(),
868            state_rate: Decimal::ZERO, // No sales tax
869            has_local_taxes: false,
870            origin_based: false,
871            tax_shipping: false,
872            tax_clothing: false,
873            tax_food: false,
874            tax_digital: false,
875        }),
876        "NY" => Some(UsStateTaxInfo {
877            state_code: "NY".into(),
878            state_name: "New York".into(),
879            state_rate: Decimal::new(4, 2), // 4%
880            has_local_taxes: true,
881            origin_based: false,
882            tax_shipping: true,
883            tax_clothing: false, // Clothing under $110 exempt
884            tax_food: false,
885            tax_digital: true,
886        }),
887        "OR" => Some(UsStateTaxInfo {
888            state_code: "OR".into(),
889            state_name: "Oregon".into(),
890            state_rate: Decimal::ZERO, // No sales tax
891            has_local_taxes: false,
892            origin_based: false,
893            tax_shipping: false,
894            tax_clothing: false,
895            tax_food: false,
896            tax_digital: false,
897        }),
898        "TX" => Some(UsStateTaxInfo {
899            state_code: "TX".into(),
900            state_name: "Texas".into(),
901            state_rate: Decimal::new(625, 4), // 6.25%
902            has_local_taxes: true,
903            origin_based: true,
904            tax_shipping: true,
905            tax_clothing: true,
906            tax_food: false,
907            tax_digital: true,
908        }),
909        "WA" => Some(UsStateTaxInfo {
910            state_code: "WA".into(),
911            state_name: "Washington".into(),
912            state_rate: Decimal::new(65, 3), // 6.5%
913            has_local_taxes: true,
914            origin_based: false,
915            tax_shipping: true,
916            tax_clothing: true,
917            tax_food: false,
918            tax_digital: true,
919        }),
920        _ => None,
921    }
922}
923
924// ============================================================================
925// EU VAT Helpers
926// ============================================================================
927
928/// EU VAT rates by country
929#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct EuVatInfo {
931    pub country_code: String,
932    pub country_name: String,
933    pub standard_rate: Decimal,
934    pub reduced_rate: Option<Decimal>,
935    pub super_reduced_rate: Option<Decimal>,
936    pub parking_rate: Option<Decimal>,
937}
938
939/// Get EU VAT information for a country
940#[must_use]
941pub fn get_eu_vat_info(country_code: &str) -> Option<EuVatInfo> {
942    match country_code.to_uppercase().as_str() {
943        "AT" => Some(EuVatInfo {
944            country_code: "AT".into(),
945            country_name: "Austria".into(),
946            standard_rate: Decimal::new(20, 2),
947            reduced_rate: Some(Decimal::new(10, 2)),
948            super_reduced_rate: None,
949            parking_rate: Some(Decimal::new(13, 2)),
950        }),
951        "BE" => Some(EuVatInfo {
952            country_code: "BE".into(),
953            country_name: "Belgium".into(),
954            standard_rate: Decimal::new(21, 2),
955            reduced_rate: Some(Decimal::new(12, 2)),
956            super_reduced_rate: Some(Decimal::new(6, 2)),
957            parking_rate: Some(Decimal::new(12, 2)),
958        }),
959        "DE" => Some(EuVatInfo {
960            country_code: "DE".into(),
961            country_name: "Germany".into(),
962            standard_rate: Decimal::new(19, 2),
963            reduced_rate: Some(Decimal::new(7, 2)),
964            super_reduced_rate: None,
965            parking_rate: None,
966        }),
967        "ES" => Some(EuVatInfo {
968            country_code: "ES".into(),
969            country_name: "Spain".into(),
970            standard_rate: Decimal::new(21, 2),
971            reduced_rate: Some(Decimal::new(10, 2)),
972            super_reduced_rate: Some(Decimal::new(4, 2)),
973            parking_rate: None,
974        }),
975        "FR" => Some(EuVatInfo {
976            country_code: "FR".into(),
977            country_name: "France".into(),
978            standard_rate: Decimal::new(20, 2),
979            reduced_rate: Some(Decimal::new(10, 2)),
980            super_reduced_rate: Some(Decimal::new(55, 3)), // 5.5%
981            parking_rate: None,
982        }),
983        "GB" => Some(EuVatInfo {
984            country_code: "GB".into(),
985            country_name: "United Kingdom".into(),
986            standard_rate: Decimal::new(20, 2),
987            reduced_rate: Some(Decimal::new(5, 2)),
988            super_reduced_rate: None,
989            parking_rate: None,
990        }),
991        "IE" => Some(EuVatInfo {
992            country_code: "IE".into(),
993            country_name: "Ireland".into(),
994            standard_rate: Decimal::new(23, 2),
995            reduced_rate: Some(Decimal::new(135, 3)), // 13.5%
996            super_reduced_rate: Some(Decimal::new(48, 3)), // 4.8%
997            parking_rate: Some(Decimal::new(135, 3)),
998        }),
999        "IT" => Some(EuVatInfo {
1000            country_code: "IT".into(),
1001            country_name: "Italy".into(),
1002            standard_rate: Decimal::new(22, 2),
1003            reduced_rate: Some(Decimal::new(10, 2)),
1004            super_reduced_rate: Some(Decimal::new(4, 2)),
1005            parking_rate: None,
1006        }),
1007        "NL" => Some(EuVatInfo {
1008            country_code: "NL".into(),
1009            country_name: "Netherlands".into(),
1010            standard_rate: Decimal::new(21, 2),
1011            reduced_rate: Some(Decimal::new(9, 2)),
1012            super_reduced_rate: None,
1013            parking_rate: None,
1014        }),
1015        "SE" => Some(EuVatInfo {
1016            country_code: "SE".into(),
1017            country_name: "Sweden".into(),
1018            standard_rate: Decimal::new(25, 2),
1019            reduced_rate: Some(Decimal::new(12, 2)),
1020            super_reduced_rate: Some(Decimal::new(6, 2)),
1021            parking_rate: None,
1022        }),
1023        _ => None,
1024    }
1025}
1026
1027/// List of EU member state country codes
1028pub const EU_MEMBER_STATES: &[&str] = &[
1029    "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV",
1030    "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
1031];
1032
1033/// Check if a country is in the EU
1034#[must_use]
1035pub fn is_eu_member(country_code: &str) -> bool {
1036    EU_MEMBER_STATES.contains(&country_code.to_uppercase().as_str())
1037}
1038
1039// ============================================================================
1040// Canadian Tax Helpers
1041// ============================================================================
1042
1043/// Canadian province/territory tax information
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1045pub struct CanadianTaxInfo {
1046    pub province_code: String,
1047    pub province_name: String,
1048    pub gst_rate: Decimal,
1049    pub pst_rate: Option<Decimal>,
1050    pub hst_rate: Option<Decimal>,
1051    pub qst_rate: Option<Decimal>,
1052    pub total_rate: Decimal,
1053}
1054
1055/// Get Canadian tax information for a province
1056#[must_use]
1057pub fn get_canadian_tax_info(province_code: &str) -> Option<CanadianTaxInfo> {
1058    let gst = Decimal::new(5, 2); // Federal GST is 5%
1059
1060    match province_code.to_uppercase().as_str() {
1061        "AB" => Some(CanadianTaxInfo {
1062            province_code: "AB".into(),
1063            province_name: "Alberta".into(),
1064            gst_rate: gst,
1065            pst_rate: None,
1066            hst_rate: None,
1067            qst_rate: None,
1068            total_rate: gst,
1069        }),
1070        "BC" => Some(CanadianTaxInfo {
1071            province_code: "BC".into(),
1072            province_name: "British Columbia".into(),
1073            gst_rate: gst,
1074            pst_rate: Some(Decimal::new(7, 2)),
1075            hst_rate: None,
1076            qst_rate: None,
1077            total_rate: Decimal::new(12, 2),
1078        }),
1079        "ON" => Some(CanadianTaxInfo {
1080            province_code: "ON".into(),
1081            province_name: "Ontario".into(),
1082            gst_rate: Decimal::ZERO, // Replaced by HST
1083            pst_rate: None,
1084            hst_rate: Some(Decimal::new(13, 2)),
1085            qst_rate: None,
1086            total_rate: Decimal::new(13, 2),
1087        }),
1088        "QC" => Some(CanadianTaxInfo {
1089            province_code: "QC".into(),
1090            province_name: "Quebec".into(),
1091            gst_rate: gst,
1092            pst_rate: None,
1093            hst_rate: None,
1094            qst_rate: Some(Decimal::new(9975, 4)), // 9.975%
1095            total_rate: Decimal::new(14975, 4),
1096        }),
1097        "SK" => Some(CanadianTaxInfo {
1098            province_code: "SK".into(),
1099            province_name: "Saskatchewan".into(),
1100            gst_rate: gst,
1101            pst_rate: Some(Decimal::new(6, 2)),
1102            hst_rate: None,
1103            qst_rate: None,
1104            total_rate: Decimal::new(11, 2),
1105        }),
1106        "MB" => Some(CanadianTaxInfo {
1107            province_code: "MB".into(),
1108            province_name: "Manitoba".into(),
1109            gst_rate: gst,
1110            pst_rate: Some(Decimal::new(7, 2)),
1111            hst_rate: None,
1112            qst_rate: None,
1113            total_rate: Decimal::new(12, 2),
1114        }),
1115        "NS" => Some(CanadianTaxInfo {
1116            province_code: "NS".into(),
1117            province_name: "Nova Scotia".into(),
1118            gst_rate: Decimal::ZERO,
1119            pst_rate: None,
1120            hst_rate: Some(Decimal::new(15, 2)),
1121            qst_rate: None,
1122            total_rate: Decimal::new(15, 2),
1123        }),
1124        "NB" => Some(CanadianTaxInfo {
1125            province_code: "NB".into(),
1126            province_name: "New Brunswick".into(),
1127            gst_rate: Decimal::ZERO,
1128            pst_rate: None,
1129            hst_rate: Some(Decimal::new(15, 2)),
1130            qst_rate: None,
1131            total_rate: Decimal::new(15, 2),
1132        }),
1133        _ => None,
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use std::str::FromStr;
1141
1142    #[test]
1143    fn tax_type_from_str() {
1144        assert_eq!(TaxType::from_str("sales_tax").unwrap(), TaxType::SalesTax);
1145        assert!(TaxType::from_str("unknown").is_err());
1146    }
1147
1148    #[test]
1149    fn tax_calculation_method_from_str() {
1150        assert_eq!(
1151            TaxCalculationMethod::from_str("inclusive").unwrap(),
1152            TaxCalculationMethod::Inclusive
1153        );
1154        assert!(TaxCalculationMethod::from_str("other").is_err());
1155    }
1156
1157    #[test]
1158    fn tax_compound_method_from_str() {
1159        assert_eq!(TaxCompoundMethod::from_str("combined").unwrap(), TaxCompoundMethod::Combined);
1160        assert!(TaxCompoundMethod::from_str("other").is_err());
1161    }
1162
1163    #[test]
1164    fn product_tax_category_from_str() {
1165        assert_eq!(
1166            ProductTaxCategory::from_str("super_reduced").unwrap(),
1167            ProductTaxCategory::SuperReduced
1168        );
1169        assert!(ProductTaxCategory::from_str("other").is_err());
1170    }
1171
1172    #[test]
1173    fn exemption_type_from_str() {
1174        assert_eq!(ExemptionType::from_str("non_profit").unwrap(), ExemptionType::NonProfit);
1175        assert!(ExemptionType::from_str("unknown").is_err());
1176    }
1177
1178    #[test]
1179    fn jurisdiction_level_from_str() {
1180        assert_eq!(JurisdictionLevel::from_str("state").unwrap(), JurisdictionLevel::State);
1181        assert!(JurisdictionLevel::from_str("unknown").is_err());
1182    }
1183
1184    #[test]
1185    fn tax_settings_rounding_strategy_maps_modes() {
1186        use rust_decimal::RoundingStrategy;
1187        let strat = |mode: &str| {
1188            let s = TaxSettings { rounding_mode: mode.to_string(), ..Default::default() };
1189            s.rounding_strategy()
1190        };
1191        assert_eq!(strat("half_up"), RoundingStrategy::MidpointAwayFromZero);
1192        assert_eq!(strat("half_even"), RoundingStrategy::MidpointNearestEven);
1193        assert_eq!(strat("bankers"), RoundingStrategy::MidpointNearestEven);
1194        assert_eq!(strat("half_down"), RoundingStrategy::MidpointTowardZero);
1195        assert_eq!(strat("up"), RoundingStrategy::AwayFromZero);
1196        assert_eq!(strat("down"), RoundingStrategy::ToZero);
1197        assert_eq!(strat("truncate"), RoundingStrategy::ToZero);
1198        assert_eq!(strat("ceil"), RoundingStrategy::ToPositiveInfinity);
1199        assert_eq!(strat("floor"), RoundingStrategy::ToNegativeInfinity);
1200        // Case- and whitespace-insensitive.
1201        assert_eq!(strat("  Half_Even "), RoundingStrategy::MidpointNearestEven);
1202        // Unknown and the documented default both resolve to half_up.
1203        assert_eq!(strat("wat"), RoundingStrategy::MidpointAwayFromZero);
1204        assert_eq!(
1205            TaxSettings::default().rounding_strategy(),
1206            RoundingStrategy::MidpointAwayFromZero
1207        );
1208    }
1209}