envelope_cli/services/
transaction.rs

1//! Transaction service
2//!
3//! Provides business logic for transaction management including CRUD operations,
4//! status management, and integration with budget calculations.
5
6use chrono::{NaiveDate, Utc};
7
8use crate::audit::EntityType;
9use crate::error::{EnvelopeError, EnvelopeResult};
10use crate::models::{
11    AccountId, CategoryId, Money, Split, Transaction, TransactionId, TransactionStatus,
12};
13use crate::storage::Storage;
14
15/// Service for transaction management
16pub struct TransactionService<'a> {
17    storage: &'a Storage,
18}
19
20/// Options for filtering transactions
21#[derive(Debug, Clone, Default)]
22pub struct TransactionFilter {
23    /// Filter by account
24    pub account_id: Option<AccountId>,
25    /// Filter by category
26    pub category_id: Option<CategoryId>,
27    /// Filter by date range start
28    pub start_date: Option<NaiveDate>,
29    /// Filter by date range end
30    pub end_date: Option<NaiveDate>,
31    /// Filter by status
32    pub status: Option<TransactionStatus>,
33    /// Maximum number of transactions to return
34    pub limit: Option<usize>,
35}
36
37impl TransactionFilter {
38    /// Create a new empty filter
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Filter by account
44    pub fn account(mut self, account_id: AccountId) -> Self {
45        self.account_id = Some(account_id);
46        self
47    }
48
49    /// Filter by category
50    pub fn category(mut self, category_id: CategoryId) -> Self {
51        self.category_id = Some(category_id);
52        self
53    }
54
55    /// Filter by date range
56    pub fn date_range(mut self, start: NaiveDate, end: NaiveDate) -> Self {
57        self.start_date = Some(start);
58        self.end_date = Some(end);
59        self
60    }
61
62    /// Filter by status
63    pub fn status(mut self, status: TransactionStatus) -> Self {
64        self.status = Some(status);
65        self
66    }
67
68    /// Limit results
69    pub fn limit(mut self, limit: usize) -> Self {
70        self.limit = Some(limit);
71        self
72    }
73}
74
75/// Input for creating a new transaction
76#[derive(Debug, Clone)]
77pub struct CreateTransactionInput {
78    pub account_id: AccountId,
79    pub date: NaiveDate,
80    pub amount: Money,
81    pub payee_name: Option<String>,
82    pub category_id: Option<CategoryId>,
83    pub memo: Option<String>,
84    pub status: Option<TransactionStatus>,
85}
86
87impl<'a> TransactionService<'a> {
88    /// Create a new transaction service
89    pub fn new(storage: &'a Storage) -> Self {
90        Self { storage }
91    }
92
93    /// Create a new transaction
94    pub fn create(&self, input: CreateTransactionInput) -> EnvelopeResult<Transaction> {
95        // Verify account exists
96        let account = self
97            .storage
98            .accounts
99            .get(input.account_id)?
100            .ok_or_else(|| EnvelopeError::account_not_found(input.account_id.to_string()))?;
101
102        if account.archived {
103            return Err(EnvelopeError::Validation(
104                "Cannot add transactions to an archived account".into(),
105            ));
106        }
107
108        // Verify category exists if provided
109        if let Some(cat_id) = input.category_id {
110            self.storage
111                .categories
112                .get_category(cat_id)?
113                .ok_or_else(|| EnvelopeError::category_not_found(cat_id.to_string()))?;
114        }
115
116        // Create the transaction
117        let mut txn = Transaction::new(input.account_id, input.date, input.amount);
118
119        if let Some(payee_name) = input.payee_name {
120            txn.payee_name = payee_name.trim().to_string();
121
122            // Try to find or create payee
123            if !txn.payee_name.is_empty() {
124                let payee = self.storage.payees.get_or_create(&txn.payee_name)?;
125                txn.payee_id = Some(payee.id);
126            }
127        }
128
129        txn.category_id = input.category_id;
130
131        if let Some(memo) = input.memo {
132            txn.memo = memo;
133        }
134
135        if let Some(status) = input.status {
136            txn.status = status;
137        }
138
139        // Validate
140        txn.validate()
141            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
142
143        // Save transaction
144        self.storage.transactions.upsert(txn.clone())?;
145        self.storage.transactions.save()?;
146
147        // Save payees if modified
148        self.storage.payees.save()?;
149
150        // Audit log
151        self.storage.log_create(
152            EntityType::Transaction,
153            txn.id.to_string(),
154            Some(format!("{} {}", txn.date, txn.payee_name)),
155            &txn,
156        )?;
157
158        Ok(txn)
159    }
160
161    /// Get a transaction by ID
162    pub fn get(&self, id: TransactionId) -> EnvelopeResult<Option<Transaction>> {
163        self.storage.transactions.get(id)
164    }
165
166    /// Find a transaction by ID string
167    pub fn find(&self, identifier: &str) -> EnvelopeResult<Option<Transaction>> {
168        if let Ok(id) = identifier.parse::<TransactionId>() {
169            return self.storage.transactions.get(id);
170        }
171        Ok(None)
172    }
173
174    /// List all transactions with optional filtering
175    pub fn list(&self, filter: TransactionFilter) -> EnvelopeResult<Vec<Transaction>> {
176        let mut transactions = if let Some(account_id) = filter.account_id {
177            self.storage.transactions.get_by_account(account_id)?
178        } else if let Some(category_id) = filter.category_id {
179            self.storage.transactions.get_by_category(category_id)?
180        } else if let (Some(start), Some(end)) = (filter.start_date, filter.end_date) {
181            self.storage.transactions.get_by_date_range(start, end)?
182        } else {
183            self.storage.transactions.get_all()?
184        };
185
186        // Apply additional filters
187        if let Some(start) = filter.start_date {
188            transactions.retain(|t| t.date >= start);
189        }
190        if let Some(end) = filter.end_date {
191            transactions.retain(|t| t.date <= end);
192        }
193        if let Some(status) = filter.status {
194            transactions.retain(|t| t.status == status);
195        }
196
197        // Apply limit
198        if let Some(limit) = filter.limit {
199            transactions.truncate(limit);
200        }
201
202        Ok(transactions)
203    }
204
205    /// Get transactions for an account
206    pub fn list_for_account(&self, account_id: AccountId) -> EnvelopeResult<Vec<Transaction>> {
207        self.storage.transactions.get_by_account(account_id)
208    }
209
210    /// Get transactions for a category
211    pub fn list_for_category(&self, category_id: CategoryId) -> EnvelopeResult<Vec<Transaction>> {
212        self.storage.transactions.get_by_category(category_id)
213    }
214
215    /// Update a transaction
216    pub fn update(
217        &self,
218        id: TransactionId,
219        date: Option<NaiveDate>,
220        amount: Option<Money>,
221        payee_name: Option<String>,
222        category_id: Option<Option<CategoryId>>,
223        memo: Option<String>,
224    ) -> EnvelopeResult<Transaction> {
225        let mut txn = self
226            .storage
227            .transactions
228            .get(id)?
229            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
230
231        // Check if locked
232        if txn.is_locked() {
233            return Err(EnvelopeError::Locked(format!(
234                "Transaction {} is reconciled and cannot be edited. Unlock it first.",
235                id
236            )));
237        }
238
239        let before = txn.clone();
240
241        // Apply updates
242        if let Some(new_date) = date {
243            txn.date = new_date;
244        }
245
246        if let Some(new_amount) = amount {
247            txn.amount = new_amount;
248        }
249
250        if let Some(new_payee_name) = payee_name {
251            txn.payee_name = new_payee_name.trim().to_string();
252            if !txn.payee_name.is_empty() {
253                let payee = self.storage.payees.get_or_create(&txn.payee_name)?;
254                txn.payee_id = Some(payee.id);
255            } else {
256                txn.payee_id = None;
257            }
258        }
259
260        // category_id: Option<Option<CategoryId>>
261        // - None: no change
262        // - Some(None): clear category
263        // - Some(Some(id)): set category
264        if let Some(new_cat_id) = category_id {
265            if let Some(cat_id) = new_cat_id {
266                // Verify category exists
267                self.storage
268                    .categories
269                    .get_category(cat_id)?
270                    .ok_or_else(|| EnvelopeError::category_not_found(cat_id.to_string()))?;
271            }
272            txn.category_id = new_cat_id;
273            // Clear splits if setting a category
274            if new_cat_id.is_some() {
275                txn.splits.clear();
276            }
277        }
278
279        if let Some(new_memo) = memo {
280            txn.memo = new_memo;
281        }
282
283        txn.updated_at = Utc::now();
284
285        // Validate
286        txn.validate()
287            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
288
289        // Save
290        self.storage.transactions.upsert(txn.clone())?;
291        self.storage.transactions.save()?;
292        self.storage.payees.save()?;
293
294        // Build diff summary
295        let mut changes = Vec::new();
296        if before.date != txn.date {
297            changes.push(format!("date: {} -> {}", before.date, txn.date));
298        }
299        if before.amount != txn.amount {
300            changes.push(format!("amount: {} -> {}", before.amount, txn.amount));
301        }
302        if before.payee_name != txn.payee_name {
303            changes.push(format!(
304                "payee: '{}' -> '{}'",
305                before.payee_name, txn.payee_name
306            ));
307        }
308        if before.category_id != txn.category_id {
309            changes.push(format!(
310                "category: {:?} -> {:?}",
311                before.category_id, txn.category_id
312            ));
313        }
314        if before.memo != txn.memo {
315            changes.push("memo changed".to_string());
316        }
317
318        let diff = if changes.is_empty() {
319            None
320        } else {
321            Some(changes.join(", "))
322        };
323
324        // Audit log
325        self.storage.log_update(
326            EntityType::Transaction,
327            txn.id.to_string(),
328            Some(format!("{} {}", txn.date, txn.payee_name)),
329            &before,
330            &txn,
331            diff,
332        )?;
333
334        Ok(txn)
335    }
336
337    /// Delete a transaction
338    pub fn delete(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
339        let txn = self
340            .storage
341            .transactions
342            .get(id)?
343            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
344
345        // Check if locked
346        if txn.is_locked() {
347            return Err(EnvelopeError::Locked(format!(
348                "Transaction {} is reconciled and cannot be deleted. Unlock it first.",
349                id
350            )));
351        }
352
353        // If this is a transfer, we need to handle the linked transaction
354        if let Some(linked_id) = txn.transfer_transaction_id {
355            // Delete the linked transaction too
356            if let Some(linked_txn) = self.storage.transactions.get(linked_id)? {
357                if linked_txn.is_locked() {
358                    return Err(EnvelopeError::Locked(format!(
359                        "Linked transfer transaction {} is reconciled and cannot be deleted.",
360                        linked_id
361                    )));
362                }
363                self.storage.transactions.delete(linked_id)?;
364                self.storage.log_delete(
365                    EntityType::Transaction,
366                    linked_id.to_string(),
367                    Some(format!(
368                        "{} {} (linked)",
369                        linked_txn.date, linked_txn.payee_name
370                    )),
371                    &linked_txn,
372                )?;
373            }
374        }
375
376        // Delete the transaction
377        self.storage.transactions.delete(id)?;
378        self.storage.transactions.save()?;
379
380        // Audit log
381        self.storage.log_delete(
382            EntityType::Transaction,
383            id.to_string(),
384            Some(format!("{} {}", txn.date, txn.payee_name)),
385            &txn,
386        )?;
387
388        Ok(txn)
389    }
390
391    /// Set the status of a transaction
392    pub fn set_status(
393        &self,
394        id: TransactionId,
395        status: TransactionStatus,
396    ) -> EnvelopeResult<Transaction> {
397        let mut txn = self
398            .storage
399            .transactions
400            .get(id)?
401            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
402
403        // Can't change status of reconciled transaction without unlocking first
404        if txn.is_locked() && status != TransactionStatus::Reconciled {
405            return Err(EnvelopeError::Locked(format!(
406                "Transaction {} is reconciled. Unlock it before changing status.",
407                id
408            )));
409        }
410
411        let before = txn.clone();
412        txn.set_status(status);
413
414        // Save
415        self.storage.transactions.upsert(txn.clone())?;
416        self.storage.transactions.save()?;
417
418        // Audit log
419        self.storage.log_update(
420            EntityType::Transaction,
421            txn.id.to_string(),
422            Some(format!("{} {}", txn.date, txn.payee_name)),
423            &before,
424            &txn,
425            Some(format!("status: {} -> {}", before.status, txn.status)),
426        )?;
427
428        Ok(txn)
429    }
430
431    /// Clear a transaction (mark as cleared)
432    pub fn clear(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
433        self.set_status(id, TransactionStatus::Cleared)
434    }
435
436    /// Unclear a transaction (mark as pending)
437    pub fn unclear(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
438        self.set_status(id, TransactionStatus::Pending)
439    }
440
441    /// Unlock a reconciled transaction for editing
442    ///
443    /// This is a potentially dangerous operation - it allows editing a transaction
444    /// that has already been reconciled with a bank statement. Use with caution.
445    pub fn unlock(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
446        let mut txn = self
447            .storage
448            .transactions
449            .get(id)?
450            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
451
452        if !txn.is_locked() {
453            return Err(EnvelopeError::Validation(format!(
454                "Transaction {} is not locked",
455                id
456            )));
457        }
458
459        let before = txn.clone();
460        txn.set_status(TransactionStatus::Cleared);
461
462        // Save
463        self.storage.transactions.upsert(txn.clone())?;
464        self.storage.transactions.save()?;
465
466        // Audit log - this is important to track
467        self.storage.log_update(
468            EntityType::Transaction,
469            txn.id.to_string(),
470            Some(format!("{} {}", txn.date, txn.payee_name)),
471            &before,
472            &txn,
473            Some("UNLOCKED: reconciled -> cleared".to_string()),
474        )?;
475
476        Ok(txn)
477    }
478
479    /// Add a split to a transaction
480    ///
481    /// Note: This validates that splits total equals the transaction amount.
482    /// If you need to add multiple splits, use `set_splits` instead to avoid
483    /// intermediate validation failures.
484    pub fn add_split(
485        &self,
486        id: TransactionId,
487        category_id: CategoryId,
488        amount: Money,
489        memo: Option<String>,
490    ) -> EnvelopeResult<Transaction> {
491        let mut txn = self
492            .storage
493            .transactions
494            .get(id)?
495            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
496
497        if txn.is_locked() {
498            return Err(EnvelopeError::Locked(format!(
499                "Transaction {} is reconciled and cannot be edited.",
500                id
501            )));
502        }
503
504        // Verify category exists
505        self.storage
506            .categories
507            .get_category(category_id)?
508            .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
509
510        let before = txn.clone();
511
512        // Add the split
513        let split = if let Some(memo) = memo {
514            Split::with_memo(category_id, amount, memo)
515        } else {
516            Split::new(category_id, amount)
517        };
518        txn.add_split(split);
519
520        // Validate
521        txn.validate()
522            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
523
524        // Save
525        self.storage.transactions.upsert(txn.clone())?;
526        self.storage.transactions.save()?;
527
528        // Audit log
529        self.storage.log_update(
530            EntityType::Transaction,
531            txn.id.to_string(),
532            Some(format!("{} {}", txn.date, txn.payee_name)),
533            &before,
534            &txn,
535            Some(format!(
536                "added split: {} to category {}",
537                amount, category_id
538            )),
539        )?;
540
541        Ok(txn)
542    }
543
544    /// Set all splits for a transaction at once
545    ///
546    /// This replaces any existing splits with the new ones.
547    /// The splits must sum to the transaction amount.
548    pub fn set_splits(&self, id: TransactionId, splits: Vec<Split>) -> EnvelopeResult<Transaction> {
549        let mut txn = self
550            .storage
551            .transactions
552            .get(id)?
553            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
554
555        if txn.is_locked() {
556            return Err(EnvelopeError::Locked(format!(
557                "Transaction {} is reconciled and cannot be edited.",
558                id
559            )));
560        }
561
562        // Verify all categories exist
563        for split in &splits {
564            self.storage
565                .categories
566                .get_category(split.category_id)?
567                .ok_or_else(|| EnvelopeError::category_not_found(split.category_id.to_string()))?;
568        }
569
570        let before = txn.clone();
571
572        // Replace splits
573        txn.splits = splits;
574        txn.category_id = None; // Clear single category when using splits
575        txn.updated_at = Utc::now();
576
577        // Validate
578        txn.validate()
579            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
580
581        // Save
582        self.storage.transactions.upsert(txn.clone())?;
583        self.storage.transactions.save()?;
584
585        // Audit log
586        self.storage.log_update(
587            EntityType::Transaction,
588            txn.id.to_string(),
589            Some(format!("{} {}", txn.date, txn.payee_name)),
590            &before,
591            &txn,
592            Some(format!("set {} splits", txn.splits.len())),
593        )?;
594
595        Ok(txn)
596    }
597
598    /// Clear all splits from a transaction
599    pub fn clear_splits(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
600        let mut txn = self
601            .storage
602            .transactions
603            .get(id)?
604            .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
605
606        if txn.is_locked() {
607            return Err(EnvelopeError::Locked(format!(
608                "Transaction {} is reconciled and cannot be edited.",
609                id
610            )));
611        }
612
613        if txn.splits.is_empty() {
614            return Ok(txn);
615        }
616
617        let before = txn.clone();
618        txn.splits.clear();
619        txn.updated_at = Utc::now();
620
621        // Save
622        self.storage.transactions.upsert(txn.clone())?;
623        self.storage.transactions.save()?;
624
625        // Audit log
626        self.storage.log_update(
627            EntityType::Transaction,
628            txn.id.to_string(),
629            Some(format!("{} {}", txn.date, txn.payee_name)),
630            &before,
631            &txn,
632            Some("cleared all splits".to_string()),
633        )?;
634
635        Ok(txn)
636    }
637
638    /// Learn from a transaction - update payee's category frequency
639    pub fn learn_from_transaction(&self, txn: &Transaction) -> EnvelopeResult<()> {
640        if let (Some(payee_id), Some(category_id)) = (txn.payee_id, txn.category_id) {
641            if let Some(mut payee) = self.storage.payees.get(payee_id)? {
642                payee.record_category_usage(category_id);
643                self.storage.payees.upsert(payee)?;
644                self.storage.payees.save()?;
645            }
646        }
647        Ok(())
648    }
649
650    /// Get suggested category for a payee name
651    pub fn suggest_category(&self, payee_name: &str) -> EnvelopeResult<Option<CategoryId>> {
652        if let Some(payee) = self.storage.payees.get_by_name(payee_name)? {
653            Ok(payee.suggested_category())
654        } else {
655            Ok(None)
656        }
657    }
658
659    /// Count transactions
660    pub fn count(&self) -> EnvelopeResult<usize> {
661        self.storage.transactions.count()
662    }
663
664    /// Get uncleared transactions for an account
665    pub fn get_uncleared(&self, account_id: AccountId) -> EnvelopeResult<Vec<Transaction>> {
666        let transactions = self.storage.transactions.get_by_account(account_id)?;
667        Ok(transactions
668            .into_iter()
669            .filter(|t| t.status == TransactionStatus::Pending)
670            .collect())
671    }
672
673    /// Get cleared (but not reconciled) transactions for an account
674    pub fn get_cleared(&self, account_id: AccountId) -> EnvelopeResult<Vec<Transaction>> {
675        let transactions = self.storage.transactions.get_by_account(account_id)?;
676        Ok(transactions
677            .into_iter()
678            .filter(|t| t.status == TransactionStatus::Cleared)
679            .collect())
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::config::paths::EnvelopePaths;
687    use crate::models::{Account, AccountType, Category, CategoryGroup};
688    use tempfile::TempDir;
689
690    fn create_test_storage() -> (TempDir, Storage) {
691        let temp_dir = TempDir::new().unwrap();
692        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
693        let mut storage = Storage::new(paths).unwrap();
694        storage.load_all().unwrap();
695        (temp_dir, storage)
696    }
697
698    fn setup_test_data(storage: &Storage) -> (AccountId, CategoryId) {
699        // Create an account
700        let account = Account::new("Checking", AccountType::Checking);
701        let account_id = account.id;
702        storage.accounts.upsert(account).unwrap();
703        storage.accounts.save().unwrap();
704
705        // Create a category
706        let group = CategoryGroup::new("Test Group");
707        storage.categories.upsert_group(group.clone()).unwrap();
708
709        let category = Category::new("Groceries", group.id);
710        let category_id = category.id;
711        storage.categories.upsert_category(category).unwrap();
712        storage.categories.save().unwrap();
713
714        (account_id, category_id)
715    }
716
717    #[test]
718    fn test_create_transaction() {
719        let (_temp_dir, storage) = create_test_storage();
720        let (account_id, category_id) = setup_test_data(&storage);
721        let service = TransactionService::new(&storage);
722
723        let input = CreateTransactionInput {
724            account_id,
725            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
726            amount: Money::from_cents(-5000),
727            payee_name: Some("Test Store".to_string()),
728            category_id: Some(category_id),
729            memo: Some("Test purchase".to_string()),
730            status: None,
731        };
732
733        let txn = service.create(input).unwrap();
734
735        assert_eq!(txn.amount.cents(), -5000);
736        assert_eq!(txn.payee_name, "Test Store");
737        assert_eq!(txn.category_id, Some(category_id));
738        assert_eq!(txn.status, TransactionStatus::Pending);
739    }
740
741    #[test]
742    fn test_list_transactions() {
743        let (_temp_dir, storage) = create_test_storage();
744        let (account_id, category_id) = setup_test_data(&storage);
745        let service = TransactionService::new(&storage);
746
747        // Create a few transactions
748        for i in 1..=3 {
749            let input = CreateTransactionInput {
750                account_id,
751                date: NaiveDate::from_ymd_opt(2025, 1, i as u32).unwrap(),
752                amount: Money::from_cents(-1000 * i),
753                payee_name: Some(format!("Store {}", i)),
754                category_id: Some(category_id),
755                memo: None,
756                status: None,
757            };
758            service.create(input).unwrap();
759        }
760
761        let transactions = service.list(TransactionFilter::new()).unwrap();
762        assert_eq!(transactions.len(), 3);
763
764        // Filter by account
765        let filtered = service
766            .list(TransactionFilter::new().account(account_id))
767            .unwrap();
768        assert_eq!(filtered.len(), 3);
769
770        // Limit results
771        let limited = service.list(TransactionFilter::new().limit(2)).unwrap();
772        assert_eq!(limited.len(), 2);
773    }
774
775    #[test]
776    fn test_update_transaction() {
777        let (_temp_dir, storage) = create_test_storage();
778        let (account_id, _category_id) = setup_test_data(&storage);
779        let service = TransactionService::new(&storage);
780
781        let input = CreateTransactionInput {
782            account_id,
783            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
784            amount: Money::from_cents(-5000),
785            payee_name: Some("Original Store".to_string()),
786            category_id: None,
787            memo: None,
788            status: None,
789        };
790
791        let txn = service.create(input).unwrap();
792
793        // Update the transaction
794        let updated = service
795            .update(
796                txn.id,
797                None,
798                Some(Money::from_cents(-7500)),
799                Some("Updated Store".to_string()),
800                None,
801                Some("Updated memo".to_string()),
802            )
803            .unwrap();
804
805        assert_eq!(updated.amount.cents(), -7500);
806        assert_eq!(updated.payee_name, "Updated Store");
807        assert_eq!(updated.memo, "Updated memo");
808    }
809
810    #[test]
811    fn test_delete_transaction() {
812        let (_temp_dir, storage) = create_test_storage();
813        let (account_id, _category_id) = setup_test_data(&storage);
814        let service = TransactionService::new(&storage);
815
816        let input = CreateTransactionInput {
817            account_id,
818            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
819            amount: Money::from_cents(-5000),
820            payee_name: None,
821            category_id: None,
822            memo: None,
823            status: None,
824        };
825
826        let txn = service.create(input).unwrap();
827        assert_eq!(service.count().unwrap(), 1);
828
829        service.delete(txn.id).unwrap();
830        assert_eq!(service.count().unwrap(), 0);
831    }
832
833    #[test]
834    fn test_status_transitions() {
835        let (_temp_dir, storage) = create_test_storage();
836        let (account_id, _category_id) = setup_test_data(&storage);
837        let service = TransactionService::new(&storage);
838
839        let input = CreateTransactionInput {
840            account_id,
841            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
842            amount: Money::from_cents(-5000),
843            payee_name: None,
844            category_id: None,
845            memo: None,
846            status: None,
847        };
848
849        let txn = service.create(input).unwrap();
850        assert_eq!(txn.status, TransactionStatus::Pending);
851
852        // Clear
853        let cleared = service.clear(txn.id).unwrap();
854        assert_eq!(cleared.status, TransactionStatus::Cleared);
855
856        // Unclear
857        let uncleared = service.unclear(txn.id).unwrap();
858        assert_eq!(uncleared.status, TransactionStatus::Pending);
859    }
860
861    #[test]
862    fn test_locked_transaction() {
863        let (_temp_dir, storage) = create_test_storage();
864        let (account_id, _category_id) = setup_test_data(&storage);
865        let service = TransactionService::new(&storage);
866
867        let input = CreateTransactionInput {
868            account_id,
869            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
870            amount: Money::from_cents(-5000),
871            payee_name: None,
872            category_id: None,
873            memo: None,
874            status: None,
875        };
876
877        let txn = service.create(input).unwrap();
878
879        // Reconcile (lock)
880        let reconciled = service
881            .set_status(txn.id, TransactionStatus::Reconciled)
882            .unwrap();
883        assert!(reconciled.is_locked());
884
885        // Try to update - should fail
886        let update_result = service.update(
887            txn.id,
888            None,
889            Some(Money::from_cents(-7500)),
890            None,
891            None,
892            None,
893        );
894        assert!(matches!(update_result, Err(EnvelopeError::Locked(_))));
895
896        // Try to delete - should fail
897        let delete_result = service.delete(txn.id);
898        assert!(matches!(delete_result, Err(EnvelopeError::Locked(_))));
899
900        // Unlock
901        let unlocked = service.unlock(txn.id).unwrap();
902        assert!(!unlocked.is_locked());
903
904        // Now update should work
905        let updated = service
906            .update(
907                txn.id,
908                None,
909                Some(Money::from_cents(-7500)),
910                None,
911                None,
912                None,
913            )
914            .unwrap();
915        assert_eq!(updated.amount.cents(), -7500);
916    }
917
918    #[test]
919    fn test_split_transactions() {
920        let (_temp_dir, storage) = create_test_storage();
921        let (account_id, category_id) = setup_test_data(&storage);
922        let service = TransactionService::new(&storage);
923
924        // Create another category for split
925        let category2 = Category::new(
926            "Household",
927            storage
928                .categories
929                .get_all_groups()
930                .unwrap()
931                .first()
932                .unwrap()
933                .id,
934        );
935        let category2_id = category2.id;
936        storage.categories.upsert_category(category2).unwrap();
937        storage.categories.save().unwrap();
938
939        let input = CreateTransactionInput {
940            account_id,
941            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
942            amount: Money::from_cents(-10000),
943            payee_name: Some("Multi-Store".to_string()),
944            category_id: None,
945            memo: None,
946            status: None,
947        };
948
949        let txn = service.create(input).unwrap();
950
951        // Set splits using set_splits to add multiple splits at once
952        let splits = vec![
953            Split::new(category_id, Money::from_cents(-6000)),
954            Split::with_memo(
955                category2_id,
956                Money::from_cents(-4000),
957                "Cleaning supplies".to_string(),
958            ),
959        ];
960
961        let final_txn = service.set_splits(txn.id, splits).unwrap();
962
963        assert!(final_txn.is_split());
964        assert_eq!(final_txn.splits.len(), 2);
965        assert!(final_txn.validate().is_ok());
966    }
967}