zakat_sqlite/
sqlite.rs

1//! SQLite-based persistence for Zakat Ledger events.
2//!
3//! This module provides a production-ready `SqliteStore` implementation
4//! that persists ledger events to a SQLite database using `sqlx`.
5
6use async_trait::async_trait;
7use sqlx::{sqlite::SqlitePoolOptions, Row, SqlitePool};
8
9use zakat_core::types::{InvalidInputDetails, WealthType, ZakatError};
10use zakat_ledger::events::{LedgerEvent, TransactionType};
11use crate::persistence::LedgerStore;
12
13/// A SQLite-backed implementation of `LedgerStore`.
14///
15/// Uses connection pooling via `sqlx::SqlitePool` for efficient concurrent access.
16pub struct SqliteStore {
17    pool: SqlitePool,
18}
19
20impl SqliteStore {
21    /// Creates a new SQLite store and ensures the schema is initialized.
22    ///
23    /// # Arguments
24    /// * `db_url` - SQLite connection URL (e.g., `"sqlite::memory:"` or `"sqlite:ledger.db?mode=rwc"`)
25    ///
26    /// # Errors
27    /// Returns `ZakatError::NetworkError` if connection or migration fails.
28    pub async fn new(db_url: &str) -> Result<Self, ZakatError> {
29        let pool = SqlitePoolOptions::new()
30            .max_connections(5)
31            .connect(db_url)
32            .await
33            .map_err(|e| ZakatError::NetworkError(format!("SQLite connection error: {}", e)))?;
34
35        let store = Self { pool };
36        store.run_migrations().await?;
37        Ok(store)
38    }
39
40    /// Creates a new `SqliteStore` from an existing pool.
41    ///
42    /// Note: This does NOT run migrations. Use `new()` for automatic schema setup.
43    pub fn from_pool(pool: SqlitePool) -> Self {
44        Self { pool }
45    }
46
47    /// Runs database migrations to ensure the schema exists.
48    async fn run_migrations(&self) -> Result<(), ZakatError> {
49        sqlx::query(
50            r#"
51            CREATE TABLE IF NOT EXISTS ledger_events (
52                id TEXT PRIMARY KEY NOT NULL,
53                date TEXT NOT NULL,
54                amount TEXT NOT NULL,
55                asset_type TEXT NOT NULL,
56                transaction_type TEXT NOT NULL,
57                description TEXT
58            )
59            "#,
60        )
61        .execute(&self.pool)
62        .await
63        .map_err(|e| ZakatError::NetworkError(format!("SQLite migration error: {}", e)))?;
64
65        Ok(())
66    }
67
68    /// Returns a reference to the underlying connection pool.
69    pub fn pool(&self) -> &SqlitePool {
70        &self.pool
71    }
72}
73
74#[async_trait]
75impl LedgerStore for SqliteStore {
76    async fn save_event(&self, event: &LedgerEvent) -> Result<(), ZakatError> {
77        let id = event.id.to_string();
78        let date = event.date.format("%Y-%m-%d").to_string();
79        let amount = event.amount.to_string();
80        
81        // Serialize enums to JSON for safe storage
82        let asset_type = serde_json::to_string(&event.asset_type)
83            .map_err(|e| make_serialize_error("asset_type", &e.to_string()))?;
84        let transaction_type = serde_json::to_string(&event.transaction_type)
85            .map_err(|e| make_serialize_error("transaction_type", &e.to_string()))?;
86
87        sqlx::query(
88            r#"
89            INSERT INTO ledger_events (id, date, amount, asset_type, transaction_type, description)
90            VALUES (?, ?, ?, ?, ?, ?)
91            "#,
92        )
93        .bind(&id)
94        .bind(&date)
95        .bind(&amount)
96        .bind(&asset_type)
97        .bind(&transaction_type)
98        .bind(&event.description)
99        .execute(&self.pool)
100        .await
101        .map_err(|e| ZakatError::NetworkError(format!("SQLite insert error: {}", e)))?;
102
103        Ok(())
104    }
105
106    async fn load_events(&self) -> Result<Vec<LedgerEvent>, ZakatError> {
107        let rows = sqlx::query(
108            r#"
109            SELECT id, date, amount, asset_type, transaction_type, description
110            FROM ledger_events
111            ORDER BY date ASC
112            "#,
113        )
114        .fetch_all(&self.pool)
115        .await
116        .map_err(|e| ZakatError::NetworkError(format!("SQLite query error: {}", e)))?;
117
118        let mut events = Vec::with_capacity(rows.len());
119
120        for row in rows {
121            let id_str: String = row.get("id");
122            let date_str: String = row.get("date");
123            let amount_str: String = row.get("amount");
124            let asset_type_str: String = row.get("asset_type");
125            let transaction_type_str: String = row.get("transaction_type");
126            let description: Option<String> = row.get("description");
127
128            let id = uuid::Uuid::parse_str(&id_str)
129                .map_err(|e| make_parse_error("id", &id_str, &e.to_string()))?;
130
131            let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
132                .map_err(|e| make_parse_error("date", &date_str, &e.to_string()))?;
133
134            let amount = rust_decimal::Decimal::from_str_exact(&amount_str)
135                .map_err(|e| make_parse_error("amount", &amount_str, &e.to_string()))?;
136
137            let asset_type: WealthType = serde_json::from_str(&asset_type_str)
138                .map_err(|e| make_parse_error("asset_type", &asset_type_str, &e.to_string()))?;
139
140            let transaction_type: TransactionType = serde_json::from_str(&transaction_type_str)
141                .map_err(|e| make_parse_error("transaction_type", &transaction_type_str, &e.to_string()))?;
142
143            events.push(LedgerEvent {
144                id,
145                date,
146                amount,
147                asset_type,
148                transaction_type,
149                description,
150            });
151        }
152
153        Ok(events)
154    }
155}
156
157fn make_serialize_error(field: &str, error: &str) -> ZakatError {
158    ZakatError::InvalidInput(Box::new(InvalidInputDetails {
159        field: field.to_string(),
160        value: "serialize".to_string(),
161        reason_key: "error-serialize".to_string(),
162        args: Some(std::collections::HashMap::from([
163            ("error".to_string(), error.to_string()),
164        ])),
165        source_label: Some("SqliteStore".to_string()),
166        asset_id: None,
167    }))
168}
169
170fn make_parse_error(field: &str, value: &str, error: &str) -> ZakatError {
171    ZakatError::InvalidInput(Box::new(InvalidInputDetails {
172        field: field.to_string(),
173        value: value.to_string(),
174        reason_key: "error-parse".to_string(),
175        args: Some(std::collections::HashMap::from([
176            ("error".to_string(), error.to_string()),
177        ])),
178        source_label: Some("SqliteStore".to_string()),
179        asset_id: None,
180    }))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use chrono::NaiveDate;
187    use rust_decimal_macros::dec;
188
189    #[tokio::test]
190    async fn test_sqlite_store_roundtrip() {
191        let store = SqliteStore::new("sqlite::memory:")
192            .await
193            .expect("Failed to connect to in-memory SQLite");
194
195        let event = LedgerEvent::new(
196            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
197            dec!(5000.50),
198            WealthType::Business,
199            TransactionType::Deposit,
200            Some("Initial deposit".to_string()),
201        );
202
203        store.save_event(&event).await.expect("Failed to save event");
204
205        let loaded = store.load_events().await.expect("Failed to load events");
206        assert_eq!(loaded.len(), 1);
207        assert_eq!(loaded[0].id, event.id);
208        assert_eq!(loaded[0].date, event.date);
209        assert_eq!(loaded[0].amount, event.amount);
210        assert_eq!(loaded[0].asset_type, event.asset_type);
211        assert_eq!(loaded[0].transaction_type, event.transaction_type);
212        assert_eq!(loaded[0].description, event.description);
213    }
214
215    #[tokio::test]
216    async fn test_sqlite_store_ordered_by_date() {
217        let store = SqliteStore::new("sqlite::memory:")
218            .await
219            .expect("Failed to connect");
220
221        let event1 = LedgerEvent::new(
222            NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
223            dec!(1000),
224            WealthType::Gold,
225            TransactionType::Deposit,
226            None,
227        );
228
229        let event2 = LedgerEvent::new(
230            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
231            dec!(2000),
232            WealthType::Silver,
233            TransactionType::Deposit,
234            None,
235        );
236
237        store.save_event(&event1).await.unwrap();
238        store.save_event(&event2).await.unwrap();
239
240        let loaded = store.load_events().await.unwrap();
241        assert_eq!(loaded.len(), 2);
242        assert_eq!(loaded[0].date, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
243        assert_eq!(loaded[1].date, NaiveDate::from_ymd_opt(2024, 3, 1).unwrap());
244    }
245
246    #[tokio::test]
247    async fn test_sqlite_store_wealth_type_other() {
248        let store = SqliteStore::new("sqlite::memory:")
249            .await
250            .expect("Failed to connect");
251
252        let event = LedgerEvent::new(
253            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
254            dec!(3000),
255            WealthType::Other("Cryptocurrency".to_string()),
256            TransactionType::Income,
257            Some("Bitcoin sale".to_string()),
258        );
259
260        store.save_event(&event).await.unwrap();
261        let loaded = store.load_events().await.unwrap();
262
263        assert_eq!(loaded.len(), 1);
264        assert_eq!(
265            loaded[0].asset_type,
266            WealthType::Other("Cryptocurrency".to_string())
267        );
268    }
269}