Skip to main content

stateset_db/sqlite/
x402_credits.rs

1//! SQLite x402 credit ledger repository implementation
2
3use super::{
4    map_db_error, params_refs, parse_datetime_row, parse_enum_row, parse_uuid_row,
5    with_immediate_transaction,
6};
7use chrono::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rusqlite::OptionalExtension;
11use stateset_core::{
12    CommerceError, Result, X402Asset, X402CreditAccount, X402CreditAdjustment, X402CreditDirection,
13    X402CreditRepository, X402CreditTransaction, X402CreditTransactionFilter, X402Network,
14};
15use uuid::Uuid;
16
17/// SQLite implementation of `X402CreditRepository`
18#[derive(Debug)]
19pub struct SqliteX402CreditRepository {
20    pool: Pool<SqliteConnectionManager>,
21}
22
23impl SqliteX402CreditRepository {
24    #[must_use]
25    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
26        Self { pool }
27    }
28
29    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
30        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
31    }
32
33    fn row_to_account(row: &rusqlite::Row<'_>) -> rusqlite::Result<X402CreditAccount> {
34        Ok(X402CreditAccount {
35            id: parse_uuid_row(&row.get::<_, String>("id")?, "x402_credit_account", "id")?,
36            payer_address: row.get("payer_address")?,
37            asset: parse_enum_row(&row.get::<_, String>("asset")?, "x402_credit_account", "asset")?,
38            network: parse_enum_row(
39                &row.get::<_, String>("network")?,
40                "x402_credit_account",
41                "network",
42            )?,
43            balance: row.get::<_, i64>("balance")? as u64,
44            created_at: parse_datetime_row(
45                &row.get::<_, String>("created_at")?,
46                "x402_credit_account",
47                "created_at",
48            )?,
49            updated_at: parse_datetime_row(
50                &row.get::<_, String>("updated_at")?,
51                "x402_credit_account",
52                "updated_at",
53            )?,
54        })
55    }
56
57    fn row_to_transaction(row: &rusqlite::Row<'_>) -> rusqlite::Result<X402CreditTransaction> {
58        Ok(X402CreditTransaction {
59            id: parse_uuid_row(&row.get::<_, String>("id")?, "x402_credit_tx", "id")?,
60            account_id: parse_uuid_row(
61                &row.get::<_, String>("account_id")?,
62                "x402_credit_tx",
63                "account_id",
64            )?,
65            payer_address: row.get("payer_address")?,
66            asset: parse_enum_row(&row.get::<_, String>("asset")?, "x402_credit_tx", "asset")?,
67            network: parse_enum_row(
68                &row.get::<_, String>("network")?,
69                "x402_credit_tx",
70                "network",
71            )?,
72            direction: parse_enum_row(
73                &row.get::<_, String>("direction")?,
74                "x402_credit_tx",
75                "direction",
76            )?,
77            amount: row.get::<_, i64>("amount")? as u64,
78            balance_after: row.get::<_, i64>("balance_after")? as u64,
79            reason: row.get("reason")?,
80            reference_id: row.get("reference_id")?,
81            metadata: row.get("metadata")?,
82            created_at: parse_datetime_row(
83                &row.get::<_, String>("created_at")?,
84                "x402_credit_tx",
85                "created_at",
86            )?,
87        })
88    }
89
90    fn insert_account_if_missing(
91        tx: &rusqlite::Transaction<'_>,
92        payer_address: &str,
93        asset: X402Asset,
94        network: X402Network,
95    ) -> std::result::Result<(Uuid, i64), rusqlite::Error> {
96        let asset_str = asset.to_string().to_lowercase();
97        let network_str = network.to_string();
98
99        let existing: Option<(String, i64)> = tx
100            .query_row(
101                "SELECT id, balance FROM x402_credit_accounts WHERE payer_address = ? AND asset = ? AND network = ?",
102                rusqlite::params![payer_address, asset_str, network_str],
103                |row| Ok((row.get(0)?, row.get(1)?)),
104            )
105            .optional()?;
106
107        if let Some((id, balance)) = existing {
108            let account_id = Uuid::parse_str(&id).map_err(|e| {
109                rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
110                    format!("Invalid UUID: {e}"),
111                )))
112            })?;
113            return Ok((account_id, balance));
114        }
115
116        let account_id = Uuid::new_v4();
117        let now = Utc::now().to_rfc3339();
118        tx.execute(
119            "INSERT INTO x402_credit_accounts (
120                id, payer_address, asset, network, balance, created_at, updated_at
121            ) VALUES (?, ?, ?, ?, ?, ?, ?)",
122            rusqlite::params![
123                account_id.to_string(),
124                payer_address,
125                asset_str,
126                network_str,
127                0i64,
128                now,
129                now,
130            ],
131        )?;
132
133        Ok((account_id, 0))
134    }
135}
136
137impl X402CreditRepository for SqliteX402CreditRepository {
138    fn get_account(
139        &self,
140        payer_address: &str,
141        asset: X402Asset,
142        network: X402Network,
143    ) -> Result<Option<X402CreditAccount>> {
144        let conn = self.conn()?;
145        let asset_str = asset.to_string().to_lowercase();
146        let network_str = network.to_string();
147
148        let mut stmt = conn
149            .prepare(
150                "SELECT * FROM x402_credit_accounts WHERE payer_address = ? AND asset = ? AND network = ?",
151            )
152            .map_err(map_db_error)?;
153
154        stmt.query_row(
155            rusqlite::params![payer_address, asset_str, network_str],
156            Self::row_to_account,
157        )
158        .optional()
159        .map_err(map_db_error)
160    }
161
162    fn get_or_create_account(
163        &self,
164        payer_address: &str,
165        asset: X402Asset,
166        network: X402Network,
167    ) -> Result<X402CreditAccount> {
168        let conn = self.conn()?;
169        let now = Utc::now().to_rfc3339();
170        let account_id = Uuid::new_v4();
171
172        conn.execute(
173            "INSERT OR IGNORE INTO x402_credit_accounts (
174                id, payer_address, asset, network, balance, created_at, updated_at
175            ) VALUES (?, ?, ?, ?, ?, ?, ?)",
176            rusqlite::params![
177                account_id.to_string(),
178                payer_address,
179                asset.to_string().to_lowercase(),
180                network.to_string(),
181                0i64,
182                now,
183                now,
184            ],
185        )
186        .map_err(map_db_error)?;
187
188        self.get_account(payer_address, asset, network)?.ok_or(CommerceError::NotFound)
189    }
190
191    fn get_balance(
192        &self,
193        payer_address: &str,
194        asset: X402Asset,
195        network: X402Network,
196    ) -> Result<u64> {
197        let account = self.get_or_create_account(payer_address, asset, network)?;
198        Ok(account.balance)
199    }
200
201    fn adjust_balance(&self, input: X402CreditAdjustment) -> Result<X402CreditTransaction> {
202        let X402CreditAdjustment {
203            payer_address,
204            asset,
205            network,
206            direction,
207            amount,
208            reason,
209            reference_id,
210            metadata,
211        } = input;
212
213        let amount_i64 = i64::try_from(amount).map_err(|_| {
214            CommerceError::ValidationError("x402 credit amount exceeds i64 range".to_string())
215        })?;
216
217        with_immediate_transaction(&self.pool, |tx| {
218            let (account_id, current_balance) =
219                Self::insert_account_if_missing(tx, &payer_address, asset, network)?;
220
221            if current_balance < 0 {
222                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
223                    CommerceError::DatabaseError(
224                        "x402 credit balance negative in database".to_string(),
225                    ),
226                )));
227            }
228
229            let new_balance = match input.direction {
230                X402CreditDirection::Credit => {
231                    current_balance.checked_add(amount_i64).ok_or_else(|| {
232                        rusqlite::Error::ToSqlConversionFailure(Box::new(
233                            CommerceError::ValidationError("x402 balance overflow".to_string()),
234                        ))
235                    })?
236                }
237                X402CreditDirection::Debit => {
238                    if current_balance < amount_i64 {
239                        return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
240                            CommerceError::NotPermitted(
241                                "Insufficient x402 credit balance".to_string(),
242                            ),
243                        )));
244                    }
245                    current_balance - amount_i64
246                }
247                _ => current_balance,
248            };
249
250            let now = Utc::now();
251            let now_str = now.to_rfc3339();
252
253            tx.execute(
254                "UPDATE x402_credit_accounts SET balance = ?, updated_at = ? WHERE id = ?",
255                rusqlite::params![new_balance, now_str, account_id.to_string()],
256            )?;
257
258            let tx_id = Uuid::new_v4();
259            let asset_str = asset.to_string().to_lowercase();
260            let network_str = network.to_string();
261            let direction_str = direction.to_string();
262
263            tx.execute(
264                "INSERT INTO x402_credit_transactions (
265                    id, account_id, payer_address, asset, network, direction,
266                    amount, balance_after, reason, reference_id, metadata, created_at
267                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
268                rusqlite::params![
269                    tx_id.to_string(),
270                    account_id.to_string(),
271                    payer_address.as_str(),
272                    asset_str,
273                    network_str,
274                    direction_str,
275                    amount_i64,
276                    new_balance,
277                    reason.as_deref(),
278                    reference_id.as_deref(),
279                    metadata.as_deref(),
280                    now_str,
281                ],
282            )?;
283
284            Ok(X402CreditTransaction {
285                id: tx_id,
286                account_id,
287                payer_address: payer_address.clone(),
288                asset,
289                network,
290                direction,
291                amount,
292                balance_after: new_balance as u64,
293                reason: reason.clone(),
294                reference_id: reference_id.clone(),
295                metadata: metadata.clone(),
296                created_at: now,
297            })
298        })
299    }
300
301    fn list_transactions(
302        &self,
303        filter: X402CreditTransactionFilter,
304    ) -> Result<Vec<X402CreditTransaction>> {
305        let conn = self.conn()?;
306        let mut conditions: Vec<String> = Vec::new();
307        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
308
309        if let Some(payer) = filter.payer_address.as_ref() {
310            conditions.push("payer_address = ?".to_string());
311            params.push(Box::new(payer.clone()));
312        }
313        if let Some(asset) = filter.asset {
314            conditions.push("asset = ?".to_string());
315            params.push(Box::new(asset.to_string().to_lowercase()));
316        }
317        if let Some(network) = filter.network {
318            conditions.push("network = ?".to_string());
319            params.push(Box::new(network.to_string()));
320        }
321        if let Some(direction) = filter.direction {
322            conditions.push("direction = ?".to_string());
323            params.push(Box::new(direction.to_string()));
324        }
325
326        let mut sql = "SELECT * FROM x402_credit_transactions".to_string();
327        if !conditions.is_empty() {
328            sql.push_str(" WHERE ");
329            sql.push_str(&conditions.join(" AND "));
330        }
331        sql.push_str(" ORDER BY created_at DESC");
332
333        if let Some(limit) = filter.limit {
334            sql.push_str(" LIMIT ?");
335            params.push(Box::new(i64::from(limit)));
336        }
337        if let Some(offset) = filter.offset {
338            sql.push_str(" OFFSET ?");
339            params.push(Box::new(i64::from(offset)));
340        }
341
342        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
343        let param_refs = params_refs(&params);
344        let rows = stmt
345            .query_map(param_refs.as_slice(), Self::row_to_transaction)
346            .map_err(map_db_error)?;
347
348        let mut results = Vec::new();
349        for row in rows {
350            results.push(row.map_err(map_db_error)?);
351        }
352        Ok(results)
353    }
354}