envelope_cli/storage/
transactions.rs

1//! Transaction repository for JSON storage
2//!
3//! Manages loading and saving transactions to transactions.json
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::RwLock;
8
9use chrono::NaiveDate;
10
11use crate::error::EnvelopeError;
12use crate::models::{AccountId, CategoryId, Transaction, TransactionId};
13
14use super::file_io::{read_json, write_json_atomic};
15
16/// Serializable transaction data structure
17#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
18struct TransactionData {
19    transactions: Vec<Transaction>,
20}
21
22/// Repository for transaction persistence with indexing
23pub struct TransactionRepository {
24    path: PathBuf,
25    data: RwLock<HashMap<TransactionId, Transaction>>,
26    /// Index: account_id -> transaction_ids
27    by_account: RwLock<HashMap<AccountId, Vec<TransactionId>>>,
28    /// Index: category_id -> transaction_ids
29    by_category: RwLock<HashMap<CategoryId, Vec<TransactionId>>>,
30}
31
32impl TransactionRepository {
33    /// Create a new transaction repository
34    pub fn new(path: PathBuf) -> Self {
35        Self {
36            path,
37            data: RwLock::new(HashMap::new()),
38            by_account: RwLock::new(HashMap::new()),
39            by_category: RwLock::new(HashMap::new()),
40        }
41    }
42
43    /// Load transactions from disk and build indexes
44    pub fn load(&self) -> Result<(), EnvelopeError> {
45        let file_data: TransactionData = read_json(&self.path)?;
46
47        let mut data = self
48            .data
49            .write()
50            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
51        let mut by_account = self
52            .by_account
53            .write()
54            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
55        let mut by_category = self
56            .by_category
57            .write()
58            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
59
60        data.clear();
61        by_account.clear();
62        by_category.clear();
63
64        for txn in file_data.transactions {
65            let id = txn.id;
66            let account_id = txn.account_id;
67
68            // Index by account
69            by_account.entry(account_id).or_default().push(id);
70
71            // Index by category
72            if let Some(cat_id) = txn.category_id {
73                by_category.entry(cat_id).or_default().push(id);
74            }
75            for split in &txn.splits {
76                by_category.entry(split.category_id).or_default().push(id);
77            }
78
79            data.insert(id, txn);
80        }
81
82        Ok(())
83    }
84
85    /// Save transactions to disk
86    pub fn save(&self) -> Result<(), EnvelopeError> {
87        let data = self
88            .data
89            .read()
90            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
91
92        let mut transactions: Vec<_> = data.values().cloned().collect();
93        transactions.sort_by(|a, b| b.date.cmp(&a.date).then(b.created_at.cmp(&a.created_at)));
94
95        let file_data = TransactionData { transactions };
96        write_json_atomic(&self.path, &file_data)
97    }
98
99    /// Get a transaction by ID
100    pub fn get(&self, id: TransactionId) -> Result<Option<Transaction>, EnvelopeError> {
101        let data = self
102            .data
103            .read()
104            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
105
106        Ok(data.get(&id).cloned())
107    }
108
109    /// Get all transactions
110    pub fn get_all(&self) -> Result<Vec<Transaction>, EnvelopeError> {
111        let data = self
112            .data
113            .read()
114            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
115
116        let mut transactions: Vec<_> = data.values().cloned().collect();
117        transactions.sort_by(|a, b| b.date.cmp(&a.date));
118        Ok(transactions)
119    }
120
121    /// Get transactions for an account
122    pub fn get_by_account(&self, account_id: AccountId) -> Result<Vec<Transaction>, EnvelopeError> {
123        let data = self
124            .data
125            .read()
126            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
127        let by_account = self
128            .by_account
129            .read()
130            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
131
132        let ids = by_account
133            .get(&account_id)
134            .map(|v| v.as_slice())
135            .unwrap_or(&[]);
136        let mut transactions: Vec<_> = ids.iter().filter_map(|id| data.get(id).cloned()).collect();
137        transactions.sort_by(|a, b| b.date.cmp(&a.date));
138        Ok(transactions)
139    }
140
141    /// Get transactions for a category
142    pub fn get_by_category(
143        &self,
144        category_id: CategoryId,
145    ) -> Result<Vec<Transaction>, EnvelopeError> {
146        let data = self
147            .data
148            .read()
149            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
150        let by_category = self
151            .by_category
152            .read()
153            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
154
155        let ids = by_category
156            .get(&category_id)
157            .map(|v| v.as_slice())
158            .unwrap_or(&[]);
159        let mut transactions: Vec<_> = ids.iter().filter_map(|id| data.get(id).cloned()).collect();
160        transactions.sort_by(|a, b| b.date.cmp(&a.date));
161        Ok(transactions)
162    }
163
164    /// Get transactions in a date range
165    pub fn get_by_date_range(
166        &self,
167        start: NaiveDate,
168        end: NaiveDate,
169    ) -> Result<Vec<Transaction>, EnvelopeError> {
170        let all = self.get_all()?;
171        Ok(all
172            .into_iter()
173            .filter(|t| t.date >= start && t.date <= end)
174            .collect())
175    }
176
177    /// Insert or update a transaction
178    pub fn upsert(&self, txn: Transaction) -> Result<(), EnvelopeError> {
179        let mut data = self
180            .data
181            .write()
182            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
183        let mut by_account = self
184            .by_account
185            .write()
186            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
187        let mut by_category = self
188            .by_category
189            .write()
190            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
191
192        // Remove from old indexes if updating
193        if let Some(old) = data.get(&txn.id) {
194            if let Some(ids) = by_account.get_mut(&old.account_id) {
195                ids.retain(|&id| id != txn.id);
196            }
197            if let Some(cat_id) = old.category_id {
198                if let Some(ids) = by_category.get_mut(&cat_id) {
199                    ids.retain(|&id| id != txn.id);
200                }
201            }
202            for split in &old.splits {
203                if let Some(ids) = by_category.get_mut(&split.category_id) {
204                    ids.retain(|&id| id != txn.id);
205                }
206            }
207        }
208
209        // Add to new indexes
210        by_account.entry(txn.account_id).or_default().push(txn.id);
211        if let Some(cat_id) = txn.category_id {
212            by_category.entry(cat_id).or_default().push(txn.id);
213        }
214        for split in &txn.splits {
215            by_category
216                .entry(split.category_id)
217                .or_default()
218                .push(txn.id);
219        }
220
221        data.insert(txn.id, txn);
222        Ok(())
223    }
224
225    /// Delete a transaction
226    pub fn delete(&self, id: TransactionId) -> Result<bool, EnvelopeError> {
227        let mut data = self
228            .data
229            .write()
230            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
231        let mut by_account = self
232            .by_account
233            .write()
234            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
235        let mut by_category = self
236            .by_category
237            .write()
238            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
239
240        if let Some(txn) = data.remove(&id) {
241            // Remove from indexes
242            if let Some(ids) = by_account.get_mut(&txn.account_id) {
243                ids.retain(|&tid| tid != id);
244            }
245            if let Some(cat_id) = txn.category_id {
246                if let Some(ids) = by_category.get_mut(&cat_id) {
247                    ids.retain(|&tid| tid != id);
248                }
249            }
250            for split in &txn.splits {
251                if let Some(ids) = by_category.get_mut(&split.category_id) {
252                    ids.retain(|&tid| tid != id);
253                }
254            }
255            Ok(true)
256        } else {
257            Ok(false)
258        }
259    }
260
261    /// Find transaction by import ID
262    pub fn find_by_import_id(&self, import_id: &str) -> Result<Option<Transaction>, EnvelopeError> {
263        let data = self
264            .data
265            .read()
266            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
267
268        Ok(data
269            .values()
270            .find(|t| t.import_id.as_deref() == Some(import_id))
271            .cloned())
272    }
273
274    /// Count transactions
275    pub fn count(&self) -> Result<usize, EnvelopeError> {
276        let data = self
277            .data
278            .read()
279            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
280
281        Ok(data.len())
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::models::Money;
289    use tempfile::TempDir;
290
291    fn create_test_repo() -> (TempDir, TransactionRepository) {
292        let temp_dir = TempDir::new().unwrap();
293        let path = temp_dir.path().join("transactions.json");
294        let repo = TransactionRepository::new(path);
295        (temp_dir, repo)
296    }
297
298    #[test]
299    fn test_empty_load() {
300        let (_temp_dir, repo) = create_test_repo();
301        repo.load().unwrap();
302        assert_eq!(repo.count().unwrap(), 0);
303    }
304
305    #[test]
306    fn test_upsert_and_get() {
307        let (_temp_dir, repo) = create_test_repo();
308        repo.load().unwrap();
309
310        let account_id = AccountId::new();
311        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
312        let txn = Transaction::new(account_id, date, Money::from_cents(-5000));
313        let id = txn.id;
314
315        repo.upsert(txn).unwrap();
316
317        let retrieved = repo.get(id).unwrap().unwrap();
318        assert_eq!(retrieved.amount.cents(), -5000);
319    }
320
321    #[test]
322    fn test_get_by_account() {
323        let (_temp_dir, repo) = create_test_repo();
324        repo.load().unwrap();
325
326        let account1 = AccountId::new();
327        let account2 = AccountId::new();
328        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
329
330        repo.upsert(Transaction::new(account1, date, Money::from_cents(-100)))
331            .unwrap();
332        repo.upsert(Transaction::new(account1, date, Money::from_cents(-200)))
333            .unwrap();
334        repo.upsert(Transaction::new(account2, date, Money::from_cents(-300)))
335            .unwrap();
336
337        let account1_txns = repo.get_by_account(account1).unwrap();
338        assert_eq!(account1_txns.len(), 2);
339
340        let account2_txns = repo.get_by_account(account2).unwrap();
341        assert_eq!(account2_txns.len(), 1);
342    }
343
344    #[test]
345    fn test_save_and_reload() {
346        let (temp_dir, repo) = create_test_repo();
347        repo.load().unwrap();
348
349        let account_id = AccountId::new();
350        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
351        let txn = Transaction::new(account_id, date, Money::from_cents(-5000));
352        let id = txn.id;
353
354        repo.upsert(txn).unwrap();
355        repo.save().unwrap();
356
357        // Create new repo and load
358        let path = temp_dir.path().join("transactions.json");
359        let repo2 = TransactionRepository::new(path);
360        repo2.load().unwrap();
361
362        assert_eq!(repo2.count().unwrap(), 1);
363        let retrieved = repo2.get(id).unwrap().unwrap();
364        assert_eq!(retrieved.amount.cents(), -5000);
365    }
366
367    #[test]
368    fn test_delete() {
369        let (_temp_dir, repo) = create_test_repo();
370        repo.load().unwrap();
371
372        let account_id = AccountId::new();
373        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
374        let txn = Transaction::new(account_id, date, Money::from_cents(-5000));
375        let id = txn.id;
376
377        repo.upsert(txn).unwrap();
378        assert_eq!(repo.count().unwrap(), 1);
379
380        repo.delete(id).unwrap();
381        assert_eq!(repo.count().unwrap(), 0);
382    }
383
384    #[test]
385    fn test_date_range_query() {
386        let (_temp_dir, repo) = create_test_repo();
387        repo.load().unwrap();
388
389        let account_id = AccountId::new();
390        repo.upsert(Transaction::new(
391            account_id,
392            NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
393            Money::from_cents(-100),
394        ))
395        .unwrap();
396        repo.upsert(Transaction::new(
397            account_id,
398            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
399            Money::from_cents(-200),
400        ))
401        .unwrap();
402        repo.upsert(Transaction::new(
403            account_id,
404            NaiveDate::from_ymd_opt(2025, 1, 20).unwrap(),
405            Money::from_cents(-300),
406        ))
407        .unwrap();
408
409        let range = repo
410            .get_by_date_range(
411                NaiveDate::from_ymd_opt(2025, 1, 12).unwrap(),
412                NaiveDate::from_ymd_opt(2025, 1, 18).unwrap(),
413            )
414            .unwrap();
415
416        assert_eq!(range.len(), 1);
417        assert_eq!(range[0].amount.cents(), -200);
418    }
419}