1use crate::error::EnvelopeResult;
7use crate::models::{BudgetPeriod, CategoryGroupId, CategoryId, Money};
8use crate::services::{BudgetService, CategoryService};
9use crate::storage::Storage;
10use std::io::Write;
11
12#[derive(Debug, Clone)]
14pub struct CategoryReportRow {
15 pub category_id: CategoryId,
17 pub category_name: String,
19 pub group_id: CategoryGroupId,
21 pub budgeted: Money,
23 pub carryover: Money,
25 pub activity: Money,
27 pub available: Money,
29}
30
31impl CategoryReportRow {
32 pub fn is_overspent(&self) -> bool {
34 self.available.is_negative()
35 }
36}
37
38#[derive(Debug, Clone)]
40pub struct GroupReportRow {
41 pub group_id: CategoryGroupId,
43 pub group_name: String,
45 pub categories: Vec<CategoryReportRow>,
47 pub total_budgeted: Money,
49 pub total_carryover: Money,
51 pub total_activity: Money,
53 pub total_available: Money,
55}
56
57impl GroupReportRow {
58 pub fn new(group_id: CategoryGroupId, group_name: String) -> Self {
60 Self {
61 group_id,
62 group_name,
63 categories: Vec::new(),
64 total_budgeted: Money::zero(),
65 total_carryover: Money::zero(),
66 total_activity: Money::zero(),
67 total_available: Money::zero(),
68 }
69 }
70
71 pub fn add_category(&mut self, category: CategoryReportRow) {
73 self.total_budgeted += category.budgeted;
74 self.total_carryover += category.carryover;
75 self.total_activity += category.activity;
76 self.total_available += category.available;
77 self.categories.push(category);
78 }
79
80 pub fn has_overspent(&self) -> bool {
82 self.categories.iter().any(|c| c.is_overspent())
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct BudgetOverviewReport {
89 pub period: BudgetPeriod,
91 pub groups: Vec<GroupReportRow>,
93 pub grand_total_budgeted: Money,
95 pub grand_total_carryover: Money,
97 pub grand_total_activity: Money,
99 pub grand_total_available: Money,
101 pub available_to_budget: Money,
103}
104
105impl BudgetOverviewReport {
106 pub fn generate(storage: &Storage, period: &BudgetPeriod) -> EnvelopeResult<Self> {
108 let budget_service = BudgetService::new(storage);
109 let category_service = CategoryService::new(storage);
110
111 let groups = category_service.list_groups()?;
113 let categories = category_service.list_categories()?;
114
115 let mut report_groups: Vec<GroupReportRow> = Vec::new();
116 let mut grand_total_budgeted = Money::zero();
117 let mut grand_total_carryover = Money::zero();
118 let mut grand_total_activity = Money::zero();
119 let mut grand_total_available = Money::zero();
120
121 for group in &groups {
123 let mut group_row = GroupReportRow::new(group.id, group.name.clone());
124
125 for category in categories.iter().filter(|c| c.group_id == group.id) {
127 let summary = budget_service.get_category_summary(category.id, period)?;
128
129 let category_row = CategoryReportRow {
130 category_id: category.id,
131 category_name: category.name.clone(),
132 group_id: group.id,
133 budgeted: summary.budgeted,
134 carryover: summary.carryover,
135 activity: summary.activity,
136 available: summary.available,
137 };
138
139 group_row.add_category(category_row);
140 }
141
142 grand_total_budgeted += group_row.total_budgeted;
144 grand_total_carryover += group_row.total_carryover;
145 grand_total_activity += group_row.total_activity;
146 grand_total_available += group_row.total_available;
147
148 report_groups.push(group_row);
149 }
150
151 let available_to_budget = budget_service.get_available_to_budget(period)?;
153
154 Ok(Self {
155 period: period.clone(),
156 groups: report_groups,
157 grand_total_budgeted,
158 grand_total_carryover,
159 grand_total_activity,
160 grand_total_available,
161 available_to_budget,
162 })
163 }
164
165 pub fn format_terminal(&self) -> String {
167 let mut output = String::new();
168
169 output.push_str(&format!("Budget Overview - {}\n", self.period));
171 output.push_str(&"=".repeat(80));
172 output.push('\n');
173 output.push_str(&format!(
174 "Available to Budget: {}\n\n",
175 self.available_to_budget
176 ));
177
178 output.push_str(&format!(
180 "{:<30} {:>12} {:>12} {:>12}\n",
181 "Category", "Budgeted", "Activity", "Available"
182 ));
183 output.push_str(&"-".repeat(80));
184 output.push('\n');
185
186 for group in &self.groups {
188 output.push_str(&format!("\n{}\n", group.group_name.to_uppercase()));
190
191 for category in &group.categories {
192 let available_display = if category.is_overspent() {
193 format!("{} *", category.available)
194 } else {
195 category.available.to_string()
196 };
197
198 output.push_str(&format!(
199 " {:<28} {:>12} {:>12} {:>12}\n",
200 category.category_name, category.budgeted, category.activity, available_display
201 ));
202 }
203
204 output.push_str(&format!(
206 " {:<28} {:>12} {:>12} {:>12}\n",
207 "Group Total:", group.total_budgeted, group.total_activity, group.total_available
208 ));
209 }
210
211 output.push_str(&"-".repeat(80));
213 output.push('\n');
214 output.push_str(&format!(
215 "{:<30} {:>12} {:>12} {:>12}\n",
216 "GRAND TOTAL",
217 self.grand_total_budgeted,
218 self.grand_total_activity,
219 self.grand_total_available
220 ));
221
222 output.push_str("\n* = Overspent\n");
223
224 output
225 }
226
227 pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
229 writeln!(
231 writer,
232 "Period,Group,Category,Budgeted,Carryover,Activity,Available"
233 )
234 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
235
236 for group in &self.groups {
238 for category in &group.categories {
239 writeln!(
240 writer,
241 "{},{},{},{:.2},{:.2},{:.2},{:.2}",
242 self.period,
243 group.group_name,
244 category.category_name,
245 category.budgeted.cents() as f64 / 100.0,
246 category.carryover.cents() as f64 / 100.0,
247 category.activity.cents() as f64 / 100.0,
248 category.available.cents() as f64 / 100.0,
249 )
250 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
251 }
252
253 writeln!(
255 writer,
256 "{},{},TOTAL,{:.2},{:.2},{:.2},{:.2}",
257 self.period,
258 group.group_name,
259 group.total_budgeted.cents() as f64 / 100.0,
260 group.total_carryover.cents() as f64 / 100.0,
261 group.total_activity.cents() as f64 / 100.0,
262 group.total_available.cents() as f64 / 100.0,
263 )
264 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
265 }
266
267 writeln!(
269 writer,
270 "{},GRAND TOTAL,,{:.2},{:.2},{:.2},{:.2}",
271 self.period,
272 self.grand_total_budgeted.cents() as f64 / 100.0,
273 self.grand_total_carryover.cents() as f64 / 100.0,
274 self.grand_total_activity.cents() as f64 / 100.0,
275 self.grand_total_available.cents() as f64 / 100.0,
276 )
277 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
278
279 Ok(())
280 }
281
282 pub fn overspent_count(&self) -> usize {
284 self.groups
285 .iter()
286 .flat_map(|g| &g.categories)
287 .filter(|c| c.is_overspent())
288 .count()
289 }
290
291 pub fn overspent_categories(&self) -> Vec<&CategoryReportRow> {
293 self.groups
294 .iter()
295 .flat_map(|g| &g.categories)
296 .filter(|c| c.is_overspent())
297 .collect()
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::config::paths::EnvelopePaths;
305 use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
306 use chrono::NaiveDate;
307 use tempfile::TempDir;
308
309 fn create_test_storage() -> (TempDir, Storage) {
310 let temp_dir = TempDir::new().unwrap();
311 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
312 let mut storage = Storage::new(paths).unwrap();
313 storage.load_all().unwrap();
314 (temp_dir, storage)
315 }
316
317 fn setup_test_data(storage: &Storage) -> BudgetPeriod {
318 let group = CategoryGroup::new("Test Group");
320 storage.categories.upsert_group(group.clone()).unwrap();
321
322 let cat1 = Category::new("Groceries", group.id);
324 let cat2 = Category::new("Dining Out", group.id);
325 storage.categories.upsert_category(cat1.clone()).unwrap();
326 storage.categories.upsert_category(cat2.clone()).unwrap();
327 storage.categories.save().unwrap();
328
329 let account = Account::with_starting_balance(
331 "Checking",
332 AccountType::Checking,
333 Money::from_cents(100000),
334 );
335 storage.accounts.upsert(account.clone()).unwrap();
336 storage.accounts.save().unwrap();
337
338 let period = BudgetPeriod::monthly(2025, 1);
340 let budget_service = BudgetService::new(storage);
341 budget_service
342 .assign_to_category(cat1.id, &period, Money::from_cents(50000))
343 .unwrap();
344 budget_service
345 .assign_to_category(cat2.id, &period, Money::from_cents(20000))
346 .unwrap();
347
348 let mut txn = Transaction::new(
350 account.id,
351 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
352 Money::from_cents(-3000),
353 );
354 txn.category_id = Some(cat1.id);
355 storage.transactions.upsert(txn).unwrap();
356
357 period
358 }
359
360 #[test]
361 fn test_generate_report() {
362 let (_temp_dir, storage) = create_test_storage();
363 let period = setup_test_data(&storage);
364
365 let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
366
367 assert_eq!(report.period, period);
368 assert_eq!(report.groups.len(), 1);
369 assert_eq!(report.groups[0].categories.len(), 2);
370 assert_eq!(report.grand_total_budgeted.cents(), 70000);
371 }
372
373 #[test]
374 fn test_csv_export() {
375 let (_temp_dir, storage) = create_test_storage();
376 let period = setup_test_data(&storage);
377
378 let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
379
380 let mut csv_output = Vec::new();
381 report.export_csv(&mut csv_output).unwrap();
382
383 let csv_string = String::from_utf8(csv_output).unwrap();
384 assert!(csv_string.contains("Period,Group,Category,Budgeted,Carryover,Activity,Available"));
385 assert!(csv_string.contains("Groceries"));
386 assert!(csv_string.contains("Dining Out"));
387 }
388
389 #[test]
390 fn test_terminal_format() {
391 let (_temp_dir, storage) = create_test_storage();
392 let period = setup_test_data(&storage);
393
394 let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
395 let output = report.format_terminal();
396
397 assert!(output.contains("Budget Overview"));
398 assert!(output.contains("Groceries"));
399 assert!(output.contains("GRAND TOTAL"));
400 }
401}