envelope_cli/cli/
budget.rs

1//! Budget CLI commands
2//!
3//! Implements CLI commands for budget management including period navigation,
4//! allocation, and overview.
5
6use clap::Subcommand;
7
8use crate::config::settings::Settings;
9use crate::error::EnvelopeResult;
10use crate::services::{BudgetService, CategoryService, PeriodService};
11use crate::storage::Storage;
12
13/// Budget subcommands
14#[derive(Subcommand)]
15pub enum BudgetCommands {
16    /// Show budget overview for a period
17    Overview {
18        /// Budget period (e.g., "2025-01", "January", "current", "last")
19        #[arg(short, long)]
20        period: Option<String>,
21    },
22
23    /// Show information about the current period
24    Period {
25        /// Budget period (e.g., "2025-01", "January", "current", "last")
26        period: Option<String>,
27    },
28
29    /// List recent budget periods
30    Periods {
31        /// Number of periods to show
32        #[arg(short, long, default_value = "6")]
33        count: usize,
34    },
35
36    /// Go to the previous period
37    Prev,
38
39    /// Go to the next period
40    Next,
41
42    // These will be implemented in Step 9:
43    /// Assign funds to a category
44    Assign {
45        /// Category name
46        category: String,
47        /// Amount (e.g., "100" or "100.00")
48        amount: String,
49        /// Budget period
50        #[arg(short, long)]
51        period: Option<String>,
52    },
53
54    /// Move funds between categories
55    Move {
56        /// Source category
57        from: String,
58        /// Destination category
59        to: String,
60        /// Amount
61        amount: String,
62        /// Budget period
63        #[arg(short, long)]
64        period: Option<String>,
65    },
66
67    /// Apply rollover from previous period
68    Rollover {
69        /// Budget period to apply rollover to (defaults to current)
70        #[arg(short, long)]
71        period: Option<String>,
72    },
73
74    /// Show overspent categories
75    Overspent {
76        /// Budget period
77        #[arg(short, long)]
78        period: Option<String>,
79    },
80}
81
82/// Handle a budget command
83pub fn handle_budget_command(
84    storage: &Storage,
85    settings: &Settings,
86    cmd: BudgetCommands,
87) -> EnvelopeResult<()> {
88    let period_service = PeriodService::new(settings);
89
90    match cmd {
91        BudgetCommands::Overview { period } => {
92            let period = period_service.parse_or_current(period.as_deref())?;
93            let friendly = period_service.format_period_friendly(&period);
94
95            println!("Budget Overview: {}", friendly);
96            println!("{}", "=".repeat(72));
97
98            // Get categories with groups
99            let category_service = CategoryService::new(storage);
100            let groups = category_service.list_groups_with_categories()?;
101
102            // Get budget service for summaries
103            let budget_service = BudgetService::new(storage);
104
105            // Calculate totals
106            let mut total_budgeted = crate::models::Money::zero();
107            let mut total_carryover = crate::models::Money::zero();
108            let mut total_activity = crate::models::Money::zero();
109            let mut total_available = crate::models::Money::zero();
110            let mut has_any_carryover = false;
111
112            // First pass: check if any categories have carryover
113            for gwc in &groups {
114                for category in &gwc.categories {
115                    let summary = budget_service.get_category_summary(category.id, &period)?;
116                    if !summary.carryover.is_zero() {
117                        has_any_carryover = true;
118                        break;
119                    }
120                }
121                if has_any_carryover {
122                    break;
123                }
124            }
125
126            for gwc in &groups {
127                if gwc.categories.is_empty() {
128                    continue;
129                }
130
131                println!("\n{}", gwc.group.name);
132                if has_any_carryover {
133                    println!(
134                        "{:26} {:>10} {:>10} {:>10} {:>10}",
135                        "", "Budgeted", "Carryover", "Activity", "Available"
136                    );
137                } else {
138                    println!(
139                        "{:30} {:>10} {:>10} {:>10}",
140                        "", "Budgeted", "Activity", "Available"
141                    );
142                }
143                println!("{}", "-".repeat(72));
144
145                for category in &gwc.categories {
146                    let summary = budget_service.get_category_summary(category.id, &period)?;
147
148                    total_budgeted += summary.budgeted;
149                    total_carryover += summary.carryover;
150                    total_activity += summary.activity;
151                    total_available += summary.available;
152
153                    let status = if summary.is_overspent() {
154                        "⚠"
155                    } else if let Some(goal) = category.goal_amount {
156                        let goal_money = crate::models::Money::from_cents(goal);
157                        if summary.budgeted >= goal_money {
158                            "✓"
159                        } else {
160                            "✗"
161                        }
162                    } else {
163                        ""
164                    };
165
166                    if has_any_carryover {
167                        println!(
168                            "  {:24} {:>10} {:>10} {:>10} {:>10} {}",
169                            category.name,
170                            summary.budgeted,
171                            summary.carryover,
172                            summary.activity,
173                            summary.available,
174                            status
175                        );
176                    } else {
177                        println!(
178                            "  {:28} {:>10} {:>10} {:>10} {}",
179                            category.name,
180                            summary.budgeted,
181                            summary.activity,
182                            summary.available,
183                            status
184                        );
185                    }
186                }
187            }
188
189            println!("\n{}", "=".repeat(72));
190            if has_any_carryover {
191                println!(
192                    "{:26} {:>10} {:>10} {:>10} {:>10}",
193                    "TOTALS:", total_budgeted, total_carryover, total_activity, total_available
194                );
195            } else {
196                println!(
197                    "{:30} {:>10} {:>10} {:>10}",
198                    "TOTALS:", total_budgeted, total_activity, total_available
199                );
200            }
201
202            // Show available to budget
203            let available_to_budget = budget_service.get_available_to_budget(&period)?;
204            println!(
205                "\n{:30} {:>10}",
206                "Available to Budget:", available_to_budget
207            );
208
209            if available_to_budget.is_negative() {
210                println!(
211                    "\n⚠️  Warning: Overbudgeted by {}",
212                    available_to_budget.abs()
213                );
214            } else if available_to_budget.is_positive() {
215                println!(
216                    "\n📌 Tip: You have {} ready to assign!",
217                    available_to_budget
218                );
219            } else {
220                println!("\n✅ Budget is balanced!");
221            }
222
223            // Show warning about overspent categories
224            let overspent = budget_service.get_overspent_categories(&period)?;
225            if !overspent.is_empty() {
226                println!(
227                    "\n⚠️  {} category/categories overspent. Run 'envelope budget overspent' for details.",
228                    overspent.len()
229                );
230            }
231        }
232
233        BudgetCommands::Period { period } => {
234            let period = period_service.parse_or_current(period.as_deref())?;
235            let friendly = period_service.format_period_friendly(&period);
236            let is_current = period_service.is_current(&period);
237
238            println!("Budget Period: {}", friendly);
239            println!("  Format: {}", period);
240            println!("  Start:  {}", period.start_date());
241            println!("  End:    {}", period.end_date());
242            if is_current {
243                println!("  Status: Current period");
244            }
245        }
246
247        BudgetCommands::Periods { count } => {
248            println!("Recent Budget Periods:");
249            println!();
250
251            let periods = period_service.recent_periods(count);
252            for period in periods {
253                let friendly = period_service.format_period_friendly(&period);
254                let marker = if period_service.is_current(&period) {
255                    " <- current"
256                } else {
257                    ""
258                };
259                println!("  {} ({}){}", friendly, period, marker);
260            }
261        }
262
263        BudgetCommands::Prev => {
264            let current = period_service.current_period();
265            let prev = period_service.previous_period(&current);
266            let friendly = period_service.format_period_friendly(&prev);
267            println!("Previous period: {} ({})", friendly, prev);
268        }
269
270        BudgetCommands::Next => {
271            let current = period_service.current_period();
272            let next = period_service.next_period(&current);
273            let friendly = period_service.format_period_friendly(&next);
274            println!("Next period: {} ({})", friendly, next);
275        }
276
277        BudgetCommands::Assign {
278            category,
279            amount,
280            period,
281        } => {
282            let period = period_service.parse_or_current(period.as_deref())?;
283            let amount = crate::models::Money::parse(&amount).map_err(|e| {
284                crate::error::EnvelopeError::Validation(format!("Invalid amount: {}", e))
285            })?;
286
287            let category_service = CategoryService::new(storage);
288            let cat = category_service
289                .find_category(&category)?
290                .ok_or_else(|| crate::error::EnvelopeError::category_not_found(&category))?;
291
292            let budget_service = BudgetService::new(storage);
293            let allocation = budget_service.assign_to_category(cat.id, &period, amount)?;
294
295            println!(
296                "Assigned {} to '{}' for {}",
297                allocation.budgeted,
298                cat.name,
299                period_service.format_period_friendly(&period)
300            );
301
302            // Show updated Available to Budget
303            let atb = budget_service.get_available_to_budget(&period)?;
304            if atb.is_negative() {
305                println!("Warning: Overbudgeted! Available to Budget: {}", atb);
306            } else {
307                println!("Available to Budget: {}", atb);
308            }
309        }
310
311        BudgetCommands::Move {
312            from,
313            to,
314            amount,
315            period,
316        } => {
317            let period = period_service.parse_or_current(period.as_deref())?;
318            let amount = crate::models::Money::parse(&amount).map_err(|e| {
319                crate::error::EnvelopeError::Validation(format!("Invalid amount: {}", e))
320            })?;
321
322            let category_service = CategoryService::new(storage);
323            let from_cat = category_service
324                .find_category(&from)?
325                .ok_or_else(|| crate::error::EnvelopeError::category_not_found(&from))?;
326            let to_cat = category_service
327                .find_category(&to)?
328                .ok_or_else(|| crate::error::EnvelopeError::category_not_found(&to))?;
329
330            let budget_service = BudgetService::new(storage);
331            budget_service.move_between_categories(from_cat.id, to_cat.id, &period, amount)?;
332
333            println!(
334                "Moved {} from '{}' to '{}' for {}",
335                amount,
336                from_cat.name,
337                to_cat.name,
338                period_service.format_period_friendly(&period)
339            );
340        }
341
342        BudgetCommands::Rollover { period } => {
343            let period = period_service.parse_or_current(period.as_deref())?;
344            let friendly = period_service.format_period_friendly(&period);
345            let prev_period = period.prev();
346            let prev_friendly = period_service.format_period_friendly(&prev_period);
347
348            println!(
349                "Applying rollover from {} to {}...",
350                prev_friendly, friendly
351            );
352            println!();
353
354            let budget_service = BudgetService::new(storage);
355            let category_service = CategoryService::new(storage);
356            let allocations = budget_service.apply_rollover_all(&period)?;
357
358            let mut positive_count = 0;
359            let mut negative_count = 0;
360            let mut total_carryover = crate::models::Money::zero();
361
362            for alloc in &allocations {
363                if !alloc.carryover.is_zero() {
364                    let cat = category_service.get_category(alloc.category_id)?;
365                    let cat_name = cat.map(|c| c.name).unwrap_or_else(|| "Unknown".to_string());
366
367                    if alloc.carryover.is_positive() {
368                        println!("  {} +{} (surplus)", cat_name, alloc.carryover);
369                        positive_count += 1;
370                    } else {
371                        println!("  {} {} (deficit)", cat_name, alloc.carryover);
372                        negative_count += 1;
373                    }
374                    total_carryover += alloc.carryover;
375                }
376            }
377
378            println!();
379            if positive_count == 0 && negative_count == 0 {
380                println!("No carryover to apply (all categories had zero balance).");
381            } else {
382                println!(
383                    "Applied rollover to {} categories ({} surplus, {} deficit)",
384                    positive_count + negative_count,
385                    positive_count,
386                    negative_count
387                );
388                println!("Net carryover: {}", total_carryover);
389            }
390        }
391
392        BudgetCommands::Overspent { period } => {
393            let period = period_service.parse_or_current(period.as_deref())?;
394            let friendly = period_service.format_period_friendly(&period);
395
396            let budget_service = BudgetService::new(storage);
397            let category_service = CategoryService::new(storage);
398            let overspent = budget_service.get_overspent_categories(&period)?;
399
400            if overspent.is_empty() {
401                println!("No overspent categories for {}.", friendly);
402                println!("All categories are within budget!");
403            } else {
404                println!("Overspent Categories for {}:", friendly);
405                println!("{}", "-".repeat(50));
406                println!("{:30} {:>10} {:>10}", "Category", "Available", "Overspent");
407                println!("{}", "-".repeat(50));
408
409                let mut total_overspent = crate::models::Money::zero();
410
411                for summary in &overspent {
412                    let cat = category_service.get_category(summary.category_id)?;
413                    let cat_name = cat.map(|c| c.name).unwrap_or_else(|| "Unknown".to_string());
414                    let overspent_amount = summary.available.abs();
415
416                    println!(
417                        "{:30} {:>10} {:>10}",
418                        cat_name, summary.available, overspent_amount
419                    );
420
421                    total_overspent += overspent_amount;
422                }
423
424                println!("{}", "-".repeat(50));
425                println!("{:30} {:>10} {:>10}", "TOTAL", "", total_overspent);
426                println!();
427                println!(
428                    "⚠️  You have {} category/categories overspent by {} total.",
429                    overspent.len(),
430                    total_overspent
431                );
432                println!("Consider moving funds from other categories to cover the deficit.");
433            }
434        }
435    }
436
437    Ok(())
438}