envelope_cli/reports/
account_register.rs

1//! Account Register Report
2//!
3//! Generates a detailed transaction register for an account with filtering options.
4
5use 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/// A single entry in the register report
13#[derive(Debug, Clone)]
14pub struct RegisterEntry {
15    /// Transaction date
16    pub date: NaiveDate,
17    /// Payee name
18    pub payee: String,
19    /// Category name (or "Split" or "Transfer" or "Uncategorized")
20    pub category: String,
21    /// Memo
22    pub memo: String,
23    /// Transaction amount
24    pub amount: Money,
25    /// Running balance after this transaction
26    pub running_balance: Money,
27    /// Transaction status
28    pub status: TransactionStatus,
29    /// Whether this is a split transaction
30    pub is_split: bool,
31    /// Whether this is a transfer
32    pub is_transfer: bool,
33}
34
35/// Filter options for the register report
36#[derive(Debug, Clone, Default)]
37pub struct RegisterFilter {
38    /// Filter by start date
39    pub start_date: Option<NaiveDate>,
40    /// Filter by end date
41    pub end_date: Option<NaiveDate>,
42    /// Filter by category ID
43    pub category_id: Option<CategoryId>,
44    /// Filter by status
45    pub status: Option<TransactionStatus>,
46    /// Filter by payee (partial match)
47    pub payee_contains: Option<String>,
48    /// Filter by minimum amount (absolute value)
49    pub min_amount: Option<Money>,
50    /// Filter by maximum amount (absolute value)
51    pub max_amount: Option<Money>,
52    /// Only show uncategorized transactions
53    pub uncategorized_only: bool,
54}
55
56impl RegisterFilter {
57    /// Check if a transaction matches this filter
58    pub fn matches(&self, txn: &Transaction) -> bool {
59        // Date filters
60        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        // Category filter
72        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        // Status filter
81        if let Some(status) = self.status {
82            if txn.status != status {
83                return false;
84            }
85        }
86
87        // Payee filter
88        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        // Amount filters
99        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        // Uncategorized filter
112        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/// Account Register Report
123#[derive(Debug, Clone)]
124pub struct AccountRegisterReport {
125    /// Account ID
126    pub account_id: AccountId,
127    /// Account name
128    pub account_name: String,
129    /// Starting balance (before first transaction in report)
130    pub starting_balance: Money,
131    /// Ending balance (after last transaction)
132    pub ending_balance: Money,
133    /// Register entries
134    pub entries: Vec<RegisterEntry>,
135    /// Total inflows in the report period
136    pub total_inflows: Money,
137    /// Total outflows in the report period
138    pub total_outflows: Money,
139    /// Filter applied
140    pub filter: RegisterFilter,
141}
142
143impl AccountRegisterReport {
144    /// Generate a register report for an account
145    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        // Get the account
154        let account = account_service.get(account_id)?.ok_or_else(|| {
155            crate::error::EnvelopeError::account_not_found(account_id.to_string())
156        })?;
157
158        // Build category lookup
159        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        // Get all transactions for this account
164        let mut transactions = storage.transactions.get_by_account(account_id)?;
165
166        // Sort by date, then by created_at for same-day transactions
167        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        // Calculate starting balance (account starting balance + all transactions before filter start)
174        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        // Build register entries
184        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            // Apply filter
191            if !filter.matches(txn) {
192                continue;
193            }
194
195            // Update running balance
196            running_balance += txn.amount;
197
198            // Track totals
199            if txn.amount.is_positive() {
200                total_inflows += txn.amount;
201            } else {
202                total_outflows += txn.amount;
203            }
204
205            // Determine category display
206            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    /// Format the report for terminal display
245    pub fn format_terminal(&self) -> String {
246        let mut output = String::new();
247
248        // Header
249        output.push_str(&format!("Account Register: {}\n", self.account_name));
250        output.push_str(&"=".repeat(100));
251        output.push('\n');
252
253        // Filter info
254        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        // Column headers
266        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        // Entries
274        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        // Summary
305        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    /// Export the report to CSV format
318    pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
319        // Write header
320        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        // Write data rows
327        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            // Escape CSV fields that might contain commas
335            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    /// Get summary statistics
358    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/// Summary statistics for a register report
388#[derive(Debug, Clone)]
389pub struct RegisterSummary {
390    /// Total number of entries
391    pub total_entries: usize,
392    /// Number of cleared/reconciled entries
393    pub cleared_count: usize,
394    /// Number of pending entries
395    pub pending_count: usize,
396    /// Total inflows
397    pub total_inflows: Money,
398    /// Total outflows
399    pub total_outflows: Money,
400    /// Net change (inflows + outflows)
401    pub net_change: Money,
402}
403
404/// Escape a string for CSV format
405fn 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        // Create test data
433        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        // Add transactions
448        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        // Generate report
465        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        // 100000 - 5000 + 200000 = 295000
472        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        // Add transactions on different dates
483        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        // Filter by date range
493        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); // Days 3, 4, 5, 6, 7
502    }
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}