Skip to main content

stateset_db/sqlite/
zone_shipping_methods.rs

1//! SQLite implementation of zone shipping method repository
2
3use super::{
4    map_db_error, parse_datetime_row, parse_decimal_row, parse_enum_row, parse_json_row,
5    parse_uuid_row, with_immediate_transaction,
6};
7use chrono::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use stateset_core::{
11    CommerceError, CreateZoneShippingMethod, Result, ShippingCondition, ShippingMethodId,
12    ZoneShippingMethod, ZoneShippingMethodFilter, ZoneShippingMethodRepository, ZoneShippingRate,
13    ZoneShippingRateRequest,
14};
15
16#[derive(Debug)]
17pub struct SqliteZoneShippingMethodRepository {
18    pool: Pool<SqliteConnectionManager>,
19}
20
21impl SqliteZoneShippingMethodRepository {
22    #[must_use]
23    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
24        Self { pool }
25    }
26
27    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
28        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
29    }
30
31    fn row_to_method(row: &rusqlite::Row<'_>) -> rusqlite::Result<ZoneShippingMethod> {
32        let conditions_json: String = row.get("conditions")?;
33        let conditions: Vec<ShippingCondition> =
34            parse_json_row(&conditions_json, "zone_shipping_method", "conditions")?;
35
36        Ok(ZoneShippingMethod {
37            id: parse_uuid_row(&row.get::<_, String>("id")?, "zone_shipping_method", "id")?.into(),
38            zone_id: parse_uuid_row(
39                &row.get::<_, String>("zone_id")?,
40                "zone_shipping_method",
41                "zone_id",
42            )?
43            .into(),
44            name: row.get("name")?,
45            carrier: row.get("carrier")?,
46            method_type: parse_enum_row(
47                &row.get::<_, String>("method_type")?,
48                "zone_shipping_method",
49                "method_type",
50            )?,
51            base_rate: parse_decimal_row(
52                &row.get::<_, String>("base_rate")?,
53                "zone_shipping_method",
54                "base_rate",
55            )?,
56            currency: parse_enum_row(
57                &row.get::<_, String>("currency")?,
58                "zone_shipping_method",
59                "currency",
60            )?,
61            min_delivery_days: row.get("min_delivery_days")?,
62            max_delivery_days: row.get("max_delivery_days")?,
63            conditions,
64            is_active: row.get::<_, i32>("is_active")? != 0,
65            created_at: parse_datetime_row(
66                &row.get::<_, String>("created_at")?,
67                "zone_shipping_method",
68                "created_at",
69            )?,
70            updated_at: parse_datetime_row(
71                &row.get::<_, String>("updated_at")?,
72                "zone_shipping_method",
73                "updated_at",
74            )?,
75        })
76    }
77}
78
79impl ZoneShippingMethodRepository for SqliteZoneShippingMethodRepository {
80    fn create(&self, input: CreateZoneShippingMethod) -> Result<ZoneShippingMethod> {
81        let id = ShippingMethodId::new();
82        let now = Utc::now();
83        let id_str = id.to_string();
84        let now_str = now.to_rfc3339();
85
86        let conditions_json = serde_json::to_string(&input.conditions)
87            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
88
89        with_immediate_transaction(&self.pool, |tx| {
90            tx.execute(
91                "INSERT INTO zone_shipping_methods (id, zone_id, name, carrier, method_type, base_rate, currency, min_delivery_days, max_delivery_days, conditions, is_active, created_at, updated_at)
92                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
93                rusqlite::params![
94                    &id_str,
95                    input.zone_id.to_string(),
96                    &input.name,
97                    &input.carrier,
98                    input.method_type.to_string(),
99                    input.base_rate.to_string(),
100                    input.currency.to_string(),
101                    input.min_delivery_days,
102                    input.max_delivery_days,
103                    &conditions_json,
104                    &now_str,
105                    &now_str,
106                ],
107            )?;
108
109            tx.query_row(
110                "SELECT * FROM zone_shipping_methods WHERE id = ?",
111                [&id_str],
112                Self::row_to_method,
113            )
114        })
115    }
116
117    fn get(&self, id: ShippingMethodId) -> Result<Option<ZoneShippingMethod>> {
118        let conn = self.conn()?;
119        match conn.query_row(
120            "SELECT * FROM zone_shipping_methods WHERE id = ?",
121            [id.to_string()],
122            Self::row_to_method,
123        ) {
124            Ok(m) => Ok(Some(m)),
125            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
126            Err(e) => Err(map_db_error(e)),
127        }
128    }
129
130    fn list(&self, filter: ZoneShippingMethodFilter) -> Result<Vec<ZoneShippingMethod>> {
131        let conn = self.conn()?;
132        let mut sql = "SELECT * FROM zone_shipping_methods WHERE 1=1".to_string();
133        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
134
135        if let Some(zone_id) = filter.zone_id {
136            sql.push_str(" AND zone_id = ?");
137            params.push(Box::new(zone_id.to_string()));
138        }
139        if let Some(ref carrier) = filter.carrier {
140            sql.push_str(" AND carrier = ?");
141            params.push(Box::new(carrier.clone()));
142        }
143        if let Some(method_type) = filter.method_type {
144            sql.push_str(" AND method_type = ?");
145            params.push(Box::new(method_type.to_string()));
146        }
147        if let Some(is_active) = filter.is_active {
148            sql.push_str(" AND is_active = ?");
149            params.push(Box::new(is_active as i32));
150        }
151
152        sql.push_str(" ORDER BY created_at DESC");
153
154        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
155
156        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
157            params.iter().map(|p| p.as_ref()).collect();
158        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
159        let rows = stmt
160            .query_map(param_refs.as_slice(), Self::row_to_method)
161            .map_err(map_db_error)?
162            .collect::<std::result::Result<Vec<_>, _>>()
163            .map_err(map_db_error)?;
164        Ok(rows)
165    }
166
167    fn delete(&self, id: ShippingMethodId) -> Result<()> {
168        let conn = self.conn()?;
169        conn.execute("DELETE FROM zone_shipping_methods WHERE id = ?", [id.to_string()])
170            .map_err(map_db_error)?;
171        Ok(())
172    }
173
174    fn calculate_rates(&self, request: ZoneShippingRateRequest) -> Result<Vec<ZoneShippingRate>> {
175        // First find matching zones, then get active methods in those zones
176        let conn = self.conn()?;
177
178        // Get all active zone shipping methods
179        let mut stmt = conn
180            .prepare("SELECT * FROM zone_shipping_methods WHERE is_active = 1")
181            .map_err(map_db_error)?;
182        let methods: Vec<ZoneShippingMethod> = stmt
183            .query_map([], Self::row_to_method)
184            .map_err(map_db_error)?
185            .collect::<std::result::Result<Vec<_>, _>>()
186            .map_err(map_db_error)?;
187
188        let rates: Vec<ZoneShippingRate> = methods
189            .iter()
190            .map(|method| {
191                let rate = method.calculate_rate(request.weight, request.order_total);
192                ZoneShippingRate {
193                    method_id: method.id,
194                    method_name: method.name.clone(),
195                    carrier: method.carrier.clone(),
196                    rate,
197                    currency: method.currency,
198                    min_delivery_days: method.min_delivery_days,
199                    max_delivery_days: method.max_delivery_days,
200                }
201            })
202            .collect();
203
204        Ok(rates)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::DatabaseConfig;
212    use crate::sqlite::SqliteDatabase;
213    use rust_decimal_macros::dec;
214    use stateset_core::{CurrencyCode, ShippingMethodType, ShippingZoneId};
215
216    fn test_repo() -> SqliteZoneShippingMethodRepository {
217        let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
218        let conn = db.conn().expect("conn");
219        conn.execute_batch(
220            "CREATE TABLE IF NOT EXISTS zone_shipping_methods (
221                id TEXT PRIMARY KEY,
222                zone_id TEXT NOT NULL,
223                name TEXT NOT NULL,
224                carrier TEXT,
225                method_type TEXT NOT NULL DEFAULT 'flat',
226                base_rate TEXT NOT NULL DEFAULT '0',
227                currency TEXT NOT NULL DEFAULT 'USD',
228                min_delivery_days INTEGER,
229                max_delivery_days INTEGER,
230                conditions TEXT NOT NULL DEFAULT '[]',
231                is_active INTEGER NOT NULL DEFAULT 1,
232                created_at TEXT NOT NULL DEFAULT (datetime('now')),
233                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
234            );",
235        )
236        .expect("create table");
237        SqliteZoneShippingMethodRepository::new(db.pool().clone())
238    }
239
240    #[test]
241    fn create_and_get_method() {
242        let repo = test_repo();
243        let zone_id = ShippingZoneId::new();
244        let method = repo
245            .create(CreateZoneShippingMethod {
246                zone_id,
247                name: "Standard Shipping".into(),
248                carrier: Some("USPS".into()),
249                method_type: ShippingMethodType::Flat,
250                base_rate: dec!(5.99),
251                currency: CurrencyCode::USD,
252                min_delivery_days: Some(3),
253                max_delivery_days: Some(7),
254                conditions: vec![],
255            })
256            .expect("create");
257
258        assert_eq!(method.name, "Standard Shipping");
259        assert_eq!(method.base_rate, dec!(5.99));
260        assert_eq!(method.carrier, Some("USPS".to_string()));
261        assert!(method.is_active);
262
263        let fetched = repo.get(method.id).expect("get").expect("found");
264        assert_eq!(fetched.id, method.id);
265        assert_eq!(fetched.zone_id, zone_id);
266    }
267
268    #[test]
269    fn list_and_delete_methods() {
270        let repo = test_repo();
271        let zone_id = ShippingZoneId::new();
272
273        repo.create(CreateZoneShippingMethod {
274            zone_id,
275            name: "Standard".into(),
276            carrier: Some("USPS".into()),
277            method_type: ShippingMethodType::Flat,
278            base_rate: dec!(5.99),
279            currency: CurrencyCode::USD,
280            min_delivery_days: Some(3),
281            max_delivery_days: Some(7),
282            conditions: vec![],
283        })
284        .expect("create standard");
285
286        repo.create(CreateZoneShippingMethod {
287            zone_id,
288            name: "Express".into(),
289            carrier: Some("FedEx".into()),
290            method_type: ShippingMethodType::Flat,
291            base_rate: dec!(15.99),
292            currency: CurrencyCode::USD,
293            min_delivery_days: Some(1),
294            max_delivery_days: Some(2),
295            conditions: vec![],
296        })
297        .expect("create express");
298
299        let all = repo.list(ZoneShippingMethodFilter::default()).expect("list");
300        assert_eq!(all.len(), 2);
301
302        // Filter by zone
303        let by_zone = repo
304            .list(ZoneShippingMethodFilter { zone_id: Some(zone_id), ..Default::default() })
305            .expect("list by zone");
306        assert_eq!(by_zone.len(), 2);
307
308        repo.delete(all[0].id).expect("delete");
309        let remaining = repo.list(ZoneShippingMethodFilter::default()).expect("list after delete");
310        assert_eq!(remaining.len(), 1);
311    }
312}