use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, ShippingMethodId, ShippingZoneId};
use strum::{Display, EnumString};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum ShippingMethodType {
#[default]
Flat,
WeightBased,
PriceBased,
Calculated,
Free,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShippingZone {
pub id: ShippingZoneId,
pub name: String,
pub countries: Vec<String>,
pub regions: Vec<String>,
pub postal_codes: Vec<String>,
pub priority: i32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneShippingMethod {
pub id: ShippingMethodId,
pub zone_id: ShippingZoneId,
pub name: String,
pub carrier: Option<String>,
pub method_type: ShippingMethodType,
pub base_rate: Decimal,
pub currency: CurrencyCode,
pub min_delivery_days: Option<i32>,
pub max_delivery_days: Option<i32>,
pub conditions: Vec<ShippingCondition>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShippingCondition {
pub min_weight: Option<Decimal>,
pub max_weight: Option<Decimal>,
pub min_price: Option<Decimal>,
pub max_price: Option<Decimal>,
pub rate: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateShippingZone {
pub name: String,
pub countries: Vec<String>,
pub regions: Vec<String>,
pub postal_codes: Vec<String>,
pub priority: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateShippingZone {
pub name: Option<String>,
pub countries: Option<Vec<String>>,
pub regions: Option<Vec<String>>,
pub postal_codes: Option<Vec<String>>,
pub priority: Option<i32>,
pub is_active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateZoneShippingMethod {
pub zone_id: ShippingZoneId,
pub name: String,
pub carrier: Option<String>,
pub method_type: ShippingMethodType,
pub base_rate: Decimal,
pub currency: CurrencyCode,
pub min_delivery_days: Option<i32>,
pub max_delivery_days: Option<i32>,
pub conditions: Vec<ShippingCondition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ShippingZoneFilter {
pub country: Option<String>,
pub is_active: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ZoneShippingMethodFilter {
pub zone_id: Option<ShippingZoneId>,
pub carrier: Option<String>,
pub method_type: Option<ShippingMethodType>,
pub is_active: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneShippingRateRequest {
pub country: String,
pub region: Option<String>,
pub postal_code: Option<String>,
pub weight: Option<Decimal>,
pub order_total: Option<Decimal>,
pub currency: CurrencyCode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneShippingRate {
pub method_id: ShippingMethodId,
pub method_name: String,
pub carrier: Option<String>,
pub rate: Decimal,
pub currency: CurrencyCode,
pub min_delivery_days: Option<i32>,
pub max_delivery_days: Option<i32>,
}
impl ZoneShippingMethod {
pub fn calculate_rate(&self, weight: Option<Decimal>, order_total: Option<Decimal>) -> Decimal {
match self.method_type {
ShippingMethodType::Free => Decimal::ZERO,
ShippingMethodType::Flat | ShippingMethodType::Calculated => self.base_rate,
ShippingMethodType::WeightBased => {
if let Some(w) = weight {
for condition in &self.conditions {
let above_min = condition.min_weight.is_none_or(|min| w >= min);
let below_max = condition.max_weight.is_none_or(|max| w <= max);
if above_min && below_max {
return condition.rate;
}
}
}
self.base_rate
}
ShippingMethodType::PriceBased => {
if let Some(total) = order_total {
for condition in &self.conditions {
let above_min = condition.min_price.is_none_or(|min| total >= min);
let below_max = condition.max_price.is_none_or(|max| total <= max);
if above_min && below_max {
return condition.rate;
}
}
}
self.base_rate
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use rust_decimal_macros::dec;
use stateset_primitives::{CurrencyCode, ShippingMethodId, ShippingZoneId};
fn make_method(
method_type: ShippingMethodType,
base_rate: Decimal,
conditions: Vec<ShippingCondition>,
) -> ZoneShippingMethod {
ZoneShippingMethod {
id: ShippingMethodId::new(),
zone_id: ShippingZoneId::new(),
name: "Test Method".to_string(),
carrier: Some("USPS".to_string()),
method_type,
base_rate,
currency: CurrencyCode::USD,
min_delivery_days: Some(3),
max_delivery_days: Some(7),
conditions,
is_active: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn calculate_rate_flat_returns_base_rate() {
let method = make_method(ShippingMethodType::Flat, dec!(5.99), vec![]);
assert_eq!(method.calculate_rate(None, None), dec!(5.99));
}
#[test]
fn calculate_rate_free_returns_zero() {
let method = make_method(ShippingMethodType::Free, dec!(5.99), vec![]);
assert_eq!(method.calculate_rate(None, None), Decimal::ZERO);
}
#[test]
fn calculate_rate_weight_based_matches_condition() {
let conditions = vec![
ShippingCondition {
min_weight: Some(dec!(0)),
max_weight: Some(dec!(500)),
min_price: None,
max_price: None,
rate: dec!(3.99),
},
ShippingCondition {
min_weight: Some(dec!(501)),
max_weight: Some(dec!(2000)),
min_price: None,
max_price: None,
rate: dec!(7.99),
},
];
let method = make_method(ShippingMethodType::WeightBased, dec!(9.99), conditions);
assert_eq!(method.calculate_rate(Some(dec!(300)), None), dec!(3.99));
assert_eq!(method.calculate_rate(Some(dec!(1000)), None), dec!(7.99));
}
#[test]
fn calculate_rate_weight_based_falls_back_to_base_rate() {
let method = make_method(ShippingMethodType::WeightBased, dec!(9.99), vec![]);
assert_eq!(method.calculate_rate(Some(dec!(300)), None), dec!(9.99));
}
#[test]
fn calculate_rate_weight_based_falls_back_when_no_weight_provided() {
let conditions = vec![ShippingCondition {
min_weight: Some(dec!(0)),
max_weight: Some(dec!(1000)),
min_price: None,
max_price: None,
rate: dec!(3.99),
}];
let method = make_method(ShippingMethodType::WeightBased, dec!(9.99), conditions);
assert_eq!(method.calculate_rate(None, None), dec!(9.99));
}
#[test]
fn calculate_rate_price_based_free_over_threshold() {
let conditions = vec![
ShippingCondition {
min_weight: None,
max_weight: None,
min_price: Some(dec!(75.00)),
max_price: None,
rate: dec!(0.00),
},
ShippingCondition {
min_weight: None,
max_weight: None,
min_price: Some(dec!(0.00)),
max_price: Some(dec!(74.99)),
rate: dec!(5.99),
},
];
let method = make_method(ShippingMethodType::PriceBased, dec!(5.99), conditions);
assert_eq!(method.calculate_rate(None, Some(dec!(100.00))), dec!(0.00));
assert_eq!(method.calculate_rate(None, Some(dec!(50.00))), dec!(5.99));
}
#[test]
fn shipping_method_type_display_fromstr_roundtrip() {
for method_type in [
ShippingMethodType::Flat,
ShippingMethodType::WeightBased,
ShippingMethodType::PriceBased,
ShippingMethodType::Calculated,
ShippingMethodType::Free,
] {
let s = method_type.to_string();
let parsed: ShippingMethodType = s.parse().unwrap();
assert_eq!(parsed, method_type, "round-trip failed for {s}");
}
}
#[test]
fn shipping_method_type_default_is_flat() {
assert_eq!(ShippingMethodType::default(), ShippingMethodType::Flat);
}
}