Skip to main content

stateset_db/sqlite/
gift_cards.rs

1//! SQLite implementation of gift card repository
2
3use 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::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rust_decimal::Decimal;
11use stateset_core::{
12    CommerceError, CreateGiftCard, GiftCard, GiftCardFilter, GiftCardId, GiftCardRepository,
13    GiftCardStatus, GiftCardTransaction, GiftCardTransactionId, Result, UpdateGiftCard,
14};
15
16#[derive(Debug)]
17pub struct SqliteGiftCardRepository {
18    pool: Pool<SqliteConnectionManager>,
19}
20
21impl SqliteGiftCardRepository {
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_gift_card(row: &rusqlite::Row<'_>) -> rusqlite::Result<GiftCard> {
32        Ok(GiftCard {
33            id: parse_uuid_row(&row.get::<_, String>("id")?, "gift_card", "id")?.into(),
34            code: row.get("code")?,
35            initial_balance: parse_decimal_row(
36                &row.get::<_, String>("initial_balance")?,
37                "gift_card",
38                "initial_balance",
39            )?,
40            current_balance: parse_decimal_row(
41                &row.get::<_, String>("current_balance")?,
42                "gift_card",
43                "current_balance",
44            )?,
45            currency: parse_enum_row(&row.get::<_, String>("currency")?, "gift_card", "currency")?,
46            status: parse_enum_row(&row.get::<_, String>("status")?, "gift_card", "status")?,
47            recipient_email: row.get("customer_id")?,
48            sender_name: row.get("issued_by")?,
49            message: row.get("notes")?,
50            expires_at: parse_datetime_opt_row(row.get("expires_at")?, "gift_card", "expires_at")?,
51            created_at: parse_datetime_row(
52                &row.get::<_, String>("created_at")?,
53                "gift_card",
54                "created_at",
55            )?,
56            updated_at: parse_datetime_row(
57                &row.get::<_, String>("updated_at")?,
58                "gift_card",
59                "updated_at",
60            )?,
61        })
62    }
63
64    fn row_to_transaction(row: &rusqlite::Row<'_>) -> rusqlite::Result<GiftCardTransaction> {
65        Ok(GiftCardTransaction {
66            id: parse_uuid_row(&row.get::<_, String>("id")?, "gift_card_txn", "id")?.into(),
67            gift_card_id: parse_uuid_row(
68                &row.get::<_, String>("gift_card_id")?,
69                "gift_card_txn",
70                "gift_card_id",
71            )?
72            .into(),
73            amount: parse_decimal_row(&row.get::<_, String>("amount")?, "gift_card_txn", "amount")?,
74            balance_after: parse_decimal_row(
75                &row.get::<_, String>("balance_after")?,
76                "gift_card_txn",
77                "balance_after",
78            )?,
79            transaction_type: parse_enum_row(
80                &row.get::<_, String>("type")?,
81                "gift_card_txn",
82                "type",
83            )?,
84            reference_id: row.get("reference_id")?,
85            created_at: parse_datetime_row(
86                &row.get::<_, String>("created_at")?,
87                "gift_card_txn",
88                "created_at",
89            )?,
90        })
91    }
92
93    /// Generate a random gift card code (16 hex characters, grouped by 4).
94    fn generate_code() -> String {
95        let id = uuid::Uuid::new_v4();
96        let hex = id.simple().to_string();
97        let short = &hex[..16];
98        format!("{}-{}-{}-{}", &short[0..4], &short[4..8], &short[8..12], &short[12..16])
99            .to_uppercase()
100    }
101}
102
103impl GiftCardRepository for SqliteGiftCardRepository {
104    fn create(&self, input: CreateGiftCard) -> Result<GiftCard> {
105        // Reject a negative initial balance up front (the Postgres schema enforces
106        // this with a CHECK; SQLite has no such constraint and would otherwise mint
107        // a negative-balance gift card).
108        if input.initial_balance < Decimal::ZERO {
109            return Err(CommerceError::ValidationError(
110                "Gift card initial balance cannot be negative".to_string(),
111            ));
112        }
113
114        let id = GiftCardId::new();
115        let now = Utc::now();
116        let id_str = id.to_string();
117        let now_str = now.to_rfc3339();
118        let code = input.code.unwrap_or_else(Self::generate_code);
119
120        with_immediate_transaction(&self.pool, |tx| {
121            tx.execute(
122                "INSERT INTO gift_cards (id, code, initial_balance, current_balance, currency, status, customer_id, issued_by, notes, expires_at, created_at, updated_at)
123                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
124                rusqlite::params![
125                    &id_str,
126                    &code,
127                    input.initial_balance.to_string(),
128                    input.initial_balance.to_string(),
129                    input.currency.to_string(),
130                    "active",
131                    &input.recipient_email,
132                    &input.sender_name,
133                    &input.message,
134                    input.expires_at.map(|dt| dt.to_rfc3339()),
135                    &now_str,
136                    &now_str,
137                ],
138            )?;
139
140            tx.query_row("SELECT * FROM gift_cards WHERE id = ?", [&id_str], Self::row_to_gift_card)
141        })
142    }
143
144    fn get(&self, id: GiftCardId) -> Result<Option<GiftCard>> {
145        let conn = self.conn()?;
146        match conn.query_row(
147            "SELECT * FROM gift_cards WHERE id = ?",
148            [id.to_string()],
149            Self::row_to_gift_card,
150        ) {
151            Ok(gc) => Ok(Some(gc)),
152            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
153            Err(e) => Err(map_db_error(e)),
154        }
155    }
156
157    fn get_by_code(&self, code: &str) -> Result<Option<GiftCard>> {
158        let conn = self.conn()?;
159        match conn.query_row(
160            "SELECT * FROM gift_cards WHERE code = ?",
161            [code],
162            Self::row_to_gift_card,
163        ) {
164            Ok(gc) => Ok(Some(gc)),
165            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
166            Err(e) => Err(map_db_error(e)),
167        }
168    }
169
170    fn update(&self, id: GiftCardId, input: UpdateGiftCard) -> Result<GiftCard> {
171        let id_str = id.to_string();
172        let now_str = Utc::now().to_rfc3339();
173
174        with_immediate_transaction(&self.pool, |tx| {
175            let mut sets = vec!["updated_at = ?".to_string()];
176            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now_str.clone())];
177
178            if let Some(status) = input.status {
179                sets.push("status = ?".into());
180                params.push(Box::new(status.to_string()));
181            }
182            if let Some(ref recipient_email) = input.recipient_email {
183                sets.push("customer_id = ?".into());
184                params.push(Box::new(recipient_email.clone()));
185            }
186            if let Some(ref expires_at) = input.expires_at {
187                sets.push("expires_at = ?".into());
188                params.push(Box::new(expires_at.map(|dt| dt.to_rfc3339())));
189            }
190
191            let sql = format!("UPDATE gift_cards SET {} WHERE id = ?", sets.join(", "));
192            params.push(Box::new(id_str.clone()));
193
194            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
195                params.iter().map(|p| p.as_ref()).collect();
196            tx.execute(&sql, param_refs.as_slice())?;
197
198            tx.query_row("SELECT * FROM gift_cards WHERE id = ?", [&id_str], Self::row_to_gift_card)
199        })
200    }
201
202    fn list(&self, filter: GiftCardFilter) -> Result<Vec<GiftCard>> {
203        let conn = self.conn()?;
204        let mut sql = "SELECT * FROM gift_cards WHERE 1=1".to_string();
205        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
206
207        if let Some(status) = filter.status {
208            sql.push_str(" AND status = ?");
209            params.push(Box::new(status.to_string()));
210        }
211        if let Some(ref code) = filter.code {
212            sql.push_str(" AND code = ?");
213            params.push(Box::new(code.clone()));
214        }
215
216        sql.push_str(" ORDER BY created_at DESC");
217
218        // SQLite rejects OFFSET without LIMIT, so use `LIMIT -1` (unbounded) when
219        // only an offset is set.
220        match (filter.limit, filter.offset) {
221            (Some(limit), Some(offset)) => sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}")),
222            (Some(limit), None) => sql.push_str(&format!(" LIMIT {limit}")),
223            (None, Some(offset)) => sql.push_str(&format!(" LIMIT -1 OFFSET {offset}")),
224            (None, None) => {}
225        }
226
227        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
228            params.iter().map(|p| p.as_ref()).collect();
229        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
230        let cards = stmt
231            .query_map(param_refs.as_slice(), Self::row_to_gift_card)
232            .map_err(map_db_error)?
233            .collect::<std::result::Result<Vec<_>, _>>()
234            .map_err(map_db_error)?;
235        Ok(cards)
236    }
237
238    fn charge(
239        &self,
240        id: GiftCardId,
241        amount: Decimal,
242        reference_id: Option<String>,
243    ) -> Result<GiftCardTransaction> {
244        if amount <= Decimal::ZERO {
245            return Err(CommerceError::ValidationError(
246                "Charge amount must be positive".to_string(),
247            ));
248        }
249
250        let id_str = id.to_string();
251        let txn_id = GiftCardTransactionId::new();
252        let txn_id_str = txn_id.to_string();
253        let now = Utc::now();
254        let now_str = now.to_rfc3339();
255
256        with_immediate_transaction(&self.pool, |tx| {
257            // Fetch current card inside the transaction
258            let (current_balance_str, status_str, expires_at_raw): (
259                String,
260                String,
261                Option<String>,
262            ) = tx.query_row(
263                "SELECT current_balance, status, expires_at FROM gift_cards WHERE id = ?",
264                [&id_str],
265                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
266            )?;
267
268            let status: GiftCardStatus = parse_enum_row(&status_str, "gift_card", "status")?;
269            if status != GiftCardStatus::Active {
270                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
271                    CommerceError::ValidationError("Gift card is not active".to_string()),
272                )));
273            }
274
275            let expires_at = parse_datetime_opt_row(expires_at_raw, "gift_card", "expires_at")?;
276            if expires_at.is_some_and(|exp| exp < now) {
277                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
278                    CommerceError::ValidationError("Gift card has expired".to_string()),
279                )));
280            }
281
282            let current_balance =
283                parse_decimal_row(&current_balance_str, "gift_card", "current_balance")?;
284            if current_balance < amount {
285                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
286                    CommerceError::ValidationError("Insufficient gift card balance".to_string()),
287                )));
288            }
289
290            let new_balance = current_balance - amount;
291            let new_status = if new_balance.is_zero() {
292                GiftCardStatus::Depleted
293            } else {
294                GiftCardStatus::Active
295            };
296
297            tx.execute(
298                "UPDATE gift_cards SET current_balance = ?, status = ?, updated_at = ? WHERE id = ?",
299                rusqlite::params![
300                    new_balance.to_string(),
301                    new_status.to_string(),
302                    &now_str,
303                    &id_str,
304                ],
305            )?;
306
307            tx.execute(
308                "INSERT INTO gift_card_transactions (id, gift_card_id, amount, balance_after, type, reference_id, created_at)
309                 VALUES (?, ?, ?, ?, ?, ?, ?)",
310                rusqlite::params![
311                    &txn_id_str,
312                    &id_str,
313                    amount.to_string(),
314                    new_balance.to_string(),
315                    "charge",
316                    &reference_id,
317                    &now_str,
318                ],
319            )?;
320
321            tx.query_row(
322                "SELECT * FROM gift_card_transactions WHERE id = ?",
323                [&txn_id_str],
324                Self::row_to_transaction,
325            )
326        })
327    }
328
329    fn refund(
330        &self,
331        id: GiftCardId,
332        amount: Decimal,
333        reference_id: Option<String>,
334    ) -> Result<GiftCardTransaction> {
335        if amount <= Decimal::ZERO {
336            return Err(CommerceError::ValidationError(
337                "Refund amount must be positive".to_string(),
338            ));
339        }
340
341        let id_str = id.to_string();
342        let txn_id = GiftCardTransactionId::new();
343        let txn_id_str = txn_id.to_string();
344        let now_str = Utc::now().to_rfc3339();
345
346        with_immediate_transaction(&self.pool, |tx| {
347            let (current_balance_str, status_str): (String, String) = tx.query_row(
348                "SELECT current_balance, status FROM gift_cards WHERE id = ?",
349                [&id_str],
350                |row| Ok((row.get(0)?, row.get(1)?)),
351            )?;
352
353            let status: GiftCardStatus = parse_enum_row(&status_str, "gift_card", "status")?;
354            if status == GiftCardStatus::Disabled {
355                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
356                    CommerceError::ValidationError(
357                        "Cannot refund to a disabled gift card".to_string(),
358                    ),
359                )));
360            }
361
362            let current_balance =
363                parse_decimal_row(&current_balance_str, "gift_card", "current_balance")?;
364            let new_balance = current_balance + amount;
365            // Restore the balance without resurrecting an expired card.
366            let new_status = if status == GiftCardStatus::Expired {
367                GiftCardStatus::Expired
368            } else {
369                GiftCardStatus::Active
370            };
371
372            tx.execute(
373                "UPDATE gift_cards SET current_balance = ?, status = ?, updated_at = ? WHERE id = ?",
374                rusqlite::params![
375                    new_balance.to_string(),
376                    new_status.to_string(),
377                    &now_str,
378                    &id_str
379                ],
380            )?;
381
382            tx.execute(
383                "INSERT INTO gift_card_transactions (id, gift_card_id, amount, balance_after, type, reference_id, created_at)
384                 VALUES (?, ?, ?, ?, ?, ?, ?)",
385                rusqlite::params![
386                    &txn_id_str,
387                    &id_str,
388                    amount.to_string(),
389                    new_balance.to_string(),
390                    "refund",
391                    &reference_id,
392                    &now_str,
393                ],
394            )?;
395
396            tx.query_row(
397                "SELECT * FROM gift_card_transactions WHERE id = ?",
398                [&txn_id_str],
399                Self::row_to_transaction,
400            )
401        })
402    }
403
404    fn disable(&self, id: GiftCardId) -> Result<GiftCard> {
405        let id_str = id.to_string();
406        let now_str = Utc::now().to_rfc3339();
407
408        with_immediate_transaction(&self.pool, |tx| {
409            tx.execute(
410                "UPDATE gift_cards SET status = 'disabled', updated_at = ? WHERE id = ?",
411                rusqlite::params![&now_str, &id_str],
412            )?;
413
414            tx.query_row("SELECT * FROM gift_cards WHERE id = ?", [&id_str], Self::row_to_gift_card)
415        })
416    }
417
418    fn get_transactions(&self, gift_card_id: GiftCardId) -> Result<Vec<GiftCardTransaction>> {
419        let conn = self.conn()?;
420        let mut stmt = conn
421            .prepare(
422                "SELECT * FROM gift_card_transactions WHERE gift_card_id = ? ORDER BY created_at DESC",
423            )
424            .map_err(map_db_error)?;
425        let txns = stmt
426            .query_map([gift_card_id.to_string()], Self::row_to_transaction)
427            .map_err(map_db_error)?
428            .collect::<std::result::Result<Vec<_>, _>>()
429            .map_err(map_db_error)?;
430        Ok(txns)
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::DatabaseConfig;
438    use crate::sqlite::SqliteDatabase;
439    use rust_decimal_macros::dec;
440    use stateset_core::CurrencyCode;
441
442    fn test_repo() -> SqliteGiftCardRepository {
443        let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).unwrap();
444        let conn = db.conn().unwrap();
445        conn.execute_batch(
446            "CREATE TABLE IF NOT EXISTS gift_cards (
447                id TEXT PRIMARY KEY,
448                code TEXT NOT NULL UNIQUE,
449                initial_balance TEXT NOT NULL,
450                current_balance TEXT NOT NULL,
451                currency TEXT NOT NULL DEFAULT 'USD',
452                status TEXT NOT NULL DEFAULT 'active',
453                customer_id TEXT,
454                issued_by TEXT,
455                expires_at TEXT,
456                notes TEXT,
457                created_at TEXT NOT NULL DEFAULT (datetime('now')),
458                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
459            );
460            CREATE TABLE IF NOT EXISTS gift_card_transactions (
461                id TEXT PRIMARY KEY,
462                gift_card_id TEXT NOT NULL,
463                amount TEXT NOT NULL,
464                balance_after TEXT NOT NULL,
465                type TEXT NOT NULL,
466                reference_id TEXT,
467                notes TEXT,
468                created_at TEXT NOT NULL DEFAULT (datetime('now'))
469            );",
470        )
471        .unwrap();
472        SqliteGiftCardRepository::new(db.pool().clone())
473    }
474
475    #[test]
476    fn create_rejects_negative_initial_balance() {
477        let repo = test_repo();
478        let err = repo
479            .create(CreateGiftCard {
480                code: None,
481                initial_balance: dec!(-50.00),
482                currency: CurrencyCode::USD,
483                recipient_email: None,
484                sender_name: None,
485                message: None,
486                expires_at: None,
487            })
488            .expect_err("negative gift card initial balance must be rejected");
489        assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
490    }
491
492    #[test]
493    fn create_and_get() {
494        let repo = test_repo();
495        let gc = repo
496            .create(CreateGiftCard {
497                code: Some("GIFT-TEST-0001".into()),
498                initial_balance: dec!(50.00),
499                currency: CurrencyCode::USD,
500                recipient_email: Some("alice@example.com".into()),
501                sender_name: Some("Bob".into()),
502                message: Some("Happy birthday!".into()),
503                expires_at: None,
504            })
505            .unwrap();
506
507        assert_eq!(gc.code, "GIFT-TEST-0001");
508        assert_eq!(gc.initial_balance, dec!(50.00));
509        assert_eq!(gc.current_balance, dec!(50.00));
510        assert_eq!(gc.currency, CurrencyCode::USD);
511        assert_eq!(gc.status, GiftCardStatus::Active);
512        assert_eq!(gc.recipient_email.as_deref(), Some("alice@example.com"));
513        assert_eq!(gc.sender_name.as_deref(), Some("Bob"));
514        assert_eq!(gc.message.as_deref(), Some("Happy birthday!"));
515
516        let fetched = repo.get(gc.id).unwrap().unwrap();
517        assert_eq!(fetched.id, gc.id);
518        assert_eq!(fetched.code, gc.code);
519        assert_eq!(fetched.initial_balance, gc.initial_balance);
520    }
521
522    #[test]
523    fn get_by_code() {
524        let repo = test_repo();
525        let gc = repo
526            .create(CreateGiftCard {
527                code: Some("LOOKUP-CODE".into()),
528                initial_balance: dec!(25.00),
529                currency: CurrencyCode::USD,
530                recipient_email: None,
531                sender_name: None,
532                message: None,
533                expires_at: None,
534            })
535            .unwrap();
536
537        let found = repo.get_by_code("LOOKUP-CODE").unwrap().unwrap();
538        assert_eq!(found.id, gc.id);
539        assert_eq!(found.code, "LOOKUP-CODE");
540
541        let missing = repo.get_by_code("NO-SUCH-CODE").unwrap();
542        assert!(missing.is_none());
543    }
544
545    fn create_card(repo: &SqliteGiftCardRepository, code: &str, balance: Decimal) -> GiftCard {
546        repo.create(CreateGiftCard {
547            code: Some(code.into()),
548            initial_balance: balance,
549            currency: CurrencyCode::USD,
550            recipient_email: None,
551            sender_name: None,
552            message: None,
553            expires_at: None,
554        })
555        .unwrap()
556    }
557
558    #[test]
559    fn charge_rejects_nonpositive_amount() {
560        let repo = test_repo();
561        let gc = create_card(&repo, "CHARGE-NONPOS", dec!(50.00));
562
563        assert!(repo.charge(gc.id, Decimal::ZERO, None).is_err());
564        assert!(repo.charge(gc.id, dec!(-10.00), None).is_err());
565
566        let fetched = repo.get(gc.id).unwrap().unwrap();
567        assert_eq!(fetched.current_balance, dec!(50.00));
568        assert!(repo.get_transactions(gc.id).unwrap().is_empty());
569    }
570
571    #[test]
572    fn charge_rejects_date_expired_card() {
573        let repo = test_repo();
574        let gc = repo
575            .create(CreateGiftCard {
576                code: Some("CHARGE-EXPIRED".into()),
577                initial_balance: dec!(50.00),
578                currency: CurrencyCode::USD,
579                recipient_email: None,
580                sender_name: None,
581                message: None,
582                expires_at: Some(Utc::now() - chrono::Duration::days(1)),
583            })
584            .unwrap();
585
586        // Status is still 'active' — only the expiry date has passed.
587        assert_eq!(gc.status, GiftCardStatus::Active);
588        assert!(repo.charge(gc.id, dec!(10.00), None).is_err());
589
590        let fetched = repo.get(gc.id).unwrap().unwrap();
591        assert_eq!(fetched.current_balance, dec!(50.00));
592    }
593
594    #[test]
595    fn refund_rejects_nonpositive_amount() {
596        let repo = test_repo();
597        let gc = create_card(&repo, "REFUND-NONPOS", dec!(50.00));
598
599        assert!(repo.refund(gc.id, Decimal::ZERO, None).is_err());
600        assert!(repo.refund(gc.id, dec!(-10.00), None).is_err());
601
602        let fetched = repo.get(gc.id).unwrap().unwrap();
603        assert_eq!(fetched.current_balance, dec!(50.00));
604    }
605
606    #[test]
607    fn refund_rejects_disabled_card() {
608        let repo = test_repo();
609        let gc = create_card(&repo, "REFUND-DISABLED", dec!(50.00));
610        repo.disable(gc.id).unwrap();
611
612        assert!(repo.refund(gc.id, dec!(10.00), None).is_err());
613
614        let fetched = repo.get(gc.id).unwrap().unwrap();
615        assert_eq!(fetched.status, GiftCardStatus::Disabled);
616        assert_eq!(fetched.current_balance, dec!(50.00));
617    }
618
619    #[test]
620    fn concurrent_charges_cannot_overspend() {
621        use std::sync::{Arc, Barrier};
622        use std::thread;
623
624        let db = Arc::new(SqliteDatabase::new(&DatabaseConfig::in_memory()).unwrap());
625        let repo = SqliteGiftCardRepository::new(db.pool().clone());
626        let gc = create_card(&repo, "CHARGE-RACE", dec!(50.00));
627
628        let thread_count = 10;
629        let barrier = Arc::new(Barrier::new(thread_count));
630        let mut handles = Vec::new();
631        for _ in 0..thread_count {
632            let db = Arc::clone(&db);
633            let barrier = Arc::clone(&barrier);
634            let card_id = gc.id;
635            handles.push(thread::spawn(move || {
636                let repo = SqliteGiftCardRepository::new(db.pool().clone());
637                barrier.wait();
638                repo.charge(card_id, dec!(30.00), None)
639            }));
640        }
641
642        let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
643        let successes = results.iter().filter(|r| r.is_ok()).count();
644        // The $50 card can fund at most one $30 charge — the safety invariant is
645        // that no more than one succeeds (never overspent). Under extreme lock
646        // contention the sole winner can fail with a retryable "table is locked"
647        // error (the caller retries), so zero successes is an acceptable
648        // transient outcome; two or more would be an overspend bug.
649        assert!(successes <= 1, "gift card overspent under concurrency: {results:?}");
650
651        let fetched = repo.get(gc.id).unwrap().unwrap();
652        assert_eq!(
653            fetched.current_balance,
654            dec!(50.00) - dec!(30.00) * Decimal::from(successes as u64),
655            "balance must reflect exactly the successful charge: {results:?}"
656        );
657        assert_eq!(repo.get_transactions(gc.id).unwrap().len(), successes);
658    }
659
660    #[test]
661    fn charge_then_refund_roundtrip() {
662        let repo = test_repo();
663        let gc = create_card(&repo, "CHARGE-REFUND-RT", dec!(50.00));
664
665        let charge = repo.charge(gc.id, dec!(50.00), Some("ORD-1".into())).unwrap();
666        assert_eq!(charge.balance_after, Decimal::ZERO);
667        assert_eq!(repo.get(gc.id).unwrap().unwrap().status, GiftCardStatus::Depleted);
668
669        // Refund reactivates a depleted card.
670        let refund = repo.refund(gc.id, dec!(20.00), Some("ORD-1".into())).unwrap();
671        assert_eq!(refund.balance_after, dec!(20.00));
672        let fetched = repo.get(gc.id).unwrap().unwrap();
673        assert_eq!(fetched.status, GiftCardStatus::Active);
674        assert_eq!(fetched.current_balance, dec!(20.00));
675    }
676
677    #[test]
678    fn disable() {
679        let repo = test_repo();
680        let gc = repo
681            .create(CreateGiftCard {
682                code: Some("DISABLE-ME".into()),
683                initial_balance: dec!(100.00),
684                currency: CurrencyCode::USD,
685                recipient_email: None,
686                sender_name: None,
687                message: None,
688                expires_at: None,
689            })
690            .unwrap();
691
692        assert_eq!(gc.status, GiftCardStatus::Active);
693
694        let disabled = repo.disable(gc.id).unwrap();
695        assert_eq!(disabled.status, GiftCardStatus::Disabled);
696        assert_eq!(disabled.id, gc.id);
697
698        let fetched = repo.get(gc.id).unwrap().unwrap();
699        assert_eq!(fetched.status, GiftCardStatus::Disabled);
700    }
701}