Skip to main content

stateset_db/sqlite/
currency.rs

1//! SQLite implementation of currency repository
2
3use 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
20/// Fractional digits of precision for stored exchange rates.
21///
22/// Postgres stores `exchange_rates.rate` as `DECIMAL(20, 10)`, which rounds any
23/// higher-precision rate to 10 dp (half away from zero). SQLite stores the rate
24/// as TEXT with no such constraint, so we round explicitly to keep conversions
25/// identical across backends.
26const RATE_SCALE: u32 = 10;
27
28/// SQLite currency repository
29#[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        // Same currency = rate of 1
79        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                // Try to find inverse rate
104                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(), // Generated for inverse
120                            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        // Round to the same scale Postgres enforces via `DECIMAL(20, 10)` so a
203        // high-precision rate converts identically on both backends.
204        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            // Upsert the rate
211            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            // Record in history
233            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        // Fetch and return the rate
249        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        // Same currency = no conversion needed
268        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    // === Batch Operations ===
373
374    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            // Match Postgres `DECIMAL(20, 10)` precision (see `RATE_SCALE`).
392            let rate = input
393                .rate
394                .round_dp_with_strategy(RATE_SCALE, RoundingStrategy::MidpointAwayFromZero);
395
396            // Upsert the rate
397            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            // Record in history
419            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        // Fetch and return all the rates
439        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(&params);
479
480        let affected = tx.execute(&query, params_ref.as_slice()).map_err(map_db_error)?;
481
482        if affected != ids.len() {
483            // Not all rates were found - rollback by not committing
484            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        // The database pre-seeds exchange rates, so assert relative to the
535        // unpaginated result rather than an absolute count.
536        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}