1use crate::audit::EntityType;
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::{Account, AccountId, AccountType, Money, TransactionStatus};
9use crate::storage::Storage;
10
11pub struct AccountService<'a> {
13 storage: &'a Storage,
14}
15
16#[derive(Debug, Clone)]
18pub struct AccountSummary {
19 pub account: Account,
20 pub balance: Money,
22 pub cleared_balance: Money,
24 pub uncleared_count: usize,
26}
27
28impl<'a> AccountService<'a> {
29 pub fn new(storage: &'a Storage) -> Self {
31 Self { storage }
32 }
33
34 pub fn create(
36 &self,
37 name: &str,
38 account_type: AccountType,
39 starting_balance: Money,
40 on_budget: bool,
41 ) -> EnvelopeResult<Account> {
42 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 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 let mut account = Account::with_starting_balance(name, account_type, starting_balance);
60 account.on_budget = on_budget;
61
62 account
64 .validate()
65 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
66
67 self.storage.accounts.upsert(account.clone())?;
69 self.storage.accounts.save()?;
70
71 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 pub fn get(&self, id: AccountId) -> EnvelopeResult<Option<Account>> {
84 self.storage.accounts.get(id)
85 }
86
87 pub fn get_by_name(&self, name: &str) -> EnvelopeResult<Option<Account>> {
89 self.storage.accounts.get_by_name(name)
90 }
91
92 pub fn find(&self, identifier: &str) -> EnvelopeResult<Option<Account>> {
94 if let Some(account) = self.storage.accounts.get_by_name(identifier)? {
96 return Ok(Some(account));
97 }
98
99 if let Ok(id) = identifier.parse::<AccountId>() {
101 return self.storage.accounts.get(id);
102 }
103
104 Ok(None)
105 }
106
107 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 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 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 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 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 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 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 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 account
232 .validate()
233 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
234
235 self.storage.accounts.upsert(account.clone())?;
237 self.storage.accounts.save()?;
238
239 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 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 self.storage.accounts.upsert(account.clone())?;
277 self.storage.accounts.save()?;
278
279 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 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 self.storage.accounts.upsert(account.clone())?;
309 self.storage.accounts.save()?;
310
311 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 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 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 let found = service.find("My Checking").unwrap().unwrap();
398 assert_eq!(found.id, created.id);
399
400 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 let active = service.list(false).unwrap();
435 assert!(active.is_empty());
436
437 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 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 let balance = service.calculate_balance(account.id).unwrap();
490 assert_eq!(balance.cents(), 115000);
491
492 let cleared = service.calculate_cleared_balance(account.id).unwrap();
494 assert_eq!(cleared.cents(), 120000);
495 }
496}