1use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use stateset_primitives::{CurrencyCode, ShippingMethodId, ShippingZoneId};
10use strum::{Display, EnumString};
11
12#[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 #[default]
22 Flat,
23 WeightBased,
25 PriceBased,
27 Calculated,
29 Free,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ShippingZone {
36 pub id: ShippingZoneId,
38 pub name: String,
40 pub countries: Vec<String>,
42 pub regions: Vec<String>,
44 pub postal_codes: Vec<String>,
46 pub priority: i32,
48 pub is_active: bool,
50 pub created_at: DateTime<Utc>,
52 pub updated_at: DateTime<Utc>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ZoneShippingMethod {
60 pub id: ShippingMethodId,
62 pub zone_id: ShippingZoneId,
64 pub name: String,
66 pub carrier: Option<String>,
68 pub method_type: ShippingMethodType,
70 pub base_rate: Decimal,
72 pub currency: CurrencyCode,
74 pub min_delivery_days: Option<i32>,
76 pub max_delivery_days: Option<i32>,
78 pub conditions: Vec<ShippingCondition>,
80 pub is_active: bool,
82 pub created_at: DateTime<Utc>,
84 pub updated_at: DateTime<Utc>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ShippingCondition {
91 pub min_weight: Option<Decimal>,
93 pub max_weight: Option<Decimal>,
95 pub min_price: Option<Decimal>,
97 pub max_price: Option<Decimal>,
99 pub rate: Decimal,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CreateShippingZone {
106 pub name: String,
108 pub countries: Vec<String>,
110 pub regions: Vec<String>,
112 pub postal_codes: Vec<String>,
114 pub priority: Option<i32>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct UpdateShippingZone {
121 pub name: Option<String>,
123 pub countries: Option<Vec<String>>,
125 pub regions: Option<Vec<String>>,
127 pub postal_codes: Option<Vec<String>>,
129 pub priority: Option<i32>,
131 pub is_active: Option<bool>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CreateZoneShippingMethod {
138 pub zone_id: ShippingZoneId,
140 pub name: String,
142 pub carrier: Option<String>,
144 pub method_type: ShippingMethodType,
146 pub base_rate: Decimal,
148 pub currency: CurrencyCode,
150 pub min_delivery_days: Option<i32>,
152 pub max_delivery_days: Option<i32>,
154 pub conditions: Vec<ShippingCondition>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct ShippingZoneFilter {
161 pub country: Option<String>,
163 pub is_active: Option<bool>,
165 pub limit: Option<u32>,
167 pub offset: Option<u32>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub struct ZoneShippingMethodFilter {
174 pub zone_id: Option<ShippingZoneId>,
176 pub carrier: Option<String>,
178 pub method_type: Option<ShippingMethodType>,
180 pub is_active: Option<bool>,
182 pub limit: Option<u32>,
184 pub offset: Option<u32>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ZoneShippingRateRequest {
191 pub country: String,
193 pub region: Option<String>,
195 pub postal_code: Option<String>,
197 pub weight: Option<Decimal>,
199 pub order_total: Option<Decimal>,
201 pub currency: CurrencyCode,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ZoneShippingRate {
208 pub method_id: ShippingMethodId,
210 pub method_name: String,
212 pub carrier: Option<String>,
214 pub rate: Decimal,
216 pub currency: CurrencyCode,
218 pub min_delivery_days: Option<i32>,
220 pub max_delivery_days: Option<i32>,
222}
223
224impl ZoneShippingMethod {
225 #[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 #[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 #[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 #[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 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 assert_eq!(method.calculate_rate(None, None), dec!(9.99));
348 }
349
350 #[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 #[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 #[test]
395 fn shipping_method_type_default_is_flat() {
396 assert_eq!(ShippingMethodType::default(), ShippingMethodType::Flat);
397 }
398}