envelope_cli/reports/
net_worth.rs

1//! Net Worth Report
2//!
3//! Generates a summary of all account balances showing total net worth.
4
5use crate::error::EnvelopeResult;
6use crate::models::{AccountId, AccountType, Money};
7use crate::services::AccountService;
8use crate::storage::Storage;
9use std::io::Write;
10
11/// Summary of a single account's balance
12#[derive(Debug, Clone)]
13pub struct AccountBalance {
14    /// Account ID
15    pub account_id: AccountId,
16    /// Account name
17    pub account_name: String,
18    /// Account type
19    pub account_type: AccountType,
20    /// Whether this is an on-budget account
21    pub on_budget: bool,
22    /// Current balance
23    pub balance: Money,
24    /// Cleared balance
25    pub cleared_balance: Money,
26    /// Number of uncleared transactions
27    pub uncleared_count: usize,
28}
29
30/// Net worth summary grouped by account type
31#[derive(Debug, Clone)]
32pub struct AccountTypeGroup {
33    /// Account type
34    pub account_type: AccountType,
35    /// Accounts of this type
36    pub accounts: Vec<AccountBalance>,
37    /// Total balance for this type
38    pub total_balance: Money,
39    /// Total cleared balance
40    pub total_cleared: Money,
41}
42
43impl AccountTypeGroup {
44    /// Create a new account type group
45    pub fn new(account_type: AccountType) -> Self {
46        Self {
47            account_type,
48            accounts: Vec::new(),
49            total_balance: Money::zero(),
50            total_cleared: Money::zero(),
51        }
52    }
53
54    /// Add an account to this group
55    pub fn add_account(&mut self, account: AccountBalance) {
56        self.total_balance += account.balance;
57        self.total_cleared += account.cleared_balance;
58        self.accounts.push(account);
59    }
60}
61
62/// Net Worth Summary
63#[derive(Debug, Clone)]
64pub struct NetWorthSummary {
65    /// Total assets (positive accounts: checking, savings, cash, investment)
66    pub total_assets: Money,
67    /// Total liabilities (negative accounts: credit cards, loans)
68    pub total_liabilities: Money,
69    /// Net worth (assets - liabilities)
70    pub net_worth: Money,
71    /// On-budget total
72    pub on_budget_total: Money,
73    /// Off-budget total
74    pub off_budget_total: Money,
75}
76
77/// Net Worth Report
78#[derive(Debug, Clone)]
79pub struct NetWorthReport {
80    /// Account groups by type
81    pub groups: Vec<AccountTypeGroup>,
82    /// Net worth summary
83    pub summary: NetWorthSummary,
84    /// Include archived accounts
85    pub include_archived: bool,
86}
87
88impl NetWorthReport {
89    /// Generate a net worth report
90    pub fn generate(storage: &Storage, include_archived: bool) -> EnvelopeResult<Self> {
91        let account_service = AccountService::new(storage);
92        let summaries = account_service.list_with_balances(include_archived)?;
93
94        // Group accounts by type
95        let mut groups: std::collections::HashMap<AccountType, AccountTypeGroup> =
96            std::collections::HashMap::new();
97
98        let mut total_assets = Money::zero();
99        let mut total_liabilities = Money::zero();
100        let mut on_budget_total = Money::zero();
101        let mut off_budget_total = Money::zero();
102
103        for account_summary in summaries {
104            let account_balance = AccountBalance {
105                account_id: account_summary.account.id,
106                account_name: account_summary.account.name.clone(),
107                account_type: account_summary.account.account_type,
108                on_budget: account_summary.account.on_budget,
109                balance: account_summary.balance,
110                cleared_balance: account_summary.cleared_balance,
111                uncleared_count: account_summary.uncleared_count,
112            };
113
114            // Add to appropriate group
115            groups
116                .entry(account_summary.account.account_type)
117                .or_insert_with(|| AccountTypeGroup::new(account_summary.account.account_type))
118                .add_account(account_balance);
119
120            // Track totals
121            if is_liability_account(account_summary.account.account_type) {
122                total_liabilities += account_summary.balance;
123            } else {
124                total_assets += account_summary.balance;
125            }
126
127            if account_summary.account.on_budget {
128                on_budget_total += account_summary.balance;
129            } else {
130                off_budget_total += account_summary.balance;
131            }
132        }
133
134        // Convert to sorted vector
135        let mut groups: Vec<_> = groups.into_values().collect();
136        groups.sort_by_key(|g| account_type_sort_order(g.account_type));
137
138        let summary = NetWorthSummary {
139            total_assets,
140            total_liabilities,
141            net_worth: total_assets + total_liabilities, // liabilities are already negative
142            on_budget_total,
143            off_budget_total,
144        };
145
146        Ok(Self {
147            groups,
148            summary,
149            include_archived,
150        })
151    }
152
153    /// Format the report for terminal display
154    pub fn format_terminal(&self) -> String {
155        let mut output = String::new();
156
157        // Header
158        output.push_str("Net Worth Report\n");
159        output.push_str(&"=".repeat(70));
160        output.push('\n');
161
162        // Summary box
163        output.push_str(&format!(
164            "Total Assets:      {:>15}\n",
165            self.summary.total_assets
166        ));
167        output.push_str(&format!(
168            "Total Liabilities: {:>15}\n",
169            self.summary.total_liabilities.abs()
170        ));
171        output.push_str(&"-".repeat(35));
172        output.push('\n');
173        output.push_str(&format!(
174            "Net Worth:         {:>15}\n",
175            self.summary.net_worth
176        ));
177        output.push('\n');
178        output.push_str(&format!(
179            "On-Budget:         {:>15}\n",
180            self.summary.on_budget_total
181        ));
182        output.push_str(&format!(
183            "Off-Budget:        {:>15}\n",
184            self.summary.off_budget_total
185        ));
186        output.push('\n');
187
188        // Column headers
189        output.push_str(&format!(
190            "{:<30} {:>12} {:>12} {:>10}\n",
191            "Account", "Balance", "Cleared", "Uncleared"
192        ));
193        output.push_str(&"-".repeat(70));
194        output.push('\n');
195
196        // Account groups
197        for group in &self.groups {
198            // Group header
199            output.push_str(&format!(
200                "\n{}\n",
201                format!("{:?}", group.account_type).to_uppercase()
202            ));
203
204            for account in &group.accounts {
205                let budget_indicator = if account.on_budget { "B" } else { " " };
206                output.push_str(&format!(
207                    "{} {:<28} {:>12} {:>12} {:>10}\n",
208                    budget_indicator,
209                    account.account_name,
210                    account.balance,
211                    account.cleared_balance,
212                    account.uncleared_count
213                ));
214            }
215
216            // Group total
217            output.push_str(&format!(
218                "  {:<28} {:>12} {:>12}\n",
219                "Subtotal:", group.total_balance, group.total_cleared
220            ));
221        }
222
223        // Legend
224        output.push_str(&"-".repeat(70));
225        output.push('\n');
226        output.push_str("B = On-Budget account\n");
227
228        output
229    }
230
231    /// Export the report to CSV format
232    pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
233        // Write header
234        writeln!(
235            writer,
236            "Account Type,Account Name,On Budget,Balance,Cleared Balance,Uncleared Count"
237        )
238        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
239
240        // Write data rows
241        for group in &self.groups {
242            for account in &group.accounts {
243                writeln!(
244                    writer,
245                    "{:?},{},{},{:.2},{:.2},{}",
246                    group.account_type,
247                    account.account_name,
248                    account.on_budget,
249                    account.balance.cents() as f64 / 100.0,
250                    account.cleared_balance.cents() as f64 / 100.0,
251                    account.uncleared_count
252                )
253                .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
254            }
255        }
256
257        // Summary rows
258        writeln!(writer).map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
259        writeln!(
260            writer,
261            "SUMMARY,Total Assets,,{:.2},,",
262            self.summary.total_assets.cents() as f64 / 100.0
263        )
264        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
265        writeln!(
266            writer,
267            "SUMMARY,Total Liabilities,,{:.2},,",
268            self.summary.total_liabilities.cents() as f64 / 100.0
269        )
270        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
271        writeln!(
272            writer,
273            "SUMMARY,Net Worth,,{:.2},,",
274            self.summary.net_worth.cents() as f64 / 100.0
275        )
276        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
277
278        Ok(())
279    }
280
281    /// Get total number of accounts
282    pub fn account_count(&self) -> usize {
283        self.groups.iter().map(|g| g.accounts.len()).sum()
284    }
285}
286
287/// Check if an account type is a liability
288fn is_liability_account(account_type: AccountType) -> bool {
289    account_type.is_liability()
290}
291
292/// Get sort order for account types (assets first, then liabilities)
293fn account_type_sort_order(account_type: AccountType) -> i32 {
294    match account_type {
295        AccountType::Checking => 0,
296        AccountType::Savings => 1,
297        AccountType::Cash => 2,
298        AccountType::Investment => 3,
299        AccountType::Other => 4,
300        AccountType::Credit => 10,
301        AccountType::LineOfCredit => 11,
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::config::paths::EnvelopePaths;
309    use crate::models::Account;
310    use tempfile::TempDir;
311
312    fn create_test_storage() -> (TempDir, Storage) {
313        let temp_dir = TempDir::new().unwrap();
314        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
315        let mut storage = Storage::new(paths).unwrap();
316        storage.load_all().unwrap();
317        (temp_dir, storage)
318    }
319
320    #[test]
321    fn test_generate_net_worth_report() {
322        let (_temp_dir, storage) = create_test_storage();
323
324        // Create accounts
325        let checking = Account::with_starting_balance(
326            "Checking",
327            AccountType::Checking,
328            Money::from_cents(500000),
329        );
330        storage.accounts.upsert(checking).unwrap();
331
332        let savings = Account::with_starting_balance(
333            "Savings",
334            AccountType::Savings,
335            Money::from_cents(1000000),
336        );
337        storage.accounts.upsert(savings).unwrap();
338
339        let credit_card = Account::with_starting_balance(
340            "Credit Card",
341            AccountType::Credit,
342            Money::from_cents(-50000),
343        );
344        storage.accounts.upsert(credit_card).unwrap();
345        storage.accounts.save().unwrap();
346
347        // Generate report
348        let report = NetWorthReport::generate(&storage, false).unwrap();
349
350        assert_eq!(report.account_count(), 3);
351        assert_eq!(report.summary.total_assets.cents(), 1500000);
352        assert_eq!(report.summary.total_liabilities.cents(), -50000);
353        assert_eq!(report.summary.net_worth.cents(), 1450000);
354    }
355
356    #[test]
357    fn test_csv_export() {
358        let (_temp_dir, storage) = create_test_storage();
359
360        let checking = Account::with_starting_balance(
361            "Checking",
362            AccountType::Checking,
363            Money::from_cents(100000),
364        );
365        storage.accounts.upsert(checking).unwrap();
366        storage.accounts.save().unwrap();
367
368        let report = NetWorthReport::generate(&storage, false).unwrap();
369
370        let mut csv_output = Vec::new();
371        report.export_csv(&mut csv_output).unwrap();
372
373        let csv_string = String::from_utf8(csv_output).unwrap();
374        assert!(csv_string.contains("Account Type,Account Name"));
375        assert!(csv_string.contains("Checking"));
376        assert!(csv_string.contains("Net Worth"));
377    }
378}