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
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::config::paths::EnvelopePaths;
343    use tempfile::TempDir;
344
345    fn create_test_storage() -> (TempDir, Storage) {
346        let temp_dir = TempDir::new().unwrap();
347        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
348        let mut storage = Storage::new(paths).unwrap();
349        storage.load_all().unwrap();
350        (temp_dir, storage)
351    }
352
353    #[test]
354    fn test_create_account() {
355        let (_temp_dir, storage) = create_test_storage();
356        let service = AccountService::new(&storage);
357
358        let account = service
359            .create(
360                "Checking",
361                AccountType::Checking,
362                Money::from_cents(100000),
363                true,
364            )
365            .unwrap();
366
367        assert_eq!(account.name, "Checking");
368        assert_eq!(account.account_type, AccountType::Checking);
369        assert_eq!(account.starting_balance.cents(), 100000);
370        assert!(account.on_budget);
371    }
372
373    #[test]
374    fn test_create_duplicate_name() {
375        let (_temp_dir, storage) = create_test_storage();
376        let service = AccountService::new(&storage);
377
378        service
379            .create("Checking", AccountType::Checking, Money::zero(), true)
380            .unwrap();
381
382        // Try to create another with same name
383        let result = service.create("Checking", AccountType::Savings, Money::zero(), true);
384        assert!(matches!(result, Err(EnvelopeError::Duplicate { .. })));
385    }
386
387    #[test]
388    fn test_find_account() {
389        let (_temp_dir, storage) = create_test_storage();
390        let service = AccountService::new(&storage);
391
392        let created = service
393            .create("My Checking", AccountType::Checking, Money::zero(), true)
394            .unwrap();
395
396        // Find by name
397        let found = service.find("My Checking").unwrap().unwrap();
398        assert_eq!(found.id, created.id);
399
400        // Case insensitive
401        let found = service.find("my checking").unwrap().unwrap();
402        assert_eq!(found.id, created.id);
403    }
404
405    #[test]
406    fn test_list_accounts() {
407        let (_temp_dir, storage) = create_test_storage();
408        let service = AccountService::new(&storage);
409
410        service
411            .create("Account 1", AccountType::Checking, Money::zero(), true)
412            .unwrap();
413        service
414            .create("Account 2", AccountType::Savings, Money::zero(), true)
415            .unwrap();
416
417        let accounts = service.list(false).unwrap();
418        assert_eq!(accounts.len(), 2);
419    }
420
421    #[test]
422    fn test_archive_account() {
423        let (_temp_dir, storage) = create_test_storage();
424        let service = AccountService::new(&storage);
425
426        let account = service
427            .create("Test", AccountType::Checking, Money::zero(), true)
428            .unwrap();
429
430        let archived = service.archive(account.id).unwrap();
431        assert!(archived.archived);
432
433        // Should not appear in active list
434        let active = service.list(false).unwrap();
435        assert!(active.is_empty());
436
437        // Should appear in all list
438        let all = service.list(true).unwrap();
439        assert_eq!(all.len(), 1);
440    }
441
442    #[test]
443    fn test_update_account() {
444        let (_temp_dir, storage) = create_test_storage();
445        let service = AccountService::new(&storage);
446
447        let account = service
448            .create("Old Name", AccountType::Checking, Money::zero(), true)
449            .unwrap();
450
451        let updated = service.update(account.id, Some("New Name")).unwrap();
452        assert_eq!(updated.name, "New Name");
453    }
454
455    #[test]
456    fn test_balance_calculation() {
457        let (_temp_dir, storage) = create_test_storage();
458        let service = AccountService::new(&storage);
459
460        let account = service
461            .create(
462                "Test",
463                AccountType::Checking,
464                Money::from_cents(100000),
465                true,
466            )
467            .unwrap();
468
469        // Add some transactions
470        use crate::models::Transaction;
471        use chrono::NaiveDate;
472
473        let txn1 = Transaction::new(
474            account.id,
475            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
476            Money::from_cents(-5000),
477        );
478        storage.transactions.upsert(txn1).unwrap();
479
480        let mut txn2 = Transaction::new(
481            account.id,
482            NaiveDate::from_ymd_opt(2025, 1, 16).unwrap(),
483            Money::from_cents(20000),
484        );
485        txn2.clear();
486        storage.transactions.upsert(txn2).unwrap();
487
488        // Total balance = 100000 - 5000 + 20000 = 115000
489        let balance = service.calculate_balance(account.id).unwrap();
490        assert_eq!(balance.cents(), 115000);
491
492        // Cleared balance = 100000 + 20000 = 120000 (pending txn not counted)
493        let cleared = service.calculate_cleared_balance(account.id).unwrap();
494        assert_eq!(cleared.cents(), 120000);
495    }
496}