1use 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#[derive(Debug, Clone)]
15pub struct SpendingByCategory {
16 pub category_id: CategoryId,
18 pub category_name: String,
20 pub group_id: CategoryGroupId,
22 pub group_name: String,
24 pub total_spending: Money,
26 pub transaction_count: usize,
28 pub percentage: f64,
30}
31
32#[derive(Debug, Clone)]
34pub struct SpendingByGroup {
35 pub group_id: CategoryGroupId,
37 pub group_name: String,
39 pub categories: Vec<SpendingByCategory>,
41 pub total_spending: Money,
43 pub transaction_count: usize,
45 pub percentage: f64,
47}
48
49#[derive(Debug, Clone)]
51pub struct SpendingReport {
52 pub start_date: NaiveDate,
54 pub end_date: NaiveDate,
56 pub groups: Vec<SpendingByGroup>,
58 pub total_spending: Money,
60 pub total_income: Money,
62 pub total_transactions: usize,
64 pub uncategorized_spending: Money,
66 pub uncategorized_count: usize,
68}
69
70impl SpendingReport {
71 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 let transactions = storage
83 .transactions
84 .get_by_date_range(start_date, end_date)?;
85
86 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 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 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_spending += txn.amount;
123 uncategorized_count += 1;
124 total_spending += txn.amount;
125 }
126 }
127
128 let total_abs_spending = total_spending.abs();
130
131 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 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 group_spending
174 .categories
175 .sort_by(|a, b| a.total_spending.cmp(&b.total_spending));
176
177 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 if !group_spending.total_spending.is_zero() {
188 report_groups.push(group_spending);
189 }
190 }
191
192 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 pub fn format_terminal(&self) -> String {
209 let mut output = String::new();
210
211 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 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 for group in &self.groups {
235 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 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 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 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 pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
286 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 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 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 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 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 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 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 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 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 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 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 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 assert!(top[0].total_spending.cents() <= top[1].total_spending.cents());
463 }
464}