1use 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
13pub struct SqliteStore {
17 pool: SqlitePool,
18}
19
20impl SqliteStore {
21 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.migrate().await?;
37 Ok(store)
38 }
39
40 pub fn from_pool(pool: SqlitePool) -> Self {
44 Self { pool }
45 }
46
47 pub async fn migrate(&self) -> Result<(), ZakatError> {
49 sqlx::query("CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY)")
51 .execute(&self.pool)
52 .await
53 .map_err(|e| ZakatError::NetworkError(format!("Migration init error: {}", e)))?;
54
55 let current_version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM _migrations")
57 .fetch_optional(&self.pool)
58 .await
59 .map_err(|e| ZakatError::NetworkError(format!("Migration version check error: {}", e)))?;
60
61 let version = current_version.unwrap_or(0);
62
63 if version < 1 {
65 sqlx::query(
66 r#"
67 CREATE TABLE IF NOT EXISTS ledger_events (
68 id TEXT PRIMARY KEY NOT NULL,
69 date TEXT NOT NULL,
70 amount TEXT NOT NULL,
71 asset_type TEXT NOT NULL,
72 transaction_type TEXT NOT NULL,
73 description TEXT
74 )
75 "#,
76 )
77 .execute(&self.pool)
78 .await
79 .map_err(|e| ZakatError::NetworkError(format!("Migration v1 error: {}", e)))?;
80
81 sqlx::query("INSERT INTO _migrations (version) VALUES (1)")
82 .execute(&self.pool)
83 .await
84 .map_err(|e| ZakatError::NetworkError(format!("Migration v1 version update error: {}", e)))?;
85 }
86
87 Ok(())
88 }
89
90 pub fn pool(&self) -> &SqlitePool {
92 &self.pool
93 }
94}
95
96#[async_trait]
97impl LedgerStore for SqliteStore {
98 async fn save_event(&self, event: &LedgerEvent) -> Result<(), ZakatError> {
99 let id = event.id.to_string();
100 let date = event.date.format("%Y-%m-%d").to_string();
101 let amount = event.amount.to_string();
102
103 let asset_type = serde_json::to_string(&event.asset_type)
105 .map_err(|e| make_serialize_error("asset_type", &e.to_string()))?;
106 let transaction_type = serde_json::to_string(&event.transaction_type)
107 .map_err(|e| make_serialize_error("transaction_type", &e.to_string()))?;
108
109 sqlx::query(
110 r#"
111 INSERT INTO ledger_events (id, date, amount, asset_type, transaction_type, description)
112 VALUES (?, ?, ?, ?, ?, ?)
113 "#,
114 )
115 .bind(&id)
116 .bind(&date)
117 .bind(&amount)
118 .bind(&asset_type)
119 .bind(&transaction_type)
120 .bind(&event.description)
121 .execute(&self.pool)
122 .await
123 .map_err(|e| ZakatError::NetworkError(format!("SQLite insert error: {}", e)))?;
124
125 Ok(())
126 }
127
128 async fn load_events(&self) -> Result<Vec<LedgerEvent>, ZakatError> {
129 let rows = sqlx::query(
130 r#"
131 SELECT id, date, amount, asset_type, transaction_type, description
132 FROM ledger_events
133 ORDER BY date ASC
134 "#,
135 )
136 .fetch_all(&self.pool)
137 .await
138 .map_err(|e| ZakatError::NetworkError(format!("SQLite query error: {}", e)))?;
139
140 let mut events = Vec::with_capacity(rows.len());
141
142 for row in rows {
143 let id_str: String = row.get("id");
144 let date_str: String = row.get("date");
145 let amount_str: String = row.get("amount");
146 let asset_type_str: String = row.get("asset_type");
147 let transaction_type_str: String = row.get("transaction_type");
148 let description: Option<String> = row.get("description");
149
150 let id = uuid::Uuid::parse_str(&id_str)
151 .map_err(|e| make_parse_error("id", &id_str, &e.to_string()))?;
152
153 let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
154 .map_err(|e| make_parse_error("date", &date_str, &e.to_string()))?;
155
156 let amount = rust_decimal::Decimal::from_str_exact(&amount_str)
157 .map_err(|e| make_parse_error("amount", &amount_str, &e.to_string()))?;
158
159 let asset_type: WealthType = serde_json::from_str(&asset_type_str)
160 .map_err(|e| make_parse_error("asset_type", &asset_type_str, &e.to_string()))?;
161
162 let transaction_type: TransactionType = serde_json::from_str(&transaction_type_str)
163 .map_err(|e| make_parse_error("transaction_type", &transaction_type_str, &e.to_string()))?;
164
165 events.push(LedgerEvent {
166 id,
167 date,
168 amount,
169 asset_type,
170 transaction_type,
171 description,
172 });
173 }
174
175 Ok(events)
176 }
177}
178
179fn make_serialize_error(field: &str, error: &str) -> ZakatError {
180 ZakatError::InvalidInput(Box::new(InvalidInputDetails {
181 code: zakat_core::types::ZakatErrorCode::InvalidInput,
182 field: field.to_string(),
183 value: "serialize".to_string(),
184 reason_key: "error-serialize".to_string(),
185 args: Some(std::collections::HashMap::from([
186 ("error".to_string(), error.to_string()),
187 ])),
188 source_label: Some("SqliteStore".to_string()),
189 ..Default::default()
190 }))
191}
192
193fn make_parse_error(field: &str, value: &str, error: &str) -> ZakatError {
194 ZakatError::InvalidInput(Box::new(InvalidInputDetails {
195 code: zakat_core::types::ZakatErrorCode::InvalidInput,
196 field: field.to_string(),
197 value: value.to_string(),
198 reason_key: "error-parse".to_string(),
199 args: Some(std::collections::HashMap::from([
200 ("error".to_string(), error.to_string()),
201 ])),
202 source_label: Some("SqliteStore".to_string()),
203 ..Default::default()
204 }))
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use chrono::NaiveDate;
211 use rust_decimal_macros::dec;
212
213 #[tokio::test]
214 async fn test_sqlite_store_roundtrip() {
215 let store = SqliteStore::new("sqlite::memory:")
216 .await
217 .expect("Failed to connect to in-memory SQLite");
218
219 let event = LedgerEvent::new(
220 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
221 dec!(5000.50),
222 WealthType::Business,
223 TransactionType::Deposit,
224 Some("Initial deposit".to_string()),
225 );
226
227 store.save_event(&event).await.expect("Failed to save event");
228
229 let loaded = store.load_events().await.expect("Failed to load events");
230 assert_eq!(loaded.len(), 1);
231 assert_eq!(loaded[0].id, event.id);
232 assert_eq!(loaded[0].date, event.date);
233 assert_eq!(loaded[0].amount, event.amount);
234 assert_eq!(loaded[0].asset_type, event.asset_type);
235 assert_eq!(loaded[0].transaction_type, event.transaction_type);
236 assert_eq!(loaded[0].description, event.description);
237 }
238
239 #[tokio::test]
240 async fn test_sqlite_store_ordered_by_date() {
241 let store = SqliteStore::new("sqlite::memory:")
242 .await
243 .expect("Failed to connect");
244
245 let event1 = LedgerEvent::new(
246 NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
247 dec!(1000),
248 WealthType::Gold,
249 TransactionType::Deposit,
250 None,
251 );
252
253 let event2 = LedgerEvent::new(
254 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
255 dec!(2000),
256 WealthType::Silver,
257 TransactionType::Deposit,
258 None,
259 );
260
261 store.save_event(&event1).await.unwrap();
262 store.save_event(&event2).await.unwrap();
263
264 let loaded = store.load_events().await.unwrap();
265 assert_eq!(loaded.len(), 2);
266 assert_eq!(loaded[0].date, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
267 assert_eq!(loaded[1].date, NaiveDate::from_ymd_opt(2024, 3, 1).unwrap());
268 }
269
270 #[tokio::test]
271 async fn test_sqlite_store_wealth_type_other() {
272 let store = SqliteStore::new("sqlite::memory:")
273 .await
274 .expect("Failed to connect");
275
276 let event = LedgerEvent::new(
277 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
278 dec!(3000),
279 WealthType::Other("Cryptocurrency".to_string()),
280 TransactionType::Income,
281 Some("Bitcoin sale".to_string()),
282 );
283
284 store.save_event(&event).await.unwrap();
285 let loaded = store.load_events().await.unwrap();
286
287 assert_eq!(loaded.len(), 1);
288 assert_eq!(
289 loaded[0].asset_type,
290 WealthType::Other("Cryptocurrency".to_string())
291 );
292 }
293}