1use crate::error::EnvelopeResult;
6use crate::models::{AccountId, CategoryId, Money, Transaction, TransactionStatus};
7use crate::services::{AccountService, CategoryService};
8use crate::storage::Storage;
9use chrono::NaiveDate;
10use std::io::Write;
11
12#[derive(Debug, Clone)]
14pub struct RegisterEntry {
15 pub date: NaiveDate,
17 pub payee: String,
19 pub category: String,
21 pub memo: String,
23 pub amount: Money,
25 pub running_balance: Money,
27 pub status: TransactionStatus,
29 pub is_split: bool,
31 pub is_transfer: bool,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct RegisterFilter {
38 pub start_date: Option<NaiveDate>,
40 pub end_date: Option<NaiveDate>,
42 pub category_id: Option<CategoryId>,
44 pub status: Option<TransactionStatus>,
46 pub payee_contains: Option<String>,
48 pub min_amount: Option<Money>,
50 pub max_amount: Option<Money>,
52 pub uncategorized_only: bool,
54}
55
56impl RegisterFilter {
57 pub fn matches(&self, txn: &Transaction) -> bool {
59 if let Some(start) = self.start_date {
61 if txn.date < start {
62 return false;
63 }
64 }
65 if let Some(end) = self.end_date {
66 if txn.date > end {
67 return false;
68 }
69 }
70
71 if let Some(cat_id) = self.category_id {
73 let matches_category = txn.category_id == Some(cat_id)
74 || txn.splits.iter().any(|s| s.category_id == cat_id);
75 if !matches_category {
76 return false;
77 }
78 }
79
80 if let Some(status) = self.status {
82 if txn.status != status {
83 return false;
84 }
85 }
86
87 if let Some(ref payee) = self.payee_contains {
89 if !txn
90 .payee_name
91 .to_lowercase()
92 .contains(&payee.to_lowercase())
93 {
94 return false;
95 }
96 }
97
98 let abs_amount = txn.amount.abs();
100 if let Some(min) = self.min_amount {
101 if abs_amount < min {
102 return false;
103 }
104 }
105 if let Some(max) = self.max_amount {
106 if abs_amount > max {
107 return false;
108 }
109 }
110
111 if self.uncategorized_only
113 && (txn.category_id.is_some() || !txn.splits.is_empty() || txn.is_transfer())
114 {
115 return false;
116 }
117
118 true
119 }
120}
121
122#[derive(Debug, Clone)]
124pub struct AccountRegisterReport {
125 pub account_id: AccountId,
127 pub account_name: String,
129 pub starting_balance: Money,
131 pub ending_balance: Money,
133 pub entries: Vec<RegisterEntry>,
135 pub total_inflows: Money,
137 pub total_outflows: Money,
139 pub filter: RegisterFilter,
141}
142
143impl AccountRegisterReport {
144 pub fn generate(
146 storage: &Storage,
147 account_id: AccountId,
148 filter: RegisterFilter,
149 ) -> EnvelopeResult<Self> {
150 let account_service = AccountService::new(storage);
151 let category_service = CategoryService::new(storage);
152
153 let account = account_service.get(account_id)?.ok_or_else(|| {
155 crate::error::EnvelopeError::account_not_found(account_id.to_string())
156 })?;
157
158 let categories = category_service.list_categories()?;
160 let category_names: std::collections::HashMap<CategoryId, String> =
161 categories.iter().map(|c| (c.id, c.name.clone())).collect();
162
163 let mut transactions = storage.transactions.get_by_account(account_id)?;
165
166 transactions.sort_by(|a, b| {
168 a.date
169 .cmp(&b.date)
170 .then_with(|| a.created_at.cmp(&b.created_at))
171 });
172
173 let mut starting_balance = account.starting_balance;
175 if let Some(start_date) = filter.start_date {
176 for txn in &transactions {
177 if txn.date < start_date {
178 starting_balance += txn.amount;
179 }
180 }
181 }
182
183 let mut entries = Vec::new();
185 let mut running_balance = starting_balance;
186 let mut total_inflows = Money::zero();
187 let mut total_outflows = Money::zero();
188
189 for txn in &transactions {
190 if !filter.matches(txn) {
192 continue;
193 }
194
195 running_balance += txn.amount;
197
198 if txn.amount.is_positive() {
200 total_inflows += txn.amount;
201 } else {
202 total_outflows += txn.amount;
203 }
204
205 let category = if txn.is_transfer() {
207 "Transfer".to_string()
208 } else if txn.is_split() {
209 "Split".to_string()
210 } else if let Some(cat_id) = txn.category_id {
211 category_names
212 .get(&cat_id)
213 .cloned()
214 .unwrap_or_else(|| "Unknown".to_string())
215 } else {
216 "Uncategorized".to_string()
217 };
218
219 entries.push(RegisterEntry {
220 date: txn.date,
221 payee: txn.payee_name.clone(),
222 category,
223 memo: txn.memo.clone(),
224 amount: txn.amount,
225 running_balance,
226 status: txn.status,
227 is_split: txn.is_split(),
228 is_transfer: txn.is_transfer(),
229 });
230 }
231
232 Ok(Self {
233 account_id,
234 account_name: account.name.clone(),
235 starting_balance,
236 ending_balance: running_balance,
237 entries,
238 total_inflows,
239 total_outflows,
240 filter,
241 })
242 }
243
244 pub fn format_terminal(&self) -> String {
246 let mut output = String::new();
247
248 output.push_str(&format!("Account Register: {}\n", self.account_name));
250 output.push_str(&"=".repeat(100));
251 output.push('\n');
252
253 if let Some(start) = self.filter.start_date {
255 output.push_str(&format!("From: {} ", start));
256 }
257 if let Some(end) = self.filter.end_date {
258 output.push_str(&format!("To: {} ", end));
259 }
260 output.push('\n');
261
262 output.push_str(&format!("Starting Balance: {}\n", self.starting_balance));
263 output.push_str(&format!("Ending Balance: {}\n\n", self.ending_balance));
264
265 output.push_str(&format!(
267 "{:<12} {:<20} {:<20} {:>12} {:>12} {:>4}\n",
268 "Date", "Payee", "Category", "Amount", "Balance", "Clr"
269 ));
270 output.push_str(&"-".repeat(100));
271 output.push('\n');
272
273 for entry in &self.entries {
275 let status_char = match entry.status {
276 TransactionStatus::Pending => " ",
277 TransactionStatus::Cleared => "C",
278 TransactionStatus::Reconciled => "R",
279 };
280
281 let payee_display = if entry.payee.len() > 18 {
282 format!("{}...", &entry.payee[..15])
283 } else {
284 entry.payee.clone()
285 };
286
287 let category_display = if entry.category.len() > 18 {
288 format!("{}...", &entry.category[..15])
289 } else {
290 entry.category.clone()
291 };
292
293 output.push_str(&format!(
294 "{:<12} {:<20} {:<20} {:>12} {:>12} {:>4}\n",
295 entry.date,
296 payee_display,
297 category_display,
298 entry.amount,
299 entry.running_balance,
300 status_char
301 ));
302 }
303
304 output.push_str(&"-".repeat(100));
306 output.push('\n');
307 output.push_str(&format!(
308 "Total Inflows: {} | Total Outflows: {} | Transactions: {}\n",
309 self.total_inflows,
310 self.total_outflows.abs(),
311 self.entries.len()
312 ));
313
314 output
315 }
316
317 pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
319 writeln!(
321 writer,
322 "Account,Date,Payee,Category,Memo,Amount,Running Balance,Status"
323 )
324 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
325
326 for entry in &self.entries {
328 let status = match entry.status {
329 TransactionStatus::Pending => "Pending",
330 TransactionStatus::Cleared => "Cleared",
331 TransactionStatus::Reconciled => "Reconciled",
332 };
333
334 let payee = escape_csv_field(&entry.payee);
336 let category = escape_csv_field(&entry.category);
337 let memo = escape_csv_field(&entry.memo);
338
339 writeln!(
340 writer,
341 "{},{},{},{},{},{:.2},{:.2},{}",
342 self.account_name,
343 entry.date,
344 payee,
345 category,
346 memo,
347 entry.amount.cents() as f64 / 100.0,
348 entry.running_balance.cents() as f64 / 100.0,
349 status
350 )
351 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
352 }
353
354 Ok(())
355 }
356
357 pub fn summary(&self) -> RegisterSummary {
359 let cleared_count = self
360 .entries
361 .iter()
362 .filter(|e| {
363 matches!(
364 e.status,
365 TransactionStatus::Cleared | TransactionStatus::Reconciled
366 )
367 })
368 .count();
369
370 let pending_count = self
371 .entries
372 .iter()
373 .filter(|e| e.status == TransactionStatus::Pending)
374 .count();
375
376 RegisterSummary {
377 total_entries: self.entries.len(),
378 cleared_count,
379 pending_count,
380 total_inflows: self.total_inflows,
381 total_outflows: self.total_outflows,
382 net_change: self.total_inflows + self.total_outflows,
383 }
384 }
385}
386
387#[derive(Debug, Clone)]
389pub struct RegisterSummary {
390 pub total_entries: usize,
392 pub cleared_count: usize,
394 pub pending_count: usize,
396 pub total_inflows: Money,
398 pub total_outflows: Money,
400 pub net_change: Money,
402}
403
404fn escape_csv_field(s: &str) -> String {
406 if s.contains(',') || s.contains('"') || s.contains('\n') {
407 format!("\"{}\"", s.replace('"', "\"\""))
408 } else {
409 s.to_string()
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::config::paths::EnvelopePaths;
417 use crate::models::{Account, AccountType, Category, CategoryGroup};
418 use tempfile::TempDir;
419
420 fn create_test_storage() -> (TempDir, Storage) {
421 let temp_dir = TempDir::new().unwrap();
422 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
423 let mut storage = Storage::new(paths).unwrap();
424 storage.load_all().unwrap();
425 (temp_dir, storage)
426 }
427
428 #[test]
429 fn test_generate_register_report() {
430 let (_temp_dir, storage) = create_test_storage();
431
432 let account = Account::with_starting_balance(
434 "Checking",
435 AccountType::Checking,
436 Money::from_cents(100000),
437 );
438 storage.accounts.upsert(account.clone()).unwrap();
439 storage.accounts.save().unwrap();
440
441 let group = CategoryGroup::new("Test");
442 storage.categories.upsert_group(group.clone()).unwrap();
443 let cat = Category::new("Groceries", group.id);
444 storage.categories.upsert_category(cat.clone()).unwrap();
445 storage.categories.save().unwrap();
446
447 let mut txn1 = Transaction::new(
449 account.id,
450 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
451 Money::from_cents(-5000),
452 );
453 txn1.payee_name = "Grocery Store".to_string();
454 txn1.category_id = Some(cat.id);
455 storage.transactions.upsert(txn1).unwrap();
456
457 let txn2 = Transaction::new(
458 account.id,
459 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
460 Money::from_cents(200000),
461 );
462 storage.transactions.upsert(txn2).unwrap();
463
464 let report =
466 AccountRegisterReport::generate(&storage, account.id, RegisterFilter::default())
467 .unwrap();
468
469 assert_eq!(report.entries.len(), 2);
470 assert_eq!(report.starting_balance.cents(), 100000);
471 assert_eq!(report.ending_balance.cents(), 295000);
473 }
474
475 #[test]
476 fn test_register_filter() {
477 let (_temp_dir, storage) = create_test_storage();
478
479 let account = Account::new("Checking", AccountType::Checking);
480 storage.accounts.upsert(account.clone()).unwrap();
481
482 for day in 1..10 {
484 let txn = Transaction::new(
485 account.id,
486 NaiveDate::from_ymd_opt(2025, 1, day).unwrap(),
487 Money::from_cents(-1000),
488 );
489 storage.transactions.upsert(txn).unwrap();
490 }
491
492 let filter = RegisterFilter {
494 start_date: Some(NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()),
495 end_date: Some(NaiveDate::from_ymd_opt(2025, 1, 7).unwrap()),
496 ..Default::default()
497 };
498
499 let report = AccountRegisterReport::generate(&storage, account.id, filter).unwrap();
500
501 assert_eq!(report.entries.len(), 5); }
503
504 #[test]
505 fn test_csv_export() {
506 let (_temp_dir, storage) = create_test_storage();
507
508 let account = Account::new("Checking", AccountType::Checking);
509 storage.accounts.upsert(account.clone()).unwrap();
510
511 let mut txn = Transaction::new(
512 account.id,
513 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
514 Money::from_cents(-5000),
515 );
516 txn.payee_name = "Test Payee".to_string();
517 storage.transactions.upsert(txn).unwrap();
518
519 let report =
520 AccountRegisterReport::generate(&storage, account.id, RegisterFilter::default())
521 .unwrap();
522
523 let mut csv_output = Vec::new();
524 report.export_csv(&mut csv_output).unwrap();
525
526 let csv_string = String::from_utf8(csv_output).unwrap();
527 assert!(csv_string.contains("Account,Date,Payee,Category,Memo,Amount"));
528 assert!(csv_string.contains("Test Payee"));
529 }
530}