1use 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 let conn = self.conn()?;
177
178 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 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}