1use clap::Subcommand;
7
8use crate::config::settings::Settings;
9use crate::error::EnvelopeResult;
10use crate::services::{BudgetService, CategoryService, PeriodService};
11use crate::storage::Storage;
12
13#[derive(Subcommand)]
15pub enum BudgetCommands {
16 Overview {
18 #[arg(short, long)]
20 period: Option<String>,
21 },
22
23 Period {
25 period: Option<String>,
27 },
28
29 Periods {
31 #[arg(short, long, default_value = "6")]
33 count: usize,
34 },
35
36 Prev,
38
39 Next,
41
42 Assign {
45 category: String,
47 amount: String,
49 #[arg(short, long)]
51 period: Option<String>,
52 },
53
54 Move {
56 from: String,
58 to: String,
60 amount: String,
62 #[arg(short, long)]
64 period: Option<String>,
65 },
66
67 Rollover {
69 #[arg(short, long)]
71 period: Option<String>,
72 },
73
74 Overspent {
76 #[arg(short, long)]
78 period: Option<String>,
79 },
80}
81
82pub 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 let category_service = CategoryService::new(storage);
100 let groups = category_service.list_groups_with_categories()?;
101
102 let budget_service = BudgetService::new(storage);
104
105 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 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 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 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(¤t);
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(¤t);
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 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}