1use chrono::Utc;
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::params;
7use rust_decimal::{Decimal, RoundingStrategy};
8use stateset_core::{
9 CommerceError, ConversionResult, ConvertCurrency, Currency, ExchangeRate, ExchangeRateFilter,
10 Result, SetExchangeRate, StoreCurrencySettings,
11};
12use uuid::Uuid;
13
14use super::{
15 build_in_clause, map_db_error, params_refs, parse_datetime_row, parse_decimal_row,
16 parse_enum_row, parse_json_row, parse_uuid_row, uuid_params,
17};
18use stateset_core::{BatchResult, validate_batch_size};
19
20const RATE_SCALE: u32 = 10;
27
28#[derive(Debug)]
30pub struct SqliteCurrencyRepository {
31 pool: Pool<SqliteConnectionManager>,
32}
33
34impl SqliteCurrencyRepository {
35 #[must_use]
36 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
37 Self { pool }
38 }
39
40 fn row_to_exchange_rate(row: &rusqlite::Row<'_>) -> rusqlite::Result<ExchangeRate> {
41 Ok(ExchangeRate {
42 id: parse_uuid_row(&row.get::<_, String>("id")?, "exchange_rate", "id")?,
43 base_currency: parse_enum_row(
44 &row.get::<_, String>("base_currency")?,
45 "exchange_rate",
46 "base_currency",
47 )?,
48 quote_currency: parse_enum_row(
49 &row.get::<_, String>("quote_currency")?,
50 "exchange_rate",
51 "quote_currency",
52 )?,
53 rate: parse_decimal_row(&row.get::<_, String>("rate")?, "exchange_rate", "rate")?,
54 source: row.get("source")?,
55 rate_at: parse_datetime_row(
56 &row.get::<_, String>("rate_at")?,
57 "exchange_rate",
58 "rate_at",
59 )?,
60 created_at: parse_datetime_row(
61 &row.get::<_, String>("created_at")?,
62 "exchange_rate",
63 "created_at",
64 )?,
65 updated_at: parse_datetime_row(
66 &row.get::<_, String>("updated_at")?,
67 "exchange_rate",
68 "updated_at",
69 )?,
70 })
71 }
72}
73
74impl stateset_core::CurrencyRepository for SqliteCurrencyRepository {
75 fn get_rate(&self, from: Currency, to: Currency) -> Result<Option<ExchangeRate>> {
76 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
77
78 if from == to {
80 return Ok(Some(ExchangeRate {
81 id: Uuid::nil(),
82 base_currency: from,
83 quote_currency: to,
84 rate: Decimal::ONE,
85 source: "identity".into(),
86 rate_at: Utc::now(),
87 created_at: Utc::now(),
88 updated_at: Utc::now(),
89 }));
90 }
91
92 let result = conn.query_row(
93 "SELECT id, base_currency, quote_currency, rate, source, rate_at, created_at, updated_at
94 FROM exchange_rates
95 WHERE base_currency = ? AND quote_currency = ?",
96 params![from.code(), to.code()],
97 Self::row_to_exchange_rate,
98 );
99
100 match result {
101 Ok(rate) => Ok(Some(rate)),
102 Err(rusqlite::Error::QueryReturnedNoRows) => {
103 let inverse_result = conn.query_row(
105 "SELECT id, base_currency, quote_currency, rate, source, rate_at, created_at, updated_at
106 FROM exchange_rates
107 WHERE base_currency = ? AND quote_currency = ?",
108 params![to.code(), from.code()],
109 |row| {
110 let direct = Self::row_to_exchange_rate(row)?;
111 let inverse_rate = direct.rate;
112 let rate = if inverse_rate.is_zero() {
113 Decimal::ZERO
114 } else {
115 Decimal::ONE / inverse_rate
116 };
117
118 Ok(ExchangeRate {
119 id: Uuid::new_v4(), base_currency: from,
121 quote_currency: to,
122 rate,
123 source: format!("inverse:{}", direct.source),
124 rate_at: direct.rate_at,
125 created_at: Utc::now(),
126 updated_at: Utc::now(),
127 })
128 },
129 );
130
131 match inverse_result {
132 Ok(rate) => Ok(Some(rate)),
133 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
134 Err(e) => Err(map_db_error(e)),
135 }
136 }
137 Err(e) => Err(map_db_error(e)),
138 }
139 }
140
141 fn get_rates_for(&self, base: Currency) -> Result<Vec<ExchangeRate>> {
142 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
143
144 let mut stmt = conn
145 .prepare(
146 "SELECT id, base_currency, quote_currency, rate, source, rate_at, created_at, updated_at
147 FROM exchange_rates
148 WHERE base_currency = ?
149 ORDER BY quote_currency",
150 )
151 .map_err(map_db_error)?;
152
153 let rows = stmt
154 .query_map(params![base.code()], Self::row_to_exchange_rate)
155 .map_err(map_db_error)?;
156
157 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(map_db_error)
158 }
159
160 fn list_rates(&self, filter: ExchangeRateFilter) -> Result<Vec<ExchangeRate>> {
161 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
162
163 let mut query = String::from(
164 "SELECT id, base_currency, quote_currency, rate, source, rate_at, created_at, updated_at
165 FROM exchange_rates WHERE 1=1",
166 );
167 let mut params_vec: Vec<String> = Vec::new();
168
169 if let Some(base) = &filter.base_currency {
170 query.push_str(" AND base_currency = ?");
171 params_vec.push(base.code().to_string());
172 }
173
174 if let Some(quote) = &filter.quote_currency {
175 query.push_str(" AND quote_currency = ?");
176 params_vec.push(quote.code().to_string());
177 }
178
179 if let Some(since) = &filter.since {
180 query.push_str(" AND rate_at >= ?");
181 params_vec.push(since.to_rfc3339());
182 }
183
184 query.push_str(" ORDER BY base_currency, quote_currency");
185 crate::sqlite::append_limit_offset(&mut query, filter.limit, filter.offset);
186
187 let mut stmt = conn.prepare(&query).map_err(map_db_error)?;
188 let params: Vec<&dyn rusqlite::ToSql> =
189 params_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
190
191 let rows =
192 stmt.query_map(params.as_slice(), Self::row_to_exchange_rate).map_err(map_db_error)?;
193
194 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(map_db_error)
195 }
196
197 fn set_rate(&self, input: SetExchangeRate) -> Result<ExchangeRate> {
198 let id = Uuid::new_v4();
199 let now = Utc::now();
200 let source = input.source.unwrap_or_else(|| "manual".into());
201
202 let rate =
205 input.rate.round_dp_with_strategy(RATE_SCALE, RoundingStrategy::MidpointAwayFromZero);
206
207 {
208 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
209
210 conn.execute(
212 "INSERT INTO exchange_rates (id, base_currency, quote_currency, rate, source, rate_at, created_at, updated_at)
213 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
214 ON CONFLICT (base_currency, quote_currency) DO UPDATE SET
215 rate = excluded.rate,
216 source = excluded.source,
217 rate_at = excluded.rate_at,
218 updated_at = excluded.updated_at",
219 params![
220 id.to_string(),
221 input.base_currency.code(),
222 input.quote_currency.code(),
223 rate.to_string(),
224 source,
225 now.to_rfc3339(),
226 now.to_rfc3339(),
227 now.to_rfc3339()
228 ],
229 )
230 .map_err(map_db_error)?;
231
232 conn.execute(
234 "INSERT INTO exchange_rate_history (id, base_currency, quote_currency, rate, source, rate_at)
235 VALUES (?, ?, ?, ?, ?, ?)",
236 params![
237 Uuid::new_v4().to_string(),
238 input.base_currency.code(),
239 input.quote_currency.code(),
240 rate.to_string(),
241 source,
242 now.to_rfc3339()
243 ],
244 )
245 .map_err(map_db_error)?;
246 }
247
248 self.get_rate(input.base_currency, input.quote_currency)?.ok_or(CommerceError::NotFound)
250 }
251
252 fn set_rates(&self, rates: Vec<SetExchangeRate>) -> Result<Vec<ExchangeRate>> {
253 rates.into_iter().map(|r| self.set_rate(r)).collect()
254 }
255
256 fn delete_rate(&self, id: Uuid) -> Result<()> {
257 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
258
259 let affected = conn
260 .execute("DELETE FROM exchange_rates WHERE id = ?", params![id.to_string()])
261 .map_err(map_db_error)?;
262
263 if affected == 0 { Err(CommerceError::NotFound) } else { Ok(()) }
264 }
265
266 fn convert(&self, input: ConvertCurrency) -> Result<ConversionResult> {
267 if input.from == input.to {
269 return Ok(ConversionResult {
270 original_amount: input.amount,
271 original_currency: input.from,
272 converted_amount: input.amount,
273 target_currency: input.to,
274 rate: Decimal::ONE,
275 inverse_rate: Decimal::ONE,
276 rate_at: Utc::now(),
277 });
278 }
279
280 let rate = self.get_rate(input.from, input.to)?.ok_or(CommerceError::ValidationError(
281 format!("No exchange rate found for {} to {}", input.from, input.to),
282 ))?;
283
284 let converted_amount = input.amount * rate.rate;
285 let inverse_rate =
286 if rate.rate.is_zero() { Decimal::ZERO } else { Decimal::ONE / rate.rate };
287
288 Ok(ConversionResult {
289 original_amount: input.amount,
290 original_currency: input.from,
291 converted_amount,
292 target_currency: input.to,
293 rate: rate.rate,
294 inverse_rate,
295 rate_at: rate.rate_at,
296 })
297 }
298
299 fn get_settings(&self) -> Result<StoreCurrencySettings> {
300 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
301
302 let result = conn.query_row(
303 "SELECT base_currency, enabled_currencies, auto_convert, rounding_mode
304 FROM store_currency_settings
305 WHERE id = 'default'",
306 [],
307 |row| {
308 let base_currency = parse_enum_row(
309 &row.get::<_, String>(0)?,
310 "store_currency_settings",
311 "base_currency",
312 )?;
313 let enabled_currencies: Vec<Currency> = parse_json_row(
314 &row.get::<_, String>(1)?,
315 "store_currency_settings",
316 "enabled_currencies",
317 )?;
318 let auto_convert: bool = row.get::<_, i32>(2)? != 0;
319 let rounding_mode = parse_enum_row(
320 &row.get::<_, String>(3)?,
321 "store_currency_settings",
322 "rounding_mode",
323 )?;
324
325 Ok(StoreCurrencySettings {
326 base_currency,
327 enabled_currencies,
328 auto_convert,
329 rounding_mode,
330 })
331 },
332 );
333
334 match result {
335 Ok(settings) => Ok(settings),
336 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(StoreCurrencySettings::default()),
337 Err(e) => Err(map_db_error(e)),
338 }
339 }
340
341 fn update_settings(&self, settings: StoreCurrencySettings) -> Result<StoreCurrencySettings> {
342 let enabled_json = serde_json::to_string(&settings.enabled_currencies)
343 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
344
345 let rounding_str = settings.rounding_mode.to_string();
346
347 {
348 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
349
350 conn.execute(
351 "INSERT INTO store_currency_settings (id, base_currency, enabled_currencies, auto_convert, rounding_mode, updated_at)
352 VALUES ('default', ?, ?, ?, ?, datetime('now'))
353 ON CONFLICT (id) DO UPDATE SET
354 base_currency = excluded.base_currency,
355 enabled_currencies = excluded.enabled_currencies,
356 auto_convert = excluded.auto_convert,
357 rounding_mode = excluded.rounding_mode,
358 updated_at = excluded.updated_at",
359 params![
360 settings.base_currency.code(),
361 enabled_json,
362 i32::from(settings.auto_convert),
363 rounding_str
364 ],
365 )
366 .map_err(map_db_error)?;
367 }
368
369 self.get_settings()
370 }
371
372 fn set_rates_atomic(&self, rates: Vec<SetExchangeRate>) -> Result<Vec<ExchangeRate>> {
375 validate_batch_size(&rates)?;
376
377 if rates.is_empty() {
378 return Ok(Vec::new());
379 }
380
381 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
382 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
383
384 let now = Utc::now();
385 let mut rate_ids: Vec<(Currency, Currency)> = Vec::with_capacity(rates.len());
386
387 for input in &rates {
388 let id = Uuid::new_v4();
389 let source = input.source.clone().unwrap_or_else(|| "manual".into());
390
391 let rate = input
393 .rate
394 .round_dp_with_strategy(RATE_SCALE, RoundingStrategy::MidpointAwayFromZero);
395
396 tx.execute(
398 "INSERT INTO exchange_rates (id, base_currency, quote_currency, rate, source, rate_at, created_at, updated_at)
399 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
400 ON CONFLICT (base_currency, quote_currency) DO UPDATE SET
401 rate = excluded.rate,
402 source = excluded.source,
403 rate_at = excluded.rate_at,
404 updated_at = excluded.updated_at",
405 params![
406 id.to_string(),
407 input.base_currency.code(),
408 input.quote_currency.code(),
409 rate.to_string(),
410 source,
411 now.to_rfc3339(),
412 now.to_rfc3339(),
413 now.to_rfc3339()
414 ],
415 )
416 .map_err(map_db_error)?;
417
418 tx.execute(
420 "INSERT INTO exchange_rate_history (id, base_currency, quote_currency, rate, source, rate_at)
421 VALUES (?, ?, ?, ?, ?, ?)",
422 params![
423 Uuid::new_v4().to_string(),
424 input.base_currency.code(),
425 input.quote_currency.code(),
426 rate.to_string(),
427 source,
428 now.to_rfc3339()
429 ],
430 )
431 .map_err(map_db_error)?;
432
433 rate_ids.push((input.base_currency, input.quote_currency));
434 }
435
436 tx.commit().map_err(map_db_error)?;
437
438 let mut results = Vec::with_capacity(rate_ids.len());
440 for (from, to) in rate_ids {
441 if let Some(rate) = self.get_rate(from, to)? {
442 results.push(rate);
443 }
444 }
445
446 Ok(results)
447 }
448
449 fn delete_rates_batch(&self, ids: Vec<Uuid>) -> Result<BatchResult<Uuid>> {
450 validate_batch_size(&ids)?;
451
452 let mut result = BatchResult::with_capacity(ids.len());
453
454 for (index, id) in ids.into_iter().enumerate() {
455 match self.delete_rate(id) {
456 Ok(()) => result.record_success(id),
457 Err(e) => result.record_failure(index, Some(id.to_string()), &e),
458 }
459 }
460
461 Ok(result)
462 }
463
464 fn delete_rates_atomic(&self, ids: Vec<Uuid>) -> Result<()> {
465 validate_batch_size(&ids)?;
466
467 if ids.is_empty() {
468 return Ok(());
469 }
470
471 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
472 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
473
474 let in_clause = build_in_clause(ids.len());
475 let query = format!("DELETE FROM exchange_rates WHERE id IN ({in_clause})");
476
477 let params = uuid_params(&ids);
478 let params_ref = params_refs(¶ms);
479
480 let affected = tx.execute(&query, params_ref.as_slice()).map_err(map_db_error)?;
481
482 if affected != ids.len() {
483 return Err(CommerceError::NotFound);
485 }
486
487 tx.commit().map_err(map_db_error)?;
488 Ok(())
489 }
490
491 fn get_rates_batch(&self, pairs: Vec<(Currency, Currency)>) -> Result<Vec<ExchangeRate>> {
492 validate_batch_size(&pairs)?;
493
494 let mut results = Vec::with_capacity(pairs.len());
495
496 for (from, to) in pairs {
497 if let Some(rate) = self.get_rate(from, to)? {
498 results.push(rate);
499 }
500 }
501
502 Ok(results)
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::SqliteDatabase;
510 use rust_decimal_macros::dec;
511 use stateset_core::{Currency, CurrencyRepository, ExchangeRateFilter};
512
513 fn fresh_repo() -> SqliteCurrencyRepository {
514 SqliteDatabase::in_memory().expect("in-memory").currency()
515 }
516
517 fn set(repo: &SqliteCurrencyRepository, base: Currency, quote: Currency) {
518 repo.set_rate(SetExchangeRate {
519 base_currency: base,
520 quote_currency: quote,
521 rate: dec!(1.5),
522 source: None,
523 })
524 .expect("set rate");
525 }
526
527 #[test]
528 fn list_rates_applies_limit_and_offset() {
529 let repo = fresh_repo();
530 set(&repo, Currency::USD, Currency::EUR);
531 set(&repo, Currency::USD, Currency::GBP);
532 set(&repo, Currency::USD, Currency::JPY);
533
534 let all = repo.list_rates(ExchangeRateFilter::default()).expect("list all");
537 assert!(all.len() >= 3, "expected at least the three inserted rates");
538
539 let page = repo
540 .list_rates(ExchangeRateFilter { limit: Some(2), ..Default::default() })
541 .expect("limited");
542 assert_eq!(page.len(), 2, "limit must bound the result set");
543 assert_eq!(page[0].id, all[0].id);
544 assert_eq!(page[1].id, all[1].id);
545
546 let rest = repo
547 .list_rates(ExchangeRateFilter { offset: Some(2), ..Default::default() })
548 .expect("offset");
549 assert_eq!(rest.len(), all.len() - 2, "offset must skip the first rows");
550 assert_eq!(rest[0].id, all[2].id);
551 }
552}