1use crate::error::EnvelopeResult;
6use crate::models::{AccountId, AccountType, Money};
7use crate::services::AccountService;
8use crate::storage::Storage;
9use std::io::Write;
10
11#[derive(Debug, Clone)]
13pub struct AccountBalance {
14 pub account_id: AccountId,
16 pub account_name: String,
18 pub account_type: AccountType,
20 pub on_budget: bool,
22 pub balance: Money,
24 pub cleared_balance: Money,
26 pub uncleared_count: usize,
28}
29
30#[derive(Debug, Clone)]
32pub struct AccountTypeGroup {
33 pub account_type: AccountType,
35 pub accounts: Vec<AccountBalance>,
37 pub total_balance: Money,
39 pub total_cleared: Money,
41}
42
43impl AccountTypeGroup {
44 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 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#[derive(Debug, Clone)]
64pub struct NetWorthSummary {
65 pub total_assets: Money,
67 pub total_liabilities: Money,
69 pub net_worth: Money,
71 pub on_budget_total: Money,
73 pub off_budget_total: Money,
75}
76
77#[derive(Debug, Clone)]
79pub struct NetWorthReport {
80 pub groups: Vec<AccountTypeGroup>,
82 pub summary: NetWorthSummary,
84 pub include_archived: bool,
86}
87
88impl NetWorthReport {
89 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 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 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 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 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, on_budget_total,
143 off_budget_total,
144 };
145
146 Ok(Self {
147 groups,
148 summary,
149 include_archived,
150 })
151 }
152
153 pub fn format_terminal(&self) -> String {
155 let mut output = String::new();
156
157 output.push_str("Net Worth Report\n");
159 output.push_str(&"=".repeat(70));
160 output.push('\n');
161
162 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 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 for group in &self.groups {
198 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 output.push_str(&format!(
218 " {:<28} {:>12} {:>12}\n",
219 "Subtotal:", group.total_balance, group.total_cleared
220 ));
221 }
222
223 output.push_str(&"-".repeat(70));
225 output.push('\n');
226 output.push_str("B = On-Budget account\n");
227
228 output
229 }
230
231 pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
233 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 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 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 pub fn account_count(&self) -> usize {
283 self.groups.iter().map(|g| g.accounts.len()).sum()
284 }
285}
286
287fn is_liability_account(account_type: AccountType) -> bool {
289 account_type.is_liability()
290}
291
292fn 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 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 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}