1use super::{
4 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_row, parse_enum_row,
5 parse_uuid_row, with_immediate_transaction,
6};
7use chrono::{DateTime, Utc};
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rust_decimal::Decimal;
11use stateset_core::{
12 CommerceError, CreatePriceSchedule, CurrencyCode, PriceSchedule, PriceScheduleEntry,
13 PriceScheduleFilter, PriceScheduleId, PriceScheduleRepository, ProductId, Result,
14 UpdatePriceSchedule,
15};
16
17#[derive(Debug)]
18pub struct SqlitePriceScheduleRepository {
19 pool: Pool<SqliteConnectionManager>,
20}
21
22impl SqlitePriceScheduleRepository {
23 #[must_use]
24 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
25 Self { pool }
26 }
27
28 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
29 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
30 }
31
32 fn row_to_schedule(row: &rusqlite::Row<'_>) -> rusqlite::Result<PriceSchedule> {
33 Ok(PriceSchedule {
34 id: parse_uuid_row(&row.get::<_, String>("id")?, "price_schedule", "id")?.into(),
35 name: row.get("name")?,
36 code: row.get("code")?,
37 currency: parse_enum_row::<CurrencyCode>(
38 &row.get::<_, String>("currency")?,
39 "price_schedule",
40 "currency",
41 )?,
42 starts_at: parse_datetime_opt_row(
43 row.get::<_, Option<String>>("starts_at")?,
44 "price_schedule",
45 "starts_at",
46 )?,
47 ends_at: parse_datetime_opt_row(
48 row.get::<_, Option<String>>("ends_at")?,
49 "price_schedule",
50 "ends_at",
51 )?,
52 is_active: row.get::<_, i32>("is_active")? != 0,
53 priority: row.get("priority")?,
54 created_at: parse_datetime_row(
55 &row.get::<_, String>("created_at")?,
56 "price_schedule",
57 "created_at",
58 )?,
59 updated_at: parse_datetime_row(
60 &row.get::<_, String>("updated_at")?,
61 "price_schedule",
62 "updated_at",
63 )?,
64 })
65 }
66
67 fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<PriceScheduleEntry> {
68 Ok(PriceScheduleEntry {
69 price_schedule_id: parse_uuid_row(
70 &row.get::<_, String>("price_schedule_id")?,
71 "price_schedule_entry",
72 "price_schedule_id",
73 )?
74 .into(),
75 product_id: parse_uuid_row(
76 &row.get::<_, String>("product_id")?,
77 "price_schedule_entry",
78 "product_id",
79 )?
80 .into(),
81 price: parse_decimal_row(
82 &row.get::<_, String>("price")?,
83 "price_schedule_entry",
84 "price",
85 )?,
86 created_at: parse_datetime_row(
87 &row.get::<_, String>("created_at")?,
88 "price_schedule_entry",
89 "created_at",
90 )?,
91 updated_at: parse_datetime_row(
92 &row.get::<_, String>("updated_at")?,
93 "price_schedule_entry",
94 "updated_at",
95 )?,
96 })
97 }
98}
99
100impl PriceScheduleRepository for SqlitePriceScheduleRepository {
101 fn create(&self, input: CreatePriceSchedule) -> Result<PriceSchedule> {
102 let id = PriceScheduleId::new();
103 let id_str = id.to_string();
104 let now_str = Utc::now().to_rfc3339();
105 let currency = input.currency.unwrap_or(CurrencyCode::USD);
106 with_immediate_transaction(&self.pool, |tx| {
107 tx.execute(
108 "INSERT INTO price_schedules (id, name, code, currency, starts_at, ends_at, is_active, priority, created_at, updated_at)
109 VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
110 rusqlite::params![
111 &id_str,
112 &input.name,
113 &input.code,
114 currency.to_string(),
115 input.starts_at.map(|d| d.to_rfc3339()),
116 input.ends_at.map(|d| d.to_rfc3339()),
117 input.priority,
118 &now_str,
119 &now_str,
120 ],
121 )?;
122 tx.query_row(
123 "SELECT * FROM price_schedules WHERE id = ?",
124 [&id_str],
125 Self::row_to_schedule,
126 )
127 })
128 }
129
130 fn get(&self, id: PriceScheduleId) -> Result<Option<PriceSchedule>> {
131 let conn = self.conn()?;
132 match conn.query_row(
133 "SELECT * FROM price_schedules WHERE id = ?",
134 [id.to_string()],
135 Self::row_to_schedule,
136 ) {
137 Ok(s) => Ok(Some(s)),
138 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
139 Err(e) => Err(map_db_error(e)),
140 }
141 }
142
143 fn update(&self, id: PriceScheduleId, input: UpdatePriceSchedule) -> Result<PriceSchedule> {
144 let id_str = id.to_string();
145 let now_str = Utc::now().to_rfc3339();
146 with_immediate_transaction(&self.pool, |tx| {
147 let mut sets = vec!["updated_at = ?".to_string()];
148 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now_str.clone())];
149
150 if let Some(ref name) = input.name {
151 sets.push("name = ?".into());
152 params.push(Box::new(name.clone()));
153 }
154 if let Some(ref code) = input.code {
155 sets.push("code = ?".into());
156 params.push(Box::new(code.clone()));
157 }
158 if let Some(starts_at) = input.starts_at {
159 sets.push("starts_at = ?".into());
160 params.push(Box::new(starts_at.to_rfc3339()));
161 }
162 if let Some(ends_at) = input.ends_at {
163 sets.push("ends_at = ?".into());
164 params.push(Box::new(ends_at.to_rfc3339()));
165 }
166 if let Some(is_active) = input.is_active {
167 sets.push("is_active = ?".into());
168 params.push(Box::new(is_active as i32));
169 }
170 if let Some(priority) = input.priority {
171 sets.push("priority = ?".into());
172 params.push(Box::new(priority));
173 }
174
175 let sql = format!("UPDATE price_schedules SET {} WHERE id = ?", sets.join(", "));
176 params.push(Box::new(id_str.clone()));
177 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
178 params.iter().map(|p| p.as_ref()).collect();
179 tx.execute(&sql, param_refs.as_slice())?;
180
181 tx.query_row(
182 "SELECT * FROM price_schedules WHERE id = ?",
183 [&id_str],
184 Self::row_to_schedule,
185 )
186 })
187 }
188
189 fn list(&self, filter: PriceScheduleFilter) -> Result<Vec<PriceSchedule>> {
190 let conn = self.conn()?;
191 let mut sql = "SELECT * FROM price_schedules WHERE 1=1".to_string();
192 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
193 if let Some(active) = filter.is_active {
194 sql.push_str(" AND is_active = ?");
195 params.push(Box::new(active as i32));
196 }
197 sql.push_str(" ORDER BY priority DESC, created_at DESC");
198 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
199 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
200 params.iter().map(|p| p.as_ref()).collect();
201 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
202 let rows = stmt
203 .query_map(param_refs.as_slice(), Self::row_to_schedule)
204 .map_err(map_db_error)?
205 .collect::<std::result::Result<Vec<_>, _>>()
206 .map_err(map_db_error)?;
207 Ok(rows)
208 }
209
210 fn delete(&self, id: PriceScheduleId) -> Result<()> {
211 let id_str = id.to_string();
212 with_immediate_transaction(&self.pool, |tx| {
213 tx.execute(
214 "DELETE FROM price_schedule_entries WHERE price_schedule_id = ?",
215 [&id_str],
216 )?;
217 tx.execute("DELETE FROM price_schedules WHERE id = ?", [&id_str])?;
218 Ok(())
219 })
220 }
221
222 fn set_entry(
223 &self,
224 id: PriceScheduleId,
225 product_id: ProductId,
226 price: Decimal,
227 ) -> Result<PriceScheduleEntry> {
228 let id_str = id.to_string();
229 let product_str = product_id.to_string();
230 let now_str = Utc::now().to_rfc3339();
231 with_immediate_transaction(&self.pool, |tx| {
232 tx.execute(
233 "INSERT INTO price_schedule_entries (price_schedule_id, product_id, price, created_at, updated_at)
234 VALUES (?, ?, ?, ?, ?)
235 ON CONFLICT(price_schedule_id, product_id) DO UPDATE SET
236 price = excluded.price,
237 updated_at = excluded.updated_at",
238 rusqlite::params![&id_str, &product_str, price.to_string(), &now_str, &now_str],
239 )?;
240 tx.query_row(
241 "SELECT * FROM price_schedule_entries WHERE price_schedule_id = ? AND product_id = ?",
242 rusqlite::params![&id_str, &product_str],
243 Self::row_to_entry,
244 )
245 })
246 }
247
248 fn delete_entry(&self, id: PriceScheduleId, product_id: ProductId) -> Result<()> {
249 let conn = self.conn()?;
250 conn.execute(
251 "DELETE FROM price_schedule_entries WHERE price_schedule_id = ? AND product_id = ?",
252 rusqlite::params![id.to_string(), product_id.to_string()],
253 )
254 .map_err(map_db_error)?;
255 Ok(())
256 }
257
258 fn list_entries(&self, id: PriceScheduleId) -> Result<Vec<PriceScheduleEntry>> {
259 let conn = self.conn()?;
260 let mut stmt = conn
261 .prepare("SELECT * FROM price_schedule_entries WHERE price_schedule_id = ? ORDER BY product_id")
262 .map_err(map_db_error)?;
263 let rows = stmt
264 .query_map([id.to_string()], Self::row_to_entry)
265 .map_err(map_db_error)?
266 .collect::<std::result::Result<Vec<_>, _>>()
267 .map_err(map_db_error)?;
268 Ok(rows)
269 }
270
271 fn resolve_price(&self, product_id: ProductId, at: DateTime<Utc>) -> Result<Option<Decimal>> {
272 let schedules =
275 self.list(PriceScheduleFilter { is_active: Some(true), ..Default::default() })?;
276 let conn = self.conn()?;
277 for schedule in schedules {
278 if !schedule.is_effective_at(at) {
279 continue;
280 }
281 let price: Option<String> = conn
282 .query_row(
283 "SELECT price FROM price_schedule_entries WHERE price_schedule_id = ? AND product_id = ?",
284 rusqlite::params![schedule.id.to_string(), product_id.to_string()],
285 |r| r.get(0),
286 )
287 .ok();
288 if let Some(price_str) = price {
289 let price = price_str
290 .parse::<Decimal>()
291 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
292 return Ok(Some(price));
293 }
294 }
295 Ok(None)
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::DatabaseConfig;
303 use crate::sqlite::SqliteDatabase;
304 use chrono::TimeZone;
305 use rust_decimal_macros::dec;
306
307 fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
308 Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
309 }
310
311 fn test_repo() -> SqlitePriceScheduleRepository {
312 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
313 SqlitePriceScheduleRepository::new(db.pool().clone())
314 }
315
316 fn new_schedule(
317 repo: &SqlitePriceScheduleRepository,
318 starts: DateTime<Utc>,
319 ends: DateTime<Utc>,
320 priority: i32,
321 ) -> PriceSchedule {
322 repo.create(CreatePriceSchedule {
323 name: "Sale".into(),
324 code: None,
325 currency: Some(CurrencyCode::USD),
326 starts_at: Some(starts),
327 ends_at: Some(ends),
328 priority,
329 })
330 .expect("create schedule")
331 }
332
333 #[test]
334 fn create_update_entries() {
335 let repo = test_repo();
336 let s = new_schedule(&repo, at(2026, 6, 1), at(2026, 6, 30), 0);
337 let product = ProductId::new();
338 let entry = repo.set_entry(s.id, product, dec!(19.99)).expect("entry");
339 assert_eq!(entry.price, dec!(19.99));
340 repo.set_entry(s.id, product, dec!(15.00)).expect("upsert");
341 assert_eq!(repo.list_entries(s.id).expect("entries").len(), 1);
342 repo.delete_entry(s.id, product).expect("delete entry");
343 assert_eq!(repo.list_entries(s.id).expect("entries").len(), 0);
344 }
345
346 #[test]
347 fn resolve_within_window() {
348 let repo = test_repo();
349 let s = new_schedule(&repo, at(2026, 6, 1), at(2026, 6, 30), 0);
350 let product = ProductId::new();
351 repo.set_entry(s.id, product, dec!(9.99)).expect("entry");
352 assert_eq!(
353 repo.resolve_price(product, at(2026, 6, 15)).expect("resolve"),
354 Some(dec!(9.99))
355 );
356 assert_eq!(repo.resolve_price(product, at(2026, 7, 15)).expect("resolve"), None);
358 }
359
360 #[test]
361 fn resolve_highest_priority_wins() {
362 let repo = test_repo();
363 let low = new_schedule(&repo, at(2026, 6, 1), at(2026, 6, 30), 1);
364 let high = new_schedule(&repo, at(2026, 6, 1), at(2026, 6, 30), 10);
365 let product = ProductId::new();
366 repo.set_entry(low.id, product, dec!(20)).expect("low");
367 repo.set_entry(high.id, product, dec!(12)).expect("high");
368 assert_eq!(repo.resolve_price(product, at(2026, 6, 15)).expect("resolve"), Some(dec!(12)));
369 }
370
371 #[test]
372 fn delete_cascades_entries() {
373 let repo = test_repo();
374 let s = new_schedule(&repo, at(2026, 6, 1), at(2026, 6, 30), 0);
375 repo.set_entry(s.id, ProductId::new(), dec!(5)).expect("entry");
376 repo.delete(s.id).expect("delete");
377 assert!(repo.get(s.id).expect("get").is_none());
378 assert_eq!(repo.list_entries(s.id).expect("entries").len(), 0);
379 }
380}