1use 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
18struct TransactionData {
19 transactions: Vec<Transaction>,
20}
21
22pub struct TransactionRepository {
24 path: PathBuf,
25 data: RwLock<HashMap<TransactionId, Transaction>>,
26 by_account: RwLock<HashMap<AccountId, Vec<TransactionId>>>,
28 by_category: RwLock<HashMap<CategoryId, Vec<TransactionId>>>,
30}
31
32impl TransactionRepository {
33 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 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 by_account.entry(account_id).or_default().push(id);
70
71 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}