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 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 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 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 let found = service.find("My Checking").unwrap().unwrap();
421 assert_eq!(found.id, created.id);
422
423 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 let active = service.list(false).unwrap();
458 assert!(active.is_empty());
459
460 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 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 let balance = service.calculate_balance(account.id).unwrap();
513 assert_eq!(balance.cents(), 115000);
514
515 let cleared = service.calculate_cleared_balance(account.id).unwrap();
517 assert_eq!(cleared.cents(), 120000);
518 }
519}