envelope_cli/cli/
report.rs

1//! CLI commands for reports
2//!
3//! Provides commands for generating and exporting various financial reports.
4
5use crate::error::EnvelopeResult;
6use crate::models::BudgetPeriod;
7use crate::reports::{
8    AccountRegisterReport, BudgetOverviewReport, NetWorthReport, RegisterFilter, SpendingReport,
9};
10use crate::services::AccountService;
11use crate::storage::Storage;
12use chrono::NaiveDate;
13use clap::Subcommand;
14use std::fs::File;
15use std::io::BufWriter;
16use std::path::PathBuf;
17
18/// Report subcommands
19#[derive(Subcommand, Debug)]
20pub enum ReportCommands {
21    /// Generate a budget overview report
22    #[command(alias = "budget-overview")]
23    Budget {
24        /// Budget period (e.g., "2025-01" for January 2025)
25        #[arg(short, long)]
26        period: Option<String>,
27
28        /// Export to CSV file
29        #[arg(short, long)]
30        output: Option<PathBuf>,
31    },
32
33    /// Generate a spending report by category
34    Spending {
35        /// Start date (YYYY-MM-DD)
36        #[arg(short, long)]
37        start: Option<String>,
38
39        /// End date (YYYY-MM-DD)
40        #[arg(short, long)]
41        end: Option<String>,
42
43        /// Budget period to report on (alternative to start/end)
44        #[arg(short, long)]
45        period: Option<String>,
46
47        /// Export to CSV file
48        #[arg(short, long)]
49        output: Option<PathBuf>,
50
51        /// Show top N categories only
52        #[arg(long)]
53        top: Option<usize>,
54    },
55
56    /// Generate an account register report
57    #[command(alias = "transactions")]
58    Register {
59        /// Account name or ID
60        account: String,
61
62        /// Start date (YYYY-MM-DD)
63        #[arg(short, long)]
64        start: Option<String>,
65
66        /// End date (YYYY-MM-DD)
67        #[arg(short, long)]
68        end: Option<String>,
69
70        /// Filter by payee (partial match)
71        #[arg(long)]
72        payee: Option<String>,
73
74        /// Show only uncategorized transactions
75        #[arg(long)]
76        uncategorized: bool,
77
78        /// Export to CSV file
79        #[arg(short, long)]
80        output: Option<PathBuf>,
81    },
82
83    /// Generate a net worth report
84    #[command(alias = "networth")]
85    NetWorth {
86        /// Include archived accounts
87        #[arg(short, long)]
88        all: bool,
89
90        /// Export to CSV file
91        #[arg(short, long)]
92        output: Option<PathBuf>,
93    },
94}
95
96/// Handle report commands
97pub fn handle_report_command(storage: &Storage, cmd: ReportCommands) -> EnvelopeResult<()> {
98    match cmd {
99        ReportCommands::Budget { period, output } => handle_budget_report(storage, period, output),
100        ReportCommands::Spending {
101            start,
102            end,
103            period,
104            output,
105            top,
106        } => handle_spending_report(storage, start, end, period, output, top),
107        ReportCommands::Register {
108            account,
109            start,
110            end,
111            payee,
112            uncategorized,
113            output,
114        } => handle_register_report(storage, account, start, end, payee, uncategorized, output),
115        ReportCommands::NetWorth { all, output } => handle_net_worth_report(storage, all, output),
116    }
117}
118
119/// Handle budget overview report
120fn handle_budget_report(
121    storage: &Storage,
122    period: Option<String>,
123    output: Option<PathBuf>,
124) -> EnvelopeResult<()> {
125    // Parse period or use current
126    let budget_period = if let Some(period_str) = period {
127        BudgetPeriod::parse(&period_str).map_err(|e| {
128            crate::error::EnvelopeError::Validation(format!(
129                "Invalid period format: {}. Use YYYY-MM (e.g., 2025-01)",
130                e
131            ))
132        })?
133    } else {
134        BudgetPeriod::current_month()
135    };
136
137    // Generate report
138    let report = BudgetOverviewReport::generate(storage, &budget_period)?;
139
140    // Output
141    if let Some(path) = output {
142        let file = File::create(&path).map_err(|e| {
143            crate::error::EnvelopeError::Export(format!(
144                "Failed to create file {}: {}",
145                path.display(),
146                e
147            ))
148        })?;
149        let mut writer = BufWriter::new(file);
150        report.export_csv(&mut writer)?;
151        println!("Budget report exported to: {}", path.display());
152    } else {
153        println!("{}", report.format_terminal());
154    }
155
156    Ok(())
157}
158
159/// Handle spending report
160fn handle_spending_report(
161    storage: &Storage,
162    start: Option<String>,
163    end: Option<String>,
164    period: Option<String>,
165    output: Option<PathBuf>,
166    top: Option<usize>,
167) -> EnvelopeResult<()> {
168    // Determine date range
169    let (start_date, end_date) = if let Some(period_str) = period {
170        let budget_period = BudgetPeriod::parse(&period_str).map_err(|e| {
171            crate::error::EnvelopeError::Validation(format!(
172                "Invalid period format: {}. Use YYYY-MM (e.g., 2025-01)",
173                e
174            ))
175        })?;
176        (budget_period.start_date(), budget_period.end_date())
177    } else {
178        let start_date = if let Some(s) = start {
179            NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
180                crate::error::EnvelopeError::Validation(format!(
181                    "Invalid start date format: {}. Use YYYY-MM-DD",
182                    s
183                ))
184            })?
185        } else {
186            // Default to start of current month
187            let today = chrono::Local::now().date_naive();
188            NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today)
189        };
190
191        let end_date = if let Some(e) = end {
192            NaiveDate::parse_from_str(&e, "%Y-%m-%d").map_err(|_| {
193                crate::error::EnvelopeError::Validation(format!(
194                    "Invalid end date format: {}. Use YYYY-MM-DD",
195                    e
196                ))
197            })?
198        } else {
199            // Default to today
200            chrono::Local::now().date_naive()
201        };
202
203        (start_date, end_date)
204    };
205
206    // Generate report
207    let report = SpendingReport::generate(storage, start_date, end_date)?;
208
209    // Output
210    if let Some(path) = output {
211        let file = File::create(&path).map_err(|e| {
212            crate::error::EnvelopeError::Export(format!(
213                "Failed to create file {}: {}",
214                path.display(),
215                e
216            ))
217        })?;
218        let mut writer = BufWriter::new(file);
219        report.export_csv(&mut writer)?;
220        println!("Spending report exported to: {}", path.display());
221    } else if let Some(n) = top {
222        // Show top N categories only
223        println!(
224            "Top {} Spending Categories: {} to {}\n",
225            n, start_date, end_date
226        );
227        println!("{:<35} {:>12} {:>8}", "Category", "Amount", "%");
228        println!("{}", "-".repeat(60));
229
230        for cat in report.top_categories(n) {
231            println!(
232                "{:<35} {:>12} {:>7.1}%",
233                cat.category_name,
234                cat.total_spending.abs(),
235                cat.percentage
236            );
237        }
238        println!("\nTotal Spending: {}", report.total_spending.abs());
239    } else {
240        println!("{}", report.format_terminal());
241    }
242
243    Ok(())
244}
245
246/// Handle account register report
247fn handle_register_report(
248    storage: &Storage,
249    account: String,
250    start: Option<String>,
251    end: Option<String>,
252    payee: Option<String>,
253    uncategorized: bool,
254    output: Option<PathBuf>,
255) -> EnvelopeResult<()> {
256    let account_service = AccountService::new(storage);
257
258    // Find account
259    let account = account_service
260        .find(&account)?
261        .ok_or_else(|| crate::error::EnvelopeError::account_not_found(&account))?;
262
263    // Build filter
264    let filter = RegisterFilter {
265        start_date: start
266            .map(|s| {
267                NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
268                    crate::error::EnvelopeError::Validation(format!(
269                        "Invalid start date format: {}. Use YYYY-MM-DD",
270                        s
271                    ))
272                })
273            })
274            .transpose()?,
275        end_date: end
276            .map(|s| {
277                NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
278                    crate::error::EnvelopeError::Validation(format!(
279                        "Invalid end date format: {}. Use YYYY-MM-DD",
280                        s
281                    ))
282                })
283            })
284            .transpose()?,
285        payee_contains: payee,
286        uncategorized_only: uncategorized,
287        ..Default::default()
288    };
289
290    // Generate report
291    let report = AccountRegisterReport::generate(storage, account.id, filter)?;
292
293    // Output
294    if let Some(path) = output {
295        let file = File::create(&path).map_err(|e| {
296            crate::error::EnvelopeError::Export(format!(
297                "Failed to create file {}: {}",
298                path.display(),
299                e
300            ))
301        })?;
302        let mut writer = BufWriter::new(file);
303        report.export_csv(&mut writer)?;
304        println!("Register report exported to: {}", path.display());
305    } else {
306        println!("{}", report.format_terminal());
307    }
308
309    Ok(())
310}
311
312/// Handle net worth report
313fn handle_net_worth_report(
314    storage: &Storage,
315    include_archived: bool,
316    output: Option<PathBuf>,
317) -> EnvelopeResult<()> {
318    // Generate report
319    let report = NetWorthReport::generate(storage, include_archived)?;
320
321    // Output
322    if let Some(path) = output {
323        let file = File::create(&path).map_err(|e| {
324            crate::error::EnvelopeError::Export(format!(
325                "Failed to create file {}: {}",
326                path.display(),
327                e
328            ))
329        })?;
330        let mut writer = BufWriter::new(file);
331        report.export_csv(&mut writer)?;
332        println!("Net worth report exported to: {}", path.display());
333    } else {
334        println!("{}", report.format_terminal());
335    }
336
337    Ok(())
338}
339
340use chrono::Datelike;