envelope_cli/reports/
spending.rs

1//! Spending Report
2//!
3//! Generates spending analysis by category for a given date range.
4
5use crate::error::EnvelopeResult;
6use crate::models::{CategoryGroupId, CategoryId, Money};
7use crate::services::CategoryService;
8use crate::storage::Storage;
9use chrono::NaiveDate;
10use std::collections::HashMap;
11use std::io::Write;
12
13/// Spending breakdown by category
14#[derive(Debug, Clone)]
15pub struct SpendingByCategory {
16    /// Category ID
17    pub category_id: CategoryId,
18    /// Category name
19    pub category_name: String,
20    /// Group ID
21    pub group_id: CategoryGroupId,
22    /// Group name
23    pub group_name: String,
24    /// Total spending (negative value)
25    pub total_spending: Money,
26    /// Number of transactions
27    pub transaction_count: usize,
28    /// Percentage of total spending
29    pub percentage: f64,
30}
31
32/// Spending by group summary
33#[derive(Debug, Clone)]
34pub struct SpendingByGroup {
35    /// Group ID
36    pub group_id: CategoryGroupId,
37    /// Group name
38    pub group_name: String,
39    /// Categories in this group with spending
40    pub categories: Vec<SpendingByCategory>,
41    /// Total spending for this group
42    pub total_spending: Money,
43    /// Transaction count for this group
44    pub transaction_count: usize,
45    /// Percentage of total spending
46    pub percentage: f64,
47}
48
49/// Spending Report
50#[derive(Debug, Clone)]
51pub struct SpendingReport {
52    /// Start date of the report
53    pub start_date: NaiveDate,
54    /// End date of the report
55    pub end_date: NaiveDate,
56    /// Spending by group
57    pub groups: Vec<SpendingByGroup>,
58    /// Total spending across all categories
59    pub total_spending: Money,
60    /// Total income in the period
61    pub total_income: Money,
62    /// Total transaction count
63    pub total_transactions: usize,
64    /// Uncategorized spending
65    pub uncategorized_spending: Money,
66    /// Uncategorized transaction count
67    pub uncategorized_count: usize,
68}
69
70impl SpendingReport {
71    /// Generate a spending report for a date range
72    pub fn generate(
73        storage: &Storage,
74        start_date: NaiveDate,
75        end_date: NaiveDate,
76    ) -> EnvelopeResult<Self> {
77        let category_service = CategoryService::new(storage);
78        let groups = category_service.list_groups()?;
79        let categories = category_service.list_categories()?;
80
81        // Get transactions in date range
82        let transactions = storage
83            .transactions
84            .get_by_date_range(start_date, end_date)?;
85
86        // Build category lookup
87        let _category_map: HashMap<CategoryId, _> =
88            categories.iter().map(|c| (c.id, c.clone())).collect();
89
90        let _group_map: HashMap<CategoryGroupId, _> =
91            groups.iter().map(|g| (g.id, g.clone())).collect();
92
93        // Aggregate spending by category
94        let mut category_spending: HashMap<CategoryId, (Money, usize)> = HashMap::new();
95        let mut uncategorized_spending = Money::zero();
96        let mut uncategorized_count = 0;
97        let mut total_income = Money::zero();
98        let mut total_spending = Money::zero();
99
100        for txn in &transactions {
101            if txn.amount.is_positive() {
102                total_income += txn.amount;
103            } else if txn.is_split() {
104                // Handle split transactions
105                for split in &txn.splits {
106                    let entry = category_spending
107                        .entry(split.category_id)
108                        .or_insert((Money::zero(), 0));
109                    entry.0 += split.amount;
110                    entry.1 += 1;
111                    total_spending += split.amount;
112                }
113            } else if let Some(cat_id) = txn.category_id {
114                let entry = category_spending
115                    .entry(cat_id)
116                    .or_insert((Money::zero(), 0));
117                entry.0 += txn.amount;
118                entry.1 += 1;
119                total_spending += txn.amount;
120            } else if !txn.is_transfer() {
121                // Uncategorized (excluding transfers)
122                uncategorized_spending += txn.amount;
123                uncategorized_count += 1;
124                total_spending += txn.amount;
125            }
126        }
127
128        // Calculate total absolute spending for percentages
129        let total_abs_spending = total_spending.abs();
130
131        // Build report by group
132        let mut report_groups: Vec<SpendingByGroup> = Vec::new();
133
134        for group in &groups {
135            let mut group_spending = SpendingByGroup {
136                group_id: group.id,
137                group_name: group.name.clone(),
138                categories: Vec::new(),
139                total_spending: Money::zero(),
140                transaction_count: 0,
141                percentage: 0.0,
142            };
143
144            // Find categories in this group with spending
145            for category in categories.iter().filter(|c| c.group_id == group.id) {
146                if let Some((spending, count)) = category_spending.get(&category.id) {
147                    if !spending.is_zero() {
148                        let percentage = if total_abs_spending.is_zero() {
149                            0.0
150                        } else {
151                            (spending.abs().cents() as f64 / total_abs_spending.cents() as f64)
152                                * 100.0
153                        };
154
155                        let cat_spending = SpendingByCategory {
156                            category_id: category.id,
157                            category_name: category.name.clone(),
158                            group_id: group.id,
159                            group_name: group.name.clone(),
160                            total_spending: *spending,
161                            transaction_count: *count,
162                            percentage,
163                        };
164
165                        group_spending.total_spending += *spending;
166                        group_spending.transaction_count += *count;
167                        group_spending.categories.push(cat_spending);
168                    }
169                }
170            }
171
172            // Sort categories by spending (most spending first)
173            group_spending
174                .categories
175                .sort_by(|a, b| a.total_spending.cmp(&b.total_spending));
176
177            // Calculate group percentage
178            group_spending.percentage = if total_abs_spending.is_zero() {
179                0.0
180            } else {
181                (group_spending.total_spending.abs().cents() as f64
182                    / total_abs_spending.cents() as f64)
183                    * 100.0
184            };
185
186            // Only include groups with spending
187            if !group_spending.total_spending.is_zero() {
188                report_groups.push(group_spending);
189            }
190        }
191
192        // Sort groups by spending
193        report_groups.sort_by(|a, b| a.total_spending.cmp(&b.total_spending));
194
195        Ok(Self {
196            start_date,
197            end_date,
198            groups: report_groups,
199            total_spending,
200            total_income,
201            total_transactions: transactions.len(),
202            uncategorized_spending,
203            uncategorized_count,
204        })
205    }
206
207    /// Format the report for terminal display
208    pub fn format_terminal(&self) -> String {
209        let mut output = String::new();
210
211        // Header
212        output.push_str(&format!(
213            "Spending Report: {} to {}\n",
214            self.start_date, self.end_date
215        ));
216        output.push_str(&"=".repeat(80));
217        output.push('\n');
218        output.push_str(&format!("Total Spending: {}\n", self.total_spending.abs()));
219        output.push_str(&format!("Total Income: {}\n", self.total_income));
220        output.push_str(&format!(
221            "Total Transactions: {}\n\n",
222            self.total_transactions
223        ));
224
225        // Column headers
226        output.push_str(&format!(
227            "{:<35} {:>12} {:>8} {:>8}\n",
228            "Category", "Amount", "Count", "%"
229        ));
230        output.push_str(&"-".repeat(80));
231        output.push('\n');
232
233        // Groups and categories
234        for group in &self.groups {
235            // Group header
236            output.push_str(&format!(
237                "\n{} ({:.1}%)\n",
238                group.group_name.to_uppercase(),
239                group.percentage
240            ));
241
242            for category in &group.categories {
243                output.push_str(&format!(
244                    "  {:<33} {:>12} {:>8} {:>7.1}%\n",
245                    category.category_name,
246                    category.total_spending.abs(),
247                    category.transaction_count,
248                    category.percentage
249                ));
250            }
251
252            // Group total
253            output.push_str(&format!(
254                "  {:<33} {:>12} {:>8}\n",
255                "Group Total:",
256                group.total_spending.abs(),
257                group.transaction_count
258            ));
259        }
260
261        // Uncategorized
262        if !self.uncategorized_spending.is_zero() {
263            output.push_str(&format!(
264                "\n{:<35} {:>12} {:>8}\n",
265                "UNCATEGORIZED",
266                self.uncategorized_spending.abs(),
267                self.uncategorized_count
268            ));
269        }
270
271        // Grand total
272        output.push_str(&"-".repeat(80));
273        output.push('\n');
274        output.push_str(&format!(
275            "{:<35} {:>12} {:>8}\n",
276            "TOTAL SPENDING",
277            self.total_spending.abs(),
278            self.total_transactions
279        ));
280
281        output
282    }
283
284    /// Export the report to CSV format
285    pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
286        // Write header
287        writeln!(
288            writer,
289            "Start Date,End Date,Group,Category,Amount,Transaction Count,Percentage"
290        )
291        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
292
293        // Write data rows
294        for group in &self.groups {
295            for category in &group.categories {
296                writeln!(
297                    writer,
298                    "{},{},{},{},{:.2},{},{:.2}",
299                    self.start_date,
300                    self.end_date,
301                    group.group_name,
302                    category.category_name,
303                    category.total_spending.abs().cents() as f64 / 100.0,
304                    category.transaction_count,
305                    category.percentage
306                )
307                .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
308            }
309        }
310
311        // Uncategorized
312        if !self.uncategorized_spending.is_zero() {
313            writeln!(
314                writer,
315                "{},{},UNCATEGORIZED,,{:.2},{},",
316                self.start_date,
317                self.end_date,
318                self.uncategorized_spending.abs().cents() as f64 / 100.0,
319                self.uncategorized_count
320            )
321            .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
322        }
323
324        // Total row
325        writeln!(
326            writer,
327            "{},{},TOTAL,,{:.2},{},100.00",
328            self.start_date,
329            self.end_date,
330            self.total_spending.abs().cents() as f64 / 100.0,
331            self.total_transactions
332        )
333        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
334
335        Ok(())
336    }
337
338    /// Get top spending categories
339    pub fn top_categories(&self, limit: usize) -> Vec<&SpendingByCategory> {
340        let mut all_categories: Vec<_> = self.groups.iter().flat_map(|g| &g.categories).collect();
341
342        // Sort by spending (most spending first - remember spending is negative)
343        all_categories.sort_by(|a, b| a.total_spending.cmp(&b.total_spending));
344
345        all_categories.into_iter().take(limit).collect()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::config::paths::EnvelopePaths;
353    use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
354    use tempfile::TempDir;
355
356    fn create_test_storage() -> (TempDir, Storage) {
357        let temp_dir = TempDir::new().unwrap();
358        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
359        let mut storage = Storage::new(paths).unwrap();
360        storage.load_all().unwrap();
361        (temp_dir, storage)
362    }
363
364    #[test]
365    fn test_generate_spending_report() {
366        let (_temp_dir, storage) = create_test_storage();
367
368        // Create test data
369        let group = CategoryGroup::new("Test Group");
370        storage.categories.upsert_group(group.clone()).unwrap();
371
372        let cat1 = Category::new("Groceries", group.id);
373        let cat2 = Category::new("Dining Out", group.id);
374        storage.categories.upsert_category(cat1.clone()).unwrap();
375        storage.categories.upsert_category(cat2.clone()).unwrap();
376        storage.categories.save().unwrap();
377
378        let account = Account::new("Checking", AccountType::Checking);
379        storage.accounts.upsert(account.clone()).unwrap();
380        storage.accounts.save().unwrap();
381
382        // Add transactions
383        let mut txn1 = Transaction::new(
384            account.id,
385            NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
386            Money::from_cents(-5000),
387        );
388        txn1.category_id = Some(cat1.id);
389        storage.transactions.upsert(txn1).unwrap();
390
391        let mut txn2 = Transaction::new(
392            account.id,
393            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
394            Money::from_cents(-3000),
395        );
396        txn2.category_id = Some(cat2.id);
397        storage.transactions.upsert(txn2).unwrap();
398
399        // Income transaction
400        let txn3 = Transaction::new(
401            account.id,
402            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
403            Money::from_cents(200000),
404        );
405        storage.transactions.upsert(txn3).unwrap();
406
407        // Generate report
408        let report = SpendingReport::generate(
409            &storage,
410            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
411            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
412        )
413        .unwrap();
414
415        assert_eq!(report.total_spending.cents(), -8000);
416        assert_eq!(report.total_income.cents(), 200000);
417        assert_eq!(report.groups.len(), 1);
418        assert_eq!(report.groups[0].categories.len(), 2);
419    }
420
421    #[test]
422    fn test_top_categories() {
423        let (_temp_dir, storage) = create_test_storage();
424
425        // Setup with multiple categories
426        let group = CategoryGroup::new("Test");
427        storage.categories.upsert_group(group.clone()).unwrap();
428
429        let cats: Vec<_> = (0..5)
430            .map(|i| {
431                let cat = Category::new(format!("Category {}", i), group.id);
432                storage.categories.upsert_category(cat.clone()).unwrap();
433                cat
434            })
435            .collect();
436        storage.categories.save().unwrap();
437
438        let account = Account::new("Checking", AccountType::Checking);
439        storage.accounts.upsert(account.clone()).unwrap();
440
441        // Add varying spending amounts
442        for (i, cat) in cats.iter().enumerate() {
443            let mut txn = Transaction::new(
444                account.id,
445                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
446                Money::from_cents(-((i + 1) as i64 * 1000)),
447            );
448            txn.category_id = Some(cat.id);
449            storage.transactions.upsert(txn).unwrap();
450        }
451
452        let report = SpendingReport::generate(
453            &storage,
454            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
455            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
456        )
457        .unwrap();
458
459        let top = report.top_categories(3);
460        assert_eq!(top.len(), 3);
461        // Should be sorted by spending (highest spending first)
462        assert!(top[0].total_spending.cents() <= top[1].total_spending.cents());
463    }
464}