Skip to main content

stateset_core/models/
shipping_zone.rs

1//! Shipping zone and rate domain models
2//!
3//! Defines geographic shipping zones with configurable shipping methods
4//! and rate calculation strategies.
5
6use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use stateset_primitives::{CurrencyCode, ShippingMethodId, ShippingZoneId};
10use strum::{Display, EnumString};
11
12/// Shipping method pricing type
13#[derive(
14    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
15)]
16#[serde(rename_all = "snake_case")]
17#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
18#[non_exhaustive]
19pub enum ShippingMethodType {
20    /// Fixed rate regardless of order details
21    #[default]
22    Flat,
23    /// Rate varies by total weight
24    WeightBased,
25    /// Rate varies by order total price
26    PriceBased,
27    /// Rate calculated by external carrier API
28    Calculated,
29    /// Free shipping (no charge)
30    Free,
31}
32
33/// A geographic shipping zone
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ShippingZone {
36    /// Unique zone ID
37    pub id: ShippingZoneId,
38    /// Human-readable zone name (e.g., "Domestic", "EU", "Rest of World")
39    pub name: String,
40    /// ISO 3166-1 alpha-2 country codes included in this zone
41    pub countries: Vec<String>,
42    /// State/province/region codes within the countries
43    pub regions: Vec<String>,
44    /// Postal/zip code patterns (supports wildcards like "90*")
45    pub postal_codes: Vec<String>,
46    /// Priority for zone matching (lower = higher priority)
47    pub priority: i32,
48    /// Whether this zone is active
49    pub is_active: bool,
50    /// When the zone was created
51    pub created_at: DateTime<Utc>,
52    /// When the zone was last updated
53    pub updated_at: DateTime<Utc>,
54}
55
56/// A configurable shipping method within a zone (distinct from the simple
57/// `ShippingMethod` enum in the `shipment` module which represents speed tiers).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ZoneShippingMethod {
60    /// Unique method ID
61    pub id: ShippingMethodId,
62    /// Zone this method belongs to
63    pub zone_id: ShippingZoneId,
64    /// Method name (e.g., "Standard Shipping", "Express")
65    pub name: String,
66    /// Carrier name (e.g., "USPS", "`FedEx`", "DHL")
67    pub carrier: Option<String>,
68    /// Pricing type
69    pub method_type: ShippingMethodType,
70    /// Base rate (used for Flat; minimum for weight/price-based)
71    pub base_rate: Decimal,
72    /// Currency code
73    pub currency: CurrencyCode,
74    /// Estimated minimum delivery days
75    pub min_delivery_days: Option<i32>,
76    /// Estimated maximum delivery days
77    pub max_delivery_days: Option<i32>,
78    /// Conditional rate tiers
79    pub conditions: Vec<ShippingCondition>,
80    /// Whether this method is active
81    pub is_active: bool,
82    /// When the method was created
83    pub created_at: DateTime<Utc>,
84    /// When the method was last updated
85    pub updated_at: DateTime<Utc>,
86}
87
88/// A conditional rate tier for weight-based or price-based shipping
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ShippingCondition {
91    /// Minimum weight in grams (for weight-based)
92    pub min_weight: Option<Decimal>,
93    /// Maximum weight in grams (for weight-based)
94    pub max_weight: Option<Decimal>,
95    /// Minimum order price (for price-based)
96    pub min_price: Option<Decimal>,
97    /// Maximum order price (for price-based)
98    pub max_price: Option<Decimal>,
99    /// Rate for this tier
100    pub rate: Decimal,
101}
102
103/// Input for creating a shipping zone
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CreateShippingZone {
106    /// Zone name
107    pub name: String,
108    /// Country codes
109    pub countries: Vec<String>,
110    /// Region codes
111    pub regions: Vec<String>,
112    /// Postal code patterns
113    pub postal_codes: Vec<String>,
114    /// Priority (default 0)
115    pub priority: Option<i32>,
116}
117
118/// Input for updating a shipping zone
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct UpdateShippingZone {
121    /// Updated name
122    pub name: Option<String>,
123    /// Updated countries
124    pub countries: Option<Vec<String>>,
125    /// Updated regions
126    pub regions: Option<Vec<String>>,
127    /// Updated postal codes
128    pub postal_codes: Option<Vec<String>>,
129    /// Updated priority
130    pub priority: Option<i32>,
131    /// Updated active status
132    pub is_active: Option<bool>,
133}
134
135/// Input for creating a zone shipping method
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CreateZoneShippingMethod {
138    /// Zone this method belongs to
139    pub zone_id: ShippingZoneId,
140    /// Method name
141    pub name: String,
142    /// Carrier name
143    pub carrier: Option<String>,
144    /// Pricing type
145    pub method_type: ShippingMethodType,
146    /// Base rate
147    pub base_rate: Decimal,
148    /// Currency code
149    pub currency: CurrencyCode,
150    /// Min delivery days
151    pub min_delivery_days: Option<i32>,
152    /// Max delivery days
153    pub max_delivery_days: Option<i32>,
154    /// Conditional rate tiers
155    pub conditions: Vec<ShippingCondition>,
156}
157
158/// Filter for listing shipping zones
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct ShippingZoneFilter {
161    /// Filter by country code
162    pub country: Option<String>,
163    /// Filter by active status
164    pub is_active: Option<bool>,
165    /// Maximum results
166    pub limit: Option<u32>,
167    /// Offset for pagination
168    pub offset: Option<u32>,
169}
170
171/// Filter for listing zone shipping methods
172#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub struct ZoneShippingMethodFilter {
174    /// Filter by zone
175    pub zone_id: Option<ShippingZoneId>,
176    /// Filter by carrier
177    pub carrier: Option<String>,
178    /// Filter by method type
179    pub method_type: Option<ShippingMethodType>,
180    /// Filter by active status
181    pub is_active: Option<bool>,
182    /// Maximum results
183    pub limit: Option<u32>,
184    /// Offset for pagination
185    pub offset: Option<u32>,
186}
187
188/// Shipping rate calculation request
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ZoneShippingRateRequest {
191    /// Destination country code
192    pub country: String,
193    /// Destination region/state
194    pub region: Option<String>,
195    /// Destination postal code
196    pub postal_code: Option<String>,
197    /// Total order weight in grams
198    pub weight: Option<Decimal>,
199    /// Total order price
200    pub order_total: Option<Decimal>,
201    /// Currency
202    pub currency: CurrencyCode,
203}
204
205/// Calculated shipping rate result
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ZoneShippingRate {
208    /// The shipping method
209    pub method_id: ShippingMethodId,
210    /// Method name
211    pub method_name: String,
212    /// Carrier
213    pub carrier: Option<String>,
214    /// Calculated rate
215    pub rate: Decimal,
216    /// Currency
217    pub currency: CurrencyCode,
218    /// Estimated min delivery days
219    pub min_delivery_days: Option<i32>,
220    /// Estimated max delivery days
221    pub max_delivery_days: Option<i32>,
222}
223
224impl ZoneShippingMethod {
225    /// Calculate the shipping rate for the given conditions
226    #[must_use]
227    pub fn calculate_rate(&self, weight: Option<Decimal>, order_total: Option<Decimal>) -> Decimal {
228        match self.method_type {
229            ShippingMethodType::Free => Decimal::ZERO,
230            ShippingMethodType::Flat | ShippingMethodType::Calculated => self.base_rate,
231            ShippingMethodType::WeightBased => {
232                if let Some(w) = weight {
233                    for condition in &self.conditions {
234                        let above_min = condition.min_weight.is_none_or(|min| w >= min);
235                        let below_max = condition.max_weight.is_none_or(|max| w <= max);
236                        if above_min && below_max {
237                            return condition.rate;
238                        }
239                    }
240                }
241                self.base_rate
242            }
243            ShippingMethodType::PriceBased => {
244                if let Some(total) = order_total {
245                    for condition in &self.conditions {
246                        let above_min = condition.min_price.is_none_or(|min| total >= min);
247                        let below_max = condition.max_price.is_none_or(|max| total <= max);
248                        if above_min && below_max {
249                            return condition.rate;
250                        }
251                    }
252                }
253                self.base_rate
254            }
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use chrono::Utc;
263    use rust_decimal_macros::dec;
264    use stateset_primitives::{CurrencyCode, ShippingMethodId, ShippingZoneId};
265
266    fn make_method(
267        method_type: ShippingMethodType,
268        base_rate: Decimal,
269        conditions: Vec<ShippingCondition>,
270    ) -> ZoneShippingMethod {
271        ZoneShippingMethod {
272            id: ShippingMethodId::new(),
273            zone_id: ShippingZoneId::new(),
274            name: "Test Method".to_string(),
275            carrier: Some("USPS".to_string()),
276            method_type,
277            base_rate,
278            currency: CurrencyCode::USD,
279            min_delivery_days: Some(3),
280            max_delivery_days: Some(7),
281            conditions,
282            is_active: true,
283            created_at: Utc::now(),
284            updated_at: Utc::now(),
285        }
286    }
287
288    // ---- calculate_rate: flat ----
289
290    #[test]
291    fn calculate_rate_flat_returns_base_rate() {
292        let method = make_method(ShippingMethodType::Flat, dec!(5.99), vec![]);
293        assert_eq!(method.calculate_rate(None, None), dec!(5.99));
294    }
295
296    // ---- calculate_rate: free ----
297
298    #[test]
299    fn calculate_rate_free_returns_zero() {
300        let method = make_method(ShippingMethodType::Free, dec!(5.99), vec![]);
301        assert_eq!(method.calculate_rate(None, None), Decimal::ZERO);
302    }
303
304    // ---- calculate_rate: weight-based ----
305
306    #[test]
307    fn calculate_rate_weight_based_matches_condition() {
308        let conditions = vec![
309            ShippingCondition {
310                min_weight: Some(dec!(0)),
311                max_weight: Some(dec!(500)),
312                min_price: None,
313                max_price: None,
314                rate: dec!(3.99),
315            },
316            ShippingCondition {
317                min_weight: Some(dec!(501)),
318                max_weight: Some(dec!(2000)),
319                min_price: None,
320                max_price: None,
321                rate: dec!(7.99),
322            },
323        ];
324        let method = make_method(ShippingMethodType::WeightBased, dec!(9.99), conditions);
325        assert_eq!(method.calculate_rate(Some(dec!(300)), None), dec!(3.99));
326        assert_eq!(method.calculate_rate(Some(dec!(1000)), None), dec!(7.99));
327    }
328
329    #[test]
330    fn calculate_rate_weight_based_falls_back_to_base_rate() {
331        let method = make_method(ShippingMethodType::WeightBased, dec!(9.99), vec![]);
332        // No conditions match — should return base_rate
333        assert_eq!(method.calculate_rate(Some(dec!(300)), None), dec!(9.99));
334    }
335
336    #[test]
337    fn calculate_rate_weight_based_falls_back_when_no_weight_provided() {
338        let conditions = vec![ShippingCondition {
339            min_weight: Some(dec!(0)),
340            max_weight: Some(dec!(1000)),
341            min_price: None,
342            max_price: None,
343            rate: dec!(3.99),
344        }];
345        let method = make_method(ShippingMethodType::WeightBased, dec!(9.99), conditions);
346        // weight is None — should fall back to base_rate
347        assert_eq!(method.calculate_rate(None, None), dec!(9.99));
348    }
349
350    // ---- calculate_rate: price-based ----
351
352    #[test]
353    fn calculate_rate_price_based_free_over_threshold() {
354        let conditions = vec![
355            ShippingCondition {
356                min_weight: None,
357                max_weight: None,
358                min_price: Some(dec!(75.00)),
359                max_price: None,
360                rate: dec!(0.00),
361            },
362            ShippingCondition {
363                min_weight: None,
364                max_weight: None,
365                min_price: Some(dec!(0.00)),
366                max_price: Some(dec!(74.99)),
367                rate: dec!(5.99),
368            },
369        ];
370        let method = make_method(ShippingMethodType::PriceBased, dec!(5.99), conditions);
371        assert_eq!(method.calculate_rate(None, Some(dec!(100.00))), dec!(0.00));
372        assert_eq!(method.calculate_rate(None, Some(dec!(50.00))), dec!(5.99));
373    }
374
375    // ---- enum Display / FromStr round-trips ----
376
377    #[test]
378    fn shipping_method_type_display_fromstr_roundtrip() {
379        for method_type in [
380            ShippingMethodType::Flat,
381            ShippingMethodType::WeightBased,
382            ShippingMethodType::PriceBased,
383            ShippingMethodType::Calculated,
384            ShippingMethodType::Free,
385        ] {
386            let s = method_type.to_string();
387            let parsed: ShippingMethodType = s.parse().unwrap();
388            assert_eq!(parsed, method_type, "round-trip failed for {s}");
389        }
390    }
391
392    // ---- Default ----
393
394    #[test]
395    fn shipping_method_type_default_is_flat() {
396        assert_eq!(ShippingMethodType::default(), ShippingMethodType::Flat);
397    }
398}