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.run_migrations().await?;
37 Ok(store)
38 }
39
40 pub fn from_pool(pool: SqlitePool) -> Self {
44 Self { pool }
45 }
46
47 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 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 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}