envelope_cli/reports/
budget_overview.rs

1//! Budget Overview Report
2//!
3//! Generates a comprehensive budget overview showing all categories
4//! with budgeted, activity (spending), and available amounts.
5
6use crate::error::EnvelopeResult;
7use crate::models::{BudgetPeriod, CategoryGroupId, CategoryId, Money};
8use crate::services::{BudgetService, CategoryService};
9use crate::storage::Storage;
10use std::io::Write;
11
12/// A row in the budget report for a single category
13#[derive(Debug, Clone)]
14pub struct CategoryReportRow {
15    /// Category ID
16    pub category_id: CategoryId,
17    /// Category name
18    pub category_name: String,
19    /// Group ID this category belongs to
20    pub group_id: CategoryGroupId,
21    /// Amount budgeted for this period
22    pub budgeted: Money,
23    /// Amount carried over from previous period
24    pub carryover: Money,
25    /// Activity (spending) for this period
26    pub activity: Money,
27    /// Available balance (budgeted + carryover + activity)
28    pub available: Money,
29}
30
31impl CategoryReportRow {
32    /// Check if this category is overspent
33    pub fn is_overspent(&self) -> bool {
34        self.available.is_negative()
35    }
36}
37
38/// A row in the budget report for a category group with totals
39#[derive(Debug, Clone)]
40pub struct GroupReportRow {
41    /// Group ID
42    pub group_id: CategoryGroupId,
43    /// Group name
44    pub group_name: String,
45    /// Categories in this group
46    pub categories: Vec<CategoryReportRow>,
47    /// Total budgeted for this group
48    pub total_budgeted: Money,
49    /// Total carryover for this group
50    pub total_carryover: Money,
51    /// Total activity for this group
52    pub total_activity: Money,
53    /// Total available for this group
54    pub total_available: Money,
55}
56
57impl GroupReportRow {
58    /// Create a new group row
59    pub fn new(group_id: CategoryGroupId, group_name: String) -> Self {
60        Self {
61            group_id,
62            group_name,
63            categories: Vec::new(),
64            total_budgeted: Money::zero(),
65            total_carryover: Money::zero(),
66            total_activity: Money::zero(),
67            total_available: Money::zero(),
68        }
69    }
70
71    /// Add a category to this group
72    pub fn add_category(&mut self, category: CategoryReportRow) {
73        self.total_budgeted += category.budgeted;
74        self.total_carryover += category.carryover;
75        self.total_activity += category.activity;
76        self.total_available += category.available;
77        self.categories.push(category);
78    }
79
80    /// Check if any category in this group is overspent
81    pub fn has_overspent(&self) -> bool {
82        self.categories.iter().any(|c| c.is_overspent())
83    }
84}
85
86/// Budget Overview Report
87#[derive(Debug, Clone)]
88pub struct BudgetOverviewReport {
89    /// The budget period for this report
90    pub period: BudgetPeriod,
91    /// Groups with their categories
92    pub groups: Vec<GroupReportRow>,
93    /// Grand total budgeted
94    pub grand_total_budgeted: Money,
95    /// Grand total carryover
96    pub grand_total_carryover: Money,
97    /// Grand total activity
98    pub grand_total_activity: Money,
99    /// Grand total available
100    pub grand_total_available: Money,
101    /// Available to Budget (funds not yet assigned)
102    pub available_to_budget: Money,
103}
104
105impl BudgetOverviewReport {
106    /// Generate a budget overview report for a period
107    pub fn generate(storage: &Storage, period: &BudgetPeriod) -> EnvelopeResult<Self> {
108        let budget_service = BudgetService::new(storage);
109        let category_service = CategoryService::new(storage);
110
111        // Get all groups and categories
112        let groups = category_service.list_groups()?;
113        let categories = category_service.list_categories()?;
114
115        let mut report_groups: Vec<GroupReportRow> = Vec::new();
116        let mut grand_total_budgeted = Money::zero();
117        let mut grand_total_carryover = Money::zero();
118        let mut grand_total_activity = Money::zero();
119        let mut grand_total_available = Money::zero();
120
121        // Build report by group
122        for group in &groups {
123            let mut group_row = GroupReportRow::new(group.id, group.name.clone());
124
125            // Find categories in this group
126            for category in categories.iter().filter(|c| c.group_id == group.id) {
127                let summary = budget_service.get_category_summary(category.id, period)?;
128
129                let category_row = CategoryReportRow {
130                    category_id: category.id,
131                    category_name: category.name.clone(),
132                    group_id: group.id,
133                    budgeted: summary.budgeted,
134                    carryover: summary.carryover,
135                    activity: summary.activity,
136                    available: summary.available,
137                };
138
139                group_row.add_category(category_row);
140            }
141
142            // Add to grand totals
143            grand_total_budgeted += group_row.total_budgeted;
144            grand_total_carryover += group_row.total_carryover;
145            grand_total_activity += group_row.total_activity;
146            grand_total_available += group_row.total_available;
147
148            report_groups.push(group_row);
149        }
150
151        // Calculate available to budget
152        let available_to_budget = budget_service.get_available_to_budget(period)?;
153
154        Ok(Self {
155            period: period.clone(),
156            groups: report_groups,
157            grand_total_budgeted,
158            grand_total_carryover,
159            grand_total_activity,
160            grand_total_available,
161            available_to_budget,
162        })
163    }
164
165    /// Format the report for terminal display
166    pub fn format_terminal(&self) -> String {
167        let mut output = String::new();
168
169        // Header
170        output.push_str(&format!("Budget Overview - {}\n", self.period));
171        output.push_str(&"=".repeat(80));
172        output.push('\n');
173        output.push_str(&format!(
174            "Available to Budget: {}\n\n",
175            self.available_to_budget
176        ));
177
178        // Column headers
179        output.push_str(&format!(
180            "{:<30} {:>12} {:>12} {:>12}\n",
181            "Category", "Budgeted", "Activity", "Available"
182        ));
183        output.push_str(&"-".repeat(80));
184        output.push('\n');
185
186        // Groups and categories
187        for group in &self.groups {
188            // Group header
189            output.push_str(&format!("\n{}\n", group.group_name.to_uppercase()));
190
191            for category in &group.categories {
192                let available_display = if category.is_overspent() {
193                    format!("{} *", category.available)
194                } else {
195                    category.available.to_string()
196                };
197
198                output.push_str(&format!(
199                    "  {:<28} {:>12} {:>12} {:>12}\n",
200                    category.category_name, category.budgeted, category.activity, available_display
201                ));
202            }
203
204            // Group total
205            output.push_str(&format!(
206                "  {:<28} {:>12} {:>12} {:>12}\n",
207                "Group Total:", group.total_budgeted, group.total_activity, group.total_available
208            ));
209        }
210
211        // Grand totals
212        output.push_str(&"-".repeat(80));
213        output.push('\n');
214        output.push_str(&format!(
215            "{:<30} {:>12} {:>12} {:>12}\n",
216            "GRAND TOTAL",
217            self.grand_total_budgeted,
218            self.grand_total_activity,
219            self.grand_total_available
220        ));
221
222        output.push_str("\n* = Overspent\n");
223
224        output
225    }
226
227    /// Export the report to CSV format
228    pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
229        // Write header
230        writeln!(
231            writer,
232            "Period,Group,Category,Budgeted,Carryover,Activity,Available"
233        )
234        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
235
236        // Write data rows
237        for group in &self.groups {
238            for category in &group.categories {
239                writeln!(
240                    writer,
241                    "{},{},{},{:.2},{:.2},{:.2},{:.2}",
242                    self.period,
243                    group.group_name,
244                    category.category_name,
245                    category.budgeted.cents() as f64 / 100.0,
246                    category.carryover.cents() as f64 / 100.0,
247                    category.activity.cents() as f64 / 100.0,
248                    category.available.cents() as f64 / 100.0,
249                )
250                .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
251            }
252
253            // Group total row
254            writeln!(
255                writer,
256                "{},{},TOTAL,{:.2},{:.2},{:.2},{:.2}",
257                self.period,
258                group.group_name,
259                group.total_budgeted.cents() as f64 / 100.0,
260                group.total_carryover.cents() as f64 / 100.0,
261                group.total_activity.cents() as f64 / 100.0,
262                group.total_available.cents() as f64 / 100.0,
263            )
264            .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
265        }
266
267        // Grand total row
268        writeln!(
269            writer,
270            "{},GRAND TOTAL,,{:.2},{:.2},{:.2},{:.2}",
271            self.period,
272            self.grand_total_budgeted.cents() as f64 / 100.0,
273            self.grand_total_carryover.cents() as f64 / 100.0,
274            self.grand_total_activity.cents() as f64 / 100.0,
275            self.grand_total_available.cents() as f64 / 100.0,
276        )
277        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
278
279        Ok(())
280    }
281
282    /// Get count of overspent categories
283    pub fn overspent_count(&self) -> usize {
284        self.groups
285            .iter()
286            .flat_map(|g| &g.categories)
287            .filter(|c| c.is_overspent())
288            .count()
289    }
290
291    /// Get list of overspent categories
292    pub fn overspent_categories(&self) -> Vec<&CategoryReportRow> {
293        self.groups
294            .iter()
295            .flat_map(|g| &g.categories)
296            .filter(|c| c.is_overspent())
297            .collect()
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::config::paths::EnvelopePaths;
305    use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
306    use chrono::NaiveDate;
307    use tempfile::TempDir;
308
309    fn create_test_storage() -> (TempDir, Storage) {
310        let temp_dir = TempDir::new().unwrap();
311        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
312        let mut storage = Storage::new(paths).unwrap();
313        storage.load_all().unwrap();
314        (temp_dir, storage)
315    }
316
317    fn setup_test_data(storage: &Storage) -> BudgetPeriod {
318        // Create a group
319        let group = CategoryGroup::new("Test Group");
320        storage.categories.upsert_group(group.clone()).unwrap();
321
322        // Create categories
323        let cat1 = Category::new("Groceries", group.id);
324        let cat2 = Category::new("Dining Out", group.id);
325        storage.categories.upsert_category(cat1.clone()).unwrap();
326        storage.categories.upsert_category(cat2.clone()).unwrap();
327        storage.categories.save().unwrap();
328
329        // Create account with starting balance
330        let account = Account::with_starting_balance(
331            "Checking",
332            AccountType::Checking,
333            Money::from_cents(100000),
334        );
335        storage.accounts.upsert(account.clone()).unwrap();
336        storage.accounts.save().unwrap();
337
338        // Create budget allocations
339        let period = BudgetPeriod::monthly(2025, 1);
340        let budget_service = BudgetService::new(storage);
341        budget_service
342            .assign_to_category(cat1.id, &period, Money::from_cents(50000))
343            .unwrap();
344        budget_service
345            .assign_to_category(cat2.id, &period, Money::from_cents(20000))
346            .unwrap();
347
348        // Add a transaction
349        let mut txn = Transaction::new(
350            account.id,
351            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
352            Money::from_cents(-3000),
353        );
354        txn.category_id = Some(cat1.id);
355        storage.transactions.upsert(txn).unwrap();
356
357        period
358    }
359
360    #[test]
361    fn test_generate_report() {
362        let (_temp_dir, storage) = create_test_storage();
363        let period = setup_test_data(&storage);
364
365        let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
366
367        assert_eq!(report.period, period);
368        assert_eq!(report.groups.len(), 1);
369        assert_eq!(report.groups[0].categories.len(), 2);
370        assert_eq!(report.grand_total_budgeted.cents(), 70000);
371    }
372
373    #[test]
374    fn test_csv_export() {
375        let (_temp_dir, storage) = create_test_storage();
376        let period = setup_test_data(&storage);
377
378        let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
379
380        let mut csv_output = Vec::new();
381        report.export_csv(&mut csv_output).unwrap();
382
383        let csv_string = String::from_utf8(csv_output).unwrap();
384        assert!(csv_string.contains("Period,Group,Category,Budgeted,Carryover,Activity,Available"));
385        assert!(csv_string.contains("Groceries"));
386        assert!(csv_string.contains("Dining Out"));
387    }
388
389    #[test]
390    fn test_terminal_format() {
391        let (_temp_dir, storage) = create_test_storage();
392        let period = setup_test_data(&storage);
393
394        let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
395        let output = report.format_terminal();
396
397        assert!(output.contains("Budget Overview"));
398        assert!(output.contains("Groceries"));
399        assert!(output.contains("GRAND TOTAL"));
400    }
401}