use crate::client::{Budget, Cashflow, Transaction};
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Serialize, PartialEq)]
pub struct CategoryReport {
pub spent: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub percent_of_budget: Option<i64>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct DuplicateCharge {
pub merchant: String,
pub amount: f64,
pub date: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct PriorPeriodComparison {
pub delta: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct SpendingReport {
pub total_spent: f64,
pub over_budget_categories: Vec<String>,
pub by_category: HashMap<String, CategoryReport>,
pub possible_duplicates: Vec<DuplicateCharge>,
pub vs_prior_month: PriorPeriodComparison,
}
pub fn compute_spending_report(
transactions: &[Transaction],
budgets: &[Budget],
cashflow: &Cashflow,
) -> SpendingReport {
let by_category = aggregate_spending_by_category(transactions);
let budget_map = build_budget_map(budgets);
let category_reports = build_category_reports(&by_category, &budget_map);
let over_budget_categories = find_over_budget_categories(&category_reports);
let total_spent = by_category.values().sum();
let possible_duplicates = find_possible_duplicates(transactions);
let prior_period = PriorPeriodComparison {
delta: total_spent - cashflow.prior_month_spending,
};
SpendingReport {
total_spent,
over_budget_categories,
by_category: category_reports,
possible_duplicates,
vs_prior_month: prior_period,
}
}
fn aggregate_spending_by_category(transactions: &[Transaction]) -> HashMap<String, f64> {
let mut totals: HashMap<String, f64> = HashMap::new();
for txn in transactions {
*totals.entry(txn.category.name.clone()).or_insert(0.0) += txn.amount;
}
totals
}
fn build_budget_map(budgets: &[Budget]) -> HashMap<String, f64> {
budgets
.iter()
.map(|b| (b.category.name.clone(), b.amount))
.collect()
}
fn build_category_reports(
spending: &HashMap<String, f64>,
budget_map: &HashMap<String, f64>,
) -> HashMap<String, CategoryReport> {
spending
.iter()
.map(|(category, &spent)| {
let report = match budget_map.get(category) {
Some(&budget) => CategoryReport {
spent,
budget: Some(budget),
percent_of_budget: percent_of_budget(spent, budget),
},
None => CategoryReport {
spent,
budget: None,
percent_of_budget: None,
},
};
(category.clone(), report)
})
.collect()
}
fn percent_of_budget(spent: f64, budget: f64) -> Option<i64> {
let magnitude = budget.abs();
if magnitude == 0.0 {
return None;
}
Some(((spent / magnitude) * 100.0).round() as i64)
}
fn find_over_budget_categories(category_reports: &HashMap<String, CategoryReport>) -> Vec<String> {
let mut over_budget: Vec<String> = category_reports
.iter()
.filter_map(|(name, report)| {
report.budget.and_then(|budget| {
if report.spent > budget.abs() {
Some(name.clone())
} else {
None
}
})
})
.collect();
over_budget.sort();
over_budget
}
fn find_possible_duplicates(transactions: &[Transaction]) -> Vec<DuplicateCharge> {
let mut seen: HashMap<(&str, i64, &str), usize> = HashMap::new();
let mut duplicates: Vec<DuplicateCharge> = Vec::new();
let mut already_flagged: std::collections::HashSet<(&str, i64, &str)> =
std::collections::HashSet::new();
for txn in transactions {
let amount_cents = (txn.amount * 100.0).round() as i64;
let key = (txn.merchant_name.as_str(), amount_cents, txn.date.as_str());
let count = seen.entry(key).or_insert(0);
*count += 1;
if *count == 2 && !already_flagged.contains(&key) {
already_flagged.insert(key);
duplicates.push(DuplicateCharge {
merchant: txn.merchant_name.clone(),
amount: txn.amount,
date: txn.date.clone(),
});
}
}
duplicates
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Budget, Cashflow, Category, Transaction};
fn make_txn(merchant: &str, amount: f64, category: &str, date: &str) -> Transaction {
Transaction {
id: format!("{merchant}-{amount}-{date}"),
amount,
date: date.to_string(),
merchant_name: merchant.to_string(),
category: Category {
name: category.to_string(),
},
tags: vec![],
notes: String::new(),
needs_review: false,
}
}
fn make_budget(category: &str, amount: f64) -> Budget {
Budget {
category: Category {
name: category.to_string(),
},
amount,
}
}
fn zero_cashflow() -> Cashflow {
Cashflow {
income: 0.0,
spending: 0.0,
prior_month_spending: 0.0,
}
}
#[test]
fn category_over_budget_is_flagged() {
let txns = vec![make_txn("Dining merchant", 850.0, "Dining", "2026-05-15")];
let budgets = vec![make_budget("Dining", 600.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
report
.over_budget_categories
.contains(&"Dining".to_string()),
"expected Dining in over_budget_categories: {:?}",
report.over_budget_categories
);
}
#[test]
fn category_exactly_at_budget_is_not_flagged() {
let txns = vec![make_txn(
"Groceries merchant",
900.0,
"Groceries",
"2026-05-15",
)];
let budgets = vec![make_budget("Groceries", 900.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Groceries".to_string()),
"Groceries at 100% should not be flagged: {:?}",
report.over_budget_categories
);
}
#[test]
fn category_under_budget_is_not_flagged() {
let txns = vec![make_txn(
"Groceries merchant",
720.0,
"Groceries",
"2026-05-15",
)];
let budgets = vec![make_budget("Groceries", 900.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Groceries".to_string()),
"Groceries under budget should not be flagged"
);
}
#[test]
fn all_over_budget_categories_are_flagged() {
let txns = vec![
make_txn("Dining merchant", 850.0, "Dining", "2026-05-15"),
make_txn("Shopping merchant", 500.0, "Shopping", "2026-05-15"),
];
let budgets = vec![make_budget("Dining", 600.0), make_budget("Shopping", 400.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
report
.over_budget_categories
.contains(&"Dining".to_string()),
"Dining should be over budget"
);
assert!(
report
.over_budget_categories
.contains(&"Shopping".to_string()),
"Shopping should be over budget"
);
}
#[test]
fn percent_of_budget_rounds_to_nearest_whole() {
assert_eq!(percent_of_budget(850.0, 600.0), Some(142));
assert_eq!(percent_of_budget(900.0, 900.0), Some(100));
}
#[test]
fn percent_of_budget_rounds_up_at_half() {
assert_eq!(percent_of_budget(1.0, 3.0), Some(33));
assert_eq!(percent_of_budget(2.0, 3.0), Some(67));
}
#[test]
fn category_report_includes_percent_when_budgeted() {
let txns = vec![make_txn("Dining merchant", 850.0, "Dining", "2026-05-15")];
let budgets = vec![make_budget("Dining", 600.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
let cat = report.by_category.get("Dining").unwrap();
assert_eq!(cat.percent_of_budget, Some(142));
}
#[test]
fn unbudgeted_category_is_not_flagged_as_over_budget() {
let txns = vec![make_txn("Travel merchant", 300.0, "Travel", "2026-05-15")];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Travel".to_string()),
"unbudgeted Travel should not be flagged"
);
let cat = report.by_category.get("Travel").unwrap();
assert_eq!(cat.spent, 300.0);
assert_eq!(cat.budget, None);
assert_eq!(cat.percent_of_budget, None);
}
#[test]
fn identical_charges_same_day_flagged_as_duplicate() {
let txns = vec![
make_txn("Acme Streaming", 49.99, "Subscriptions", "2026-05-14"),
make_txn("Acme Streaming", 49.99, "Subscriptions", "2026-05-14"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
let merchants: Vec<&str> = report
.possible_duplicates
.iter()
.map(|d| d.merchant.as_str())
.collect();
assert!(
merchants.contains(&"Acme Streaming"),
"expected Acme Streaming in duplicates: {:?}",
merchants
);
}
#[test]
fn same_merchant_different_amount_not_a_duplicate() {
let txns = vec![
make_txn("Acme Streaming", 49.99, "Subscriptions", "2026-05-14"),
make_txn("Acme Streaming", 9.99, "Subscriptions", "2026-05-14"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
let merchants: Vec<&str> = report
.possible_duplicates
.iter()
.map(|d| d.merchant.as_str())
.collect();
assert!(
!merchants.contains(&"Acme Streaming"),
"different amounts should not be a duplicate: {:?}",
merchants
);
}
#[test]
fn prior_period_delta_is_positive_when_spending_increased() {
let txns = vec![make_txn("Various", 4600.0, "General", "2026-05-15")];
let cashflow = Cashflow {
income: 0.0,
spending: 0.0,
prior_month_spending: 4000.0,
};
let report = compute_spending_report(&txns, &[], &cashflow);
assert_eq!(report.vs_prior_month.delta, 600.0);
}
#[test]
fn prior_period_delta_is_negative_when_spending_decreased() {
let txns = vec![make_txn("Various", 3000.0, "General", "2026-05-15")];
let cashflow = Cashflow {
income: 0.0,
spending: 0.0,
prior_month_spending: 4000.0,
};
let report = compute_spending_report(&txns, &[], &cashflow);
assert_eq!(report.vs_prior_month.delta, -1000.0);
}
#[test]
fn empty_period_reports_zero_spend_and_no_flags() {
let report = compute_spending_report(&[], &[], &zero_cashflow());
assert_eq!(report.total_spent, 0.0);
assert!(report.over_budget_categories.is_empty());
assert!(report.by_category.is_empty());
assert!(report.possible_duplicates.is_empty());
}
#[test]
fn total_spent_is_sum_of_all_transactions() {
let txns = vec![
make_txn("Dining merchant", 850.0, "Dining", "2026-05-15"),
make_txn("Groceries merchant", 720.0, "Groceries", "2026-05-15"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(report.total_spent, 1570.0);
}
#[test]
fn zero_budget_with_spend_produces_no_percent_and_is_over_budget() {
let txns = vec![make_txn("Netflix", 15.99, "Streaming", "2026-05-15")];
let budgets = vec![make_budget("Streaming", 0.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
let cat = report
.by_category
.get("Streaming")
.expect("Streaming category must exist");
assert_ne!(
cat.percent_of_budget,
Some(i64::MAX),
"zero budget with spend must not produce i64::MAX percent"
);
assert_eq!(
cat.percent_of_budget, None,
"zero budget should yield no percent (division by zero)"
);
assert!(
report
.over_budget_categories
.contains(&"Streaming".to_string()),
"spending on a $0-budget category must still be classified over budget"
);
}
#[test]
fn negative_budget_under_magnitude_is_not_over_budget() {
let txns = vec![make_txn("Loan Co", 1000.0, "Loan Repayment", "2026-05-15")];
let budgets = vec![make_budget("Loan Repayment", -1280.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Loan Repayment".to_string()),
"spending 1000 against a -1280 budget should NOT be over budget (1000 < 1280)"
);
}
#[test]
fn negative_budget_over_magnitude_is_flagged_as_over_budget() {
let txns = vec![make_txn("Loan Co", 1400.0, "Loan Repayment", "2026-05-15")];
let budgets = vec![make_budget("Loan Repayment", -1280.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
report
.over_budget_categories
.contains(&"Loan Repayment".to_string()),
"spending 1400 against a -1280 budget SHOULD be over budget (1400 > 1280)"
);
}
#[test]
fn negative_budget_percent_of_budget_is_positive_and_sane() {
assert_eq!(percent_of_budget(1000.0, -1280.0), Some(78));
}
#[test]
fn negative_budget_exactly_at_magnitude_is_not_over_budget() {
let txns = vec![make_txn("Loan Co", 1280.0, "Loan Repayment", "2026-05-15")];
let budgets = vec![make_budget("Loan Repayment", -1280.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Loan Repayment".to_string()),
"spending exactly 1280 against a -1280 budget should NOT be over budget"
);
let cat = report.by_category.get("Loan Repayment").unwrap();
assert_eq!(cat.percent_of_budget, Some(100));
}
#[test]
fn negative_budget_zero_spend_is_not_over_budget() {
let txns = vec![];
let budgets = vec![make_budget("Loan Repayment", -1280.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Loan Repayment".to_string()),
"zero spend against a negative budget should not be over budget"
);
}
#[test]
fn zero_budget_with_zero_spend_produces_no_percent_and_is_not_over_budget() {
let txns = vec![make_txn("Netflix", 0.0, "Streaming", "2026-05-15")];
let budgets = vec![make_budget("Streaming", 0.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
let cat = report
.by_category
.get("Streaming")
.expect("Streaming category must exist");
assert_eq!(
cat.percent_of_budget, None,
"zero budget with zero spend should yield None percent (0/0 = NaN, not 0)"
);
assert!(
!report
.over_budget_categories
.contains(&"Streaming".to_string()),
"zero spend on $0-budget category is not over budget"
);
}
}