envelope_cli/services/
account.rs

1//! Account service
2//!
3//! Provides business logic for account management including CRUD operations,
4//! balance calculation, and validation.
5
6use crate::audit::EntityType;
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::{Account, AccountId, AccountType, Money, TransactionStatus};
9use crate::storage::Storage;
10
11/// Service for account management
12pub struct AccountService<'a> {
13    storage: &'a Storage,
14}
15
16/// Summary of an account with computed fields
17#[derive(Debug, Clone)]
18pub struct AccountSummary {
19    pub account: Account,
20    /// Current balance (starting balance + all transactions)
21    pub balance: Money,
22    /// Cleared balance (starting balance + cleared/reconciled transactions only)
23    pub cleared_balance: Money,
24    /// Number of uncleared transactions
25    pub uncleared_count: usize,
26}
27
28impl<'a> AccountService<'a> {
29    /// Create a new account service
30    pub fn new(storage: &'a Storage) -> Self {
31        Self { storage }
32    }
33
34    /// Create a new account
35    pub fn create(
36        &self,
37        name: &str,
38        account_type: AccountType,
39        starting_balance: Money,
40        on_budget: bool,
41    ) -> EnvelopeResult<Account> {
42        // Validate name is not empty
43        let name = name.trim();
44        if name.is_empty() {
45            return Err(EnvelopeError::Validation(
46                "Account name cannot be empty".into(),
47            ));
48        }
49
50        // Check for duplicate name
51        if self.storage.accounts.name_exists(name, None)? {
52            return Err(EnvelopeError::Duplicate {
53                entity_type: "Account",
54                identifier: name.to_string(),
55            });
56        }
57
58        // Create the account
59        let mut account = Account::with_starting_balance(name, account_type, starting_balance);
60        account.on_budget = on_budget;
61
62        // Validate
63        account
64            .validate()
65            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
66
67        // Save to storage
68        self.storage.accounts.upsert(account.clone())?;
69        self.storage.accounts.save()?;
70
71        // Audit log
72        self.storage.log_create(
73            EntityType::Account,
74            account.id.to_string(),
75            Some(account.name.clone()),
76            &account,
77        )?;
78
79        Ok(account)
80    }
81
82    /// Get an account by ID
83    pub fn get(&self, id: AccountId) -> EnvelopeResult<Option<Account>> {
84        self.storage.accounts.get(id)
85    }
86
87    /// Get an account by name (case-insensitive)
88    pub fn get_by_name(&self, name: &str) -> EnvelopeResult<Option<Account>> {
89        self.storage.accounts.get_by_name(name)
90    }
91
92    /// Find an account by name or ID string
93    pub fn find(&self, identifier: &str) -> EnvelopeResult<Option<Account>> {
94        // Try by name first
95        if let Some(account) = self.storage.accounts.get_by_name(identifier)? {
96            return Ok(Some(account));
97        }
98
99        // Try parsing as ID
100        if let Ok(id) = identifier.parse::<AccountId>() {
101            return self.storage.accounts.get(id);
102        }
103
104        Ok(None)
105    }
106
107    /// Get all accounts
108    pub fn list(&self, include_archived: bool) -> EnvelopeResult<Vec<Account>> {
109        if include_archived {
110            self.storage.accounts.get_all()
111        } else {
112            self.storage.accounts.get_active()
113        }
114    }
115
116    /// Get all accounts with their computed balances
117    pub fn list_with_balances(
118        &self,
119        include_archived: bool,
120    ) -> EnvelopeResult<Vec<AccountSummary>> {
121        let accounts = self.list(include_archived)?;
122        let mut summaries = Vec::with_capacity(accounts.len());
123
124        for account in accounts {
125            let summary = self.get_summary(&account)?;
126            summaries.push(summary);
127        }
128
129        Ok(summaries)
130    }
131
132    /// Get account summary with computed balances
133    pub fn get_summary(&self, account: &Account) -> EnvelopeResult<AccountSummary> {
134        let transactions = self.storage.transactions.get_by_account(account.id)?;
135
136        let mut balance = account.starting_balance;
137        let mut cleared_balance = account.starting_balance;
138        let mut uncleared_count = 0;
139
140        for txn in &transactions {
141            balance += txn.amount;
142
143            match txn.status {
144                TransactionStatus::Cleared | TransactionStatus::Reconciled => {
145                    cleared_balance += txn.amount;
146                }
147                TransactionStatus::Pending => {
148                    uncleared_count += 1;
149                }
150            }
151        }
152
153        Ok(AccountSummary {
154            account: account.clone(),
155            balance,
156            cleared_balance,
157            uncleared_count,
158        })
159    }
160
161    /// Calculate the current balance for an account
162    pub fn calculate_balance(&self, account_id: AccountId) -> EnvelopeResult<Money> {
163        let account = self
164            .storage
165            .accounts
166            .get(account_id)?
167            .ok_or_else(|| EnvelopeError::account_not_found(account_id.to_string()))?;
168
169        let transactions = self.storage.transactions.get_by_account(account_id)?;
170        let transaction_total: Money = transactions.iter().map(|t| t.amount).sum();
171
172        Ok(account.starting_balance + transaction_total)
173    }
174
175    /// Calculate the cleared balance for an account
176    pub fn calculate_cleared_balance(&self, account_id: AccountId) -> EnvelopeResult<Money> {
177        let account = self
178            .storage
179            .accounts
180            .get(account_id)?
181            .ok_or_else(|| EnvelopeError::account_not_found(account_id.to_string()))?;
182
183        let transactions = self.storage.transactions.get_by_account(account_id)?;
184        let cleared_total: Money = transactions
185            .iter()
186            .filter(|t| {
187                matches!(
188                    t.status,
189                    TransactionStatus::Cleared | TransactionStatus::Reconciled
190                )
191            })
192            .map(|t| t.amount)
193            .sum();
194
195        Ok(account.starting_balance + cleared_total)
196    }
197
198    /// Update an account
199    pub fn update(&self, id: AccountId, name: Option<&str>) -> EnvelopeResult<Account> {
200        let mut account = self
201            .storage
202            .accounts
203            .get(id)?
204            .ok_or_else(|| EnvelopeError::account_not_found(id.to_string()))?;
205
206        let before = account.clone();
207
208        // Update name if provided
209        if let Some(new_name) = name {
210            let new_name = new_name.trim();
211            if new_name.is_empty() {
212                return Err(EnvelopeError::Validation(
213                    "Account name cannot be empty".into(),
214                ));
215            }
216
217            // Check for duplicate name (excluding self)
218            if self.storage.accounts.name_exists(new_name, Some(id))? {
219                return Err(EnvelopeError::Duplicate {
220                    entity_type: "Account",
221                    identifier: new_name.to_string(),
222                });
223            }
224
225            account.name = new_name.to_string();
226        }
227
228        account.updated_at = chrono::Utc::now();
229
230        // Validate
231        account
232            .validate()
233            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
234
235        // Save
236        self.storage.accounts.upsert(account.clone())?;
237        self.storage.accounts.save()?;
238
239        // Audit log
240        let diff = if before.name != account.name {
241            Some(format!("name: {} -> {}", before.name, account.name))
242        } else {
243            None
244        };
245
246        self.storage.log_update(
247            EntityType::Account,
248            account.id.to_string(),
249            Some(account.name.clone()),
250            &before,
251            &account,
252            diff,
253        )?;
254
255        Ok(account)
256    }
257
258    /// Archive an account (soft delete)
259    pub fn archive(&self, id: AccountId) -> EnvelopeResult<Account> {
260        let mut account = self
261            .storage
262            .accounts
263            .get(id)?
264            .ok_or_else(|| EnvelopeError::account_not_found(id.to_string()))?;
265
266        if account.archived {
267            return Err(EnvelopeError::Validation(
268                "Account is already archived".into(),
269            ));
270        }
271
272        let before = account.clone();
273        account.archive();
274
275        // Save
276        self.storage.accounts.upsert(account.clone())?;
277        self.storage.accounts.save()?;
278
279        // Audit log
280        self.storage.log_update(
281            EntityType::Account,
282            account.id.to_string(),
283            Some(account.name.clone()),
284            &before,
285            &account,
286            Some("archived: false -> true".to_string()),
287        )?;
288
289        Ok(account)
290    }
291
292    /// Unarchive an account
293    pub fn unarchive(&self, id: AccountId) -> EnvelopeResult<Account> {
294        let mut account = self
295            .storage
296            .accounts
297            .get(id)?
298            .ok_or_else(|| EnvelopeError::account_not_found(id.to_string()))?;
299
300        if !account.archived {
301            return Err(EnvelopeError::Validation("Account is not archived".into()));
302        }
303
304        let before = account.clone();
305        account.unarchive();
306
307        // Save
308        self.storage.accounts.upsert(account.clone())?;
309        self.storage.accounts.save()?;
310
311        // Audit log
312        self.storage.log_update(
313            EntityType::Account,
314            account.id.to_string(),
315            Some(account.name.clone()),
316            &before,
317            &account,
318            Some("archived: true -> false".to_string()),
319        )?;
320
321        Ok(account)
322    }
323
324    /// Get total balance across all on-budget accounts
325    pub fn total_on_budget_balance(&self) -> EnvelopeResult<Money> {
326        let accounts = self.storage.accounts.get_active()?;
327        let mut total = Money::zero();
328
329        for account in accounts {
330            if account.on_budget {
331                total += self.calculate_balance(account.id)?;
332            }
333        }
334
335        Ok(total)
336    }
337
338    /// Get total balance for all accounts of a specific type
339    pub fn total_balance_by_type(&self, account_type: AccountType) -> EnvelopeResult<Money> {
340        let accounts = self.storage.accounts.get_active()?;
341        let mut total = Money::zero();
342
343        for account in accounts {
344            if account.account_type == account_type {
345                total += self.calculate_balance(account.id)?;
346            }
347        }
348
349        Ok(total)
350    }
351
352    /// Get count of accounts of a specific type
353    pub fn count_by_type(&self, account_type: AccountType) -> EnvelopeResult<usize> {
354        let accounts = self.storage.accounts.get_active()?;
355        Ok(accounts
356            .iter()
357            .filter(|a| a.account_type == account_type)
358            .count())
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::config::paths::EnvelopePaths;
366    use tempfile::TempDir;
367
368    fn create_test_storage() -> (TempDir, Storage) {
369        let temp_dir = TempDir::new().unwrap();
370        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
371        let mut storage = Storage::new(paths).unwrap();
372        storage.load_all().unwrap();
373        (temp_dir, storage)
374    }
375
376    #[test]
377    fn test_create_account() {
378        let (_temp_dir, storage) = create_test_storage();
379        let service = AccountService::new(&storage);
380
381        let account = service
382            .create(
383                "Checking",
384                AccountType::Checking,
385                Money::from_cents(100000),
386                true,
387            )
388            .unwrap();
389
390        assert_eq!(account.name, "Checking");
391        assert_eq!(account.account_type, AccountType::Checking);
392        assert_eq!(account.starting_balance.cents(), 100000);
393        assert!(account.on_budget);
394    }
395
396    #[test]
397    fn test_create_duplicate_name() {
398        let (_temp_dir, storage) = create_test_storage();
399        let service = AccountService::new(&storage);
400
401        service
402            .create("Checking", AccountType::Checking, Money::zero(), true)
403            .unwrap();
404
405        // Try to create another with same name
406        let result = service.create("Checking", AccountType::Savings, Money::zero(), true);
407        assert!(matches!(result, Err(EnvelopeError::Duplicate { .. })));
408    }
409
410    #[test]
411    fn test_find_account() {
412        let (_temp_dir, storage) = create_test_storage();
413        let service = AccountService::new(&storage);
414
415        let created = service
416            .create("My Checking", AccountType::Checking, Money::zero(), true)
417            .unwrap();
418
419        // Find by name
420        let found = service.find("My Checking").unwrap().unwrap();
421        assert_eq!(found.id, created.id);
422
423        // Case insensitive
424        let found = service.find("my checking").unwrap().unwrap();
425        assert_eq!(found.id, created.id);
426    }
427
428    #[test]
429    fn test_list_accounts() {
430        let (_temp_dir, storage) = create_test_storage();
431        let service = AccountService::new(&storage);
432
433        service
434            .create("Account 1", AccountType::Checking, Money::zero(), true)
435            .unwrap();
436        service
437            .create("Account 2", AccountType::Savings, Money::zero(), true)
438            .unwrap();
439
440        let accounts = service.list(false).unwrap();
441        assert_eq!(accounts.len(), 2);
442    }
443
444    #[test]
445    fn test_archive_account() {
446        let (_temp_dir, storage) = create_test_storage();
447        let service = AccountService::new(&storage);
448
449        let account = service
450            .create("Test", AccountType::Checking, Money::zero(), true)
451            .unwrap();
452
453        let archived = service.archive(account.id).unwrap();
454        assert!(archived.archived);
455
456        // Should not appear in active list
457        let active = service.list(false).unwrap();
458        assert!(active.is_empty());
459
460        // Should appear in all list
461        let all = service.list(true).unwrap();
462        assert_eq!(all.len(), 1);
463    }
464
465    #[test]
466    fn test_update_account() {
467        let (_temp_dir, storage) = create_test_storage();
468        let service = AccountService::new(&storage);
469
470        let account = service
471            .create("Old Name", AccountType::Checking, Money::zero(), true)
472            .unwrap();
473
474        let updated = service.update(account.id, Some("New Name")).unwrap();
475        assert_eq!(updated.name, "New Name");
476    }
477
478    #[test]
479    fn test_balance_calculation() {
480        let (_temp_dir, storage) = create_test_storage();
481        let service = AccountService::new(&storage);
482
483        let account = service
484            .create(
485                "Test",
486                AccountType::Checking,
487                Money::from_cents(100000),
488                true,
489            )
490            .unwrap();
491
492        // Add some transactions
493        use crate::models::Transaction;
494        use chrono::NaiveDate;
495
496        let txn1 = Transaction::new(
497            account.id,
498            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
499            Money::from_cents(-5000),
500        );
501        storage.transactions.upsert(txn1).unwrap();
502
503        let mut txn2 = Transaction::new(
504            account.id,
505            NaiveDate::from_ymd_opt(2025, 1, 16).unwrap(),
506            Money::from_cents(20000),
507        );
508        txn2.clear();
509        storage.transactions.upsert(txn2).unwrap();
510
511        // Total balance = 100000 - 5000 + 20000 = 115000
512        let balance = service.calculate_balance(account.id).unwrap();
513        assert_eq!(balance.cents(), 115000);
514
515        // Cleared balance = 100000 + 20000 = 120000 (pending txn not counted)
516        let cleared = service.calculate_cleared_balance(account.id).unwrap();
517        assert_eq!(cleared.cents(), 120000);
518    }
519}