use crate::error::EnvelopeResult;
use crate::models::{BudgetPeriod, CategoryGroupId, CategoryId, Money};
use crate::services::{BudgetService, CategoryService};
use crate::storage::Storage;
use std::io::Write;
#[derive(Debug, Clone)]
pub struct CategoryReportRow {
pub category_id: CategoryId,
pub category_name: String,
pub group_id: CategoryGroupId,
pub budgeted: Money,
pub carryover: Money,
pub activity: Money,
pub available: Money,
}
impl CategoryReportRow {
pub fn is_overspent(&self) -> bool {
self.available.is_negative()
}
}
#[derive(Debug, Clone)]
pub struct GroupReportRow {
pub group_id: CategoryGroupId,
pub group_name: String,
pub categories: Vec<CategoryReportRow>,
pub total_budgeted: Money,
pub total_carryover: Money,
pub total_activity: Money,
pub total_available: Money,
}
impl GroupReportRow {
pub fn new(group_id: CategoryGroupId, group_name: String) -> Self {
Self {
group_id,
group_name,
categories: Vec::new(),
total_budgeted: Money::zero(),
total_carryover: Money::zero(),
total_activity: Money::zero(),
total_available: Money::zero(),
}
}
pub fn add_category(&mut self, category: CategoryReportRow) {
self.total_budgeted += category.budgeted;
self.total_carryover += category.carryover;
self.total_activity += category.activity;
self.total_available += category.available;
self.categories.push(category);
}
pub fn has_overspent(&self) -> bool {
self.categories.iter().any(|c| c.is_overspent())
}
}
#[derive(Debug, Clone)]
pub struct BudgetOverviewReport {
pub period: BudgetPeriod,
pub groups: Vec<GroupReportRow>,
pub grand_total_budgeted: Money,
pub grand_total_carryover: Money,
pub grand_total_activity: Money,
pub grand_total_available: Money,
pub available_to_budget: Money,
}
impl BudgetOverviewReport {
pub fn generate(storage: &Storage, period: &BudgetPeriod) -> EnvelopeResult<Self> {
let budget_service = BudgetService::new(storage);
let category_service = CategoryService::new(storage);
let groups = category_service.list_groups()?;
let categories = category_service.list_categories()?;
let mut report_groups: Vec<GroupReportRow> = Vec::new();
let mut grand_total_budgeted = Money::zero();
let mut grand_total_carryover = Money::zero();
let mut grand_total_activity = Money::zero();
let mut grand_total_available = Money::zero();
for group in &groups {
let mut group_row = GroupReportRow::new(group.id, group.name.clone());
for category in categories.iter().filter(|c| c.group_id == group.id) {
let summary = budget_service.get_category_summary(category.id, period)?;
let category_row = CategoryReportRow {
category_id: category.id,
category_name: category.name.clone(),
group_id: group.id,
budgeted: summary.budgeted,
carryover: summary.carryover,
activity: summary.activity,
available: summary.available,
};
group_row.add_category(category_row);
}
grand_total_budgeted += group_row.total_budgeted;
grand_total_carryover += group_row.total_carryover;
grand_total_activity += group_row.total_activity;
grand_total_available += group_row.total_available;
report_groups.push(group_row);
}
let available_to_budget = budget_service.get_available_to_budget(period)?;
Ok(Self {
period: period.clone(),
groups: report_groups,
grand_total_budgeted,
grand_total_carryover,
grand_total_activity,
grand_total_available,
available_to_budget,
})
}
pub fn format_terminal(&self) -> String {
let mut output = String::new();
output.push_str(&format!("Budget Overview - {}\n", self.period));
output.push_str(&"=".repeat(80));
output.push('\n');
output.push_str(&format!(
"Available to Budget: {}\n\n",
self.available_to_budget
));
output.push_str(&format!(
"{:<30} {:>12} {:>12} {:>12}\n",
"Category", "Budgeted", "Activity", "Available"
));
output.push_str(&"-".repeat(80));
output.push('\n');
for group in &self.groups {
output.push_str(&format!("\n{}\n", group.group_name.to_uppercase()));
for category in &group.categories {
let available_display = if category.is_overspent() {
format!("{} *", category.available)
} else {
category.available.to_string()
};
output.push_str(&format!(
" {:<28} {:>12} {:>12} {:>12}\n",
category.category_name, category.budgeted, category.activity, available_display
));
}
output.push_str(&format!(
" {:<28} {:>12} {:>12} {:>12}\n",
"Group Total:", group.total_budgeted, group.total_activity, group.total_available
));
}
output.push_str(&"-".repeat(80));
output.push('\n');
output.push_str(&format!(
"{:<30} {:>12} {:>12} {:>12}\n",
"GRAND TOTAL",
self.grand_total_budgeted,
self.grand_total_activity,
self.grand_total_available
));
output.push_str("\n* = Overspent\n");
output
}
pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
writeln!(
writer,
"Period,Group,Category,Budgeted,Carryover,Activity,Available"
)
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
for group in &self.groups {
for category in &group.categories {
writeln!(
writer,
"{},{},{},{:.2},{:.2},{:.2},{:.2}",
self.period,
group.group_name,
category.category_name,
category.budgeted.cents() as f64 / 100.0,
category.carryover.cents() as f64 / 100.0,
category.activity.cents() as f64 / 100.0,
category.available.cents() as f64 / 100.0,
)
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
}
writeln!(
writer,
"{},{},TOTAL,{:.2},{:.2},{:.2},{:.2}",
self.period,
group.group_name,
group.total_budgeted.cents() as f64 / 100.0,
group.total_carryover.cents() as f64 / 100.0,
group.total_activity.cents() as f64 / 100.0,
group.total_available.cents() as f64 / 100.0,
)
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
}
writeln!(
writer,
"{},GRAND TOTAL,,{:.2},{:.2},{:.2},{:.2}",
self.period,
self.grand_total_budgeted.cents() as f64 / 100.0,
self.grand_total_carryover.cents() as f64 / 100.0,
self.grand_total_activity.cents() as f64 / 100.0,
self.grand_total_available.cents() as f64 / 100.0,
)
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
Ok(())
}
pub fn overspent_count(&self) -> usize {
self.groups
.iter()
.flat_map(|g| &g.categories)
.filter(|c| c.is_overspent())
.count()
}
pub fn overspent_categories(&self) -> Vec<&CategoryReportRow> {
self.groups
.iter()
.flat_map(|g| &g.categories)
.filter(|c| c.is_overspent())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::paths::EnvelopePaths;
use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
use chrono::NaiveDate;
use tempfile::TempDir;
fn create_test_storage() -> (TempDir, Storage) {
let temp_dir = TempDir::new().unwrap();
let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
let mut storage = Storage::new(paths).unwrap();
storage.load_all().unwrap();
(temp_dir, storage)
}
fn setup_test_data(storage: &Storage) -> BudgetPeriod {
let group = CategoryGroup::new("Test Group");
storage.categories.upsert_group(group.clone()).unwrap();
let cat1 = Category::new("Groceries", group.id);
let cat2 = Category::new("Dining Out", group.id);
storage.categories.upsert_category(cat1.clone()).unwrap();
storage.categories.upsert_category(cat2.clone()).unwrap();
storage.categories.save().unwrap();
let account = Account::with_starting_balance(
"Checking",
AccountType::Checking,
Money::from_cents(100000),
);
storage.accounts.upsert(account.clone()).unwrap();
storage.accounts.save().unwrap();
let period = BudgetPeriod::monthly(2025, 1);
let budget_service = BudgetService::new(storage);
budget_service
.assign_to_category(cat1.id, &period, Money::from_cents(50000))
.unwrap();
budget_service
.assign_to_category(cat2.id, &period, Money::from_cents(20000))
.unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
Money::from_cents(-3000),
);
txn.category_id = Some(cat1.id);
storage.transactions.upsert(txn).unwrap();
period
}
#[test]
fn test_generate_report() {
let (_temp_dir, storage) = create_test_storage();
let period = setup_test_data(&storage);
let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
assert_eq!(report.period, period);
assert_eq!(report.groups.len(), 1);
assert_eq!(report.groups[0].categories.len(), 2);
assert_eq!(report.grand_total_budgeted.cents(), 70000);
}
#[test]
fn test_csv_export() {
let (_temp_dir, storage) = create_test_storage();
let period = setup_test_data(&storage);
let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
let mut csv_output = Vec::new();
report.export_csv(&mut csv_output).unwrap();
let csv_string = String::from_utf8(csv_output).unwrap();
assert!(csv_string.contains("Period,Group,Category,Budgeted,Carryover,Activity,Available"));
assert!(csv_string.contains("Groceries"));
assert!(csv_string.contains("Dining Out"));
}
#[test]
fn test_terminal_format() {
let (_temp_dir, storage) = create_test_storage();
let period = setup_test_data(&storage);
let report = BudgetOverviewReport::generate(&storage, &period).unwrap();
let output = report.format_terminal();
assert!(output.contains("Budget Overview"));
assert!(output.contains("Groceries"));
assert!(output.contains("GRAND TOTAL"));
}
}