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 ReversalPair {
pub merchant: String,
pub amount: f64,
pub charge_date: String,
pub refund_date: String,
}
#[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 possible_reversals: Vec<ReversalPair>,
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 = compute_true_spending(transactions);
let possible_duplicates = find_possible_duplicates(transactions);
let possible_reversals = find_possible_reversals(transactions);
let prior_period = PriorPeriodComparison {
delta: total_spent - cashflow.prior_month_spending,
};
SpendingReport {
total_spent,
over_budget_categories,
by_category: category_reports,
possible_duplicates,
possible_reversals,
vs_prior_month: prior_period,
}
}
pub(crate) fn transaction_spend_magnitude(txn: &Transaction) -> f64 {
match txn.category.group_type.as_deref() {
Some("expense") => (-txn.amount).max(0.0),
Some("income") | Some("transfer") => 0.0,
_ => (-txn.amount).max(0.0), }
}
fn aggregate_spending_by_category(transactions: &[Transaction]) -> HashMap<String, f64> {
let mut totals: HashMap<String, f64> = HashMap::new();
for txn in transactions {
let magnitude = transaction_spend_magnitude(txn);
if matches!(txn.category.group_type.as_deref(), Some("expense") | None) {
*totals.entry(txn.category.name.clone()).or_insert(0.0) += magnitude;
}
}
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
}
const REVERSAL_WINDOW_DAYS: i64 = 14;
fn parse_date_to_days(date: &str) -> Option<i64> {
let parts: Vec<&str> = date.splitn(3, '-').collect();
if parts.len() != 3 {
return None;
}
let y: i64 = parts[0].parse().ok()?;
let m: i64 = parts[1].parse().ok()?;
let d: i64 = parts[2].parse().ok()?;
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
fn find_possible_reversals(transactions: &[Transaction]) -> Vec<ReversalPair> {
let mut reversals = Vec::new();
let charges: Vec<&Transaction> = transactions
.iter()
.filter(|t| {
t.amount < 0.0 && matches!(t.category.group_type.as_deref(), Some("expense") | None)
})
.collect();
let refunds: Vec<&Transaction> = transactions
.iter()
.filter(|t| {
t.amount > 0.0 && matches!(t.category.group_type.as_deref(), Some("expense") | None)
})
.collect();
let mut consumed_refund_indices = std::collections::HashSet::new();
for charge in &charges {
let charge_days = match parse_date_to_days(&charge.date) {
Some(d) => d,
None => continue,
};
let charge_cents = (-charge.amount * 100.0).round() as i64;
for (refund_idx, refund) in refunds.iter().enumerate() {
if consumed_refund_indices.contains(&refund_idx) {
continue;
}
if refund.merchant_name != charge.merchant_name {
continue;
}
let refund_cents = (refund.amount * 100.0).round() as i64;
if (refund_cents - charge_cents).abs() > 1 {
continue;
}
let refund_days = match parse_date_to_days(&refund.date) {
Some(d) => d,
None => continue,
};
let gap = refund_days - charge_days;
if !(0..=REVERSAL_WINDOW_DAYS).contains(&gap) {
continue;
}
reversals.push(ReversalPair {
merchant: charge.merchant_name.clone(),
amount: -charge.amount, charge_date: charge.date.clone(),
refund_date: refund.date.clone(),
});
consumed_refund_indices.insert(refund_idx);
break;
}
}
reversals
}
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
}
pub fn compute_true_spending(transactions: &[Transaction]) -> f64 {
transactions.iter().map(transaction_spend_magnitude).sum()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Budget, Cashflow, Category, Transaction};
fn make_expense_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(),
group_type: Some("expense".into()),
},
tags: vec![],
notes: String::new(),
needs_review: false,
}
}
fn make_income_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(),
group_type: Some("income".into()),
},
tags: vec![],
notes: String::new(),
needs_review: false,
}
}
fn make_transfer_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(),
group_type: Some("transfer".into()),
},
tags: vec![],
notes: String::new(),
needs_review: false,
}
}
fn make_budget(category: &str, amount: f64) -> Budget {
Budget {
category: Category {
name: category.to_string(),
group_type: None,
},
amount,
}
}
fn zero_cashflow() -> Cashflow {
Cashflow {
income: 0.0,
spending: 0.0,
prior_month_spending: 0.0,
}
}
#[test]
fn negative_expense_over_budget_is_flagged() {
let txns = vec![make_expense_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 negative_expense_exactly_at_budget_is_not_flagged() {
let txns = vec![make_expense_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 negative_expense_under_budget_is_not_flagged() {
let txns = vec![make_expense_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_expense_categories_are_flagged() {
let txns = vec![
make_expense_txn("Dining merchant", -850.0, "Dining", "2026-05-15"),
make_expense_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 income_category_is_never_flagged_as_over_budget() {
let txns = vec![make_income_txn(
"Employer",
5000.0,
"Paychecks",
"2026-05-15",
)];
let budgets = vec![make_budget("Paychecks", 100.0)]; let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Paychecks".to_string()),
"income category Paychecks must never appear in over_budget_categories"
);
}
#[test]
fn transfer_category_is_excluded_from_spending() {
let txns = vec![make_transfer_txn(
"Chase CC Payment",
2000.0,
"Credit Card Payment",
"2026-05-15",
)];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(
report.total_spent, 0.0,
"transfer category must not contribute to total_spent"
);
assert!(
!report
.over_budget_categories
.contains(&"Credit Card Payment".to_string()),
"transfer category must not appear in over_budget_categories"
);
}
#[test]
fn total_spent_excludes_income_and_includes_only_expense_magnitudes() {
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks", "2026-05-15"),
make_expense_txn("Whole Foods", -365.0, "Groceries", "2026-05-16"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(
report.total_spent, 365.0,
"total_spent must equal grocery magnitude (365), not net 5000-365=4635"
);
}
#[test]
fn total_spent_is_sum_of_expense_magnitudes_only() {
let txns = vec![
make_expense_txn("Dining merchant", -850.0, "Dining", "2026-05-15"),
make_expense_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 expense_category_with_only_a_refund_is_not_flagged() {
let txns = vec![make_expense_txn("Insurer", 50.0, "Medical", "2026-05-15")];
let budgets = vec![make_budget("Medical", 200.0)];
let report = compute_spending_report(&txns, &budgets, &zero_cashflow());
assert!(
!report
.over_budget_categories
.contains(&"Medical".to_string()),
"expense category with only a positive refund must not be over-budget"
);
let cat = report.by_category.get("Medical").unwrap();
assert_eq!(
cat.spent, 0.0,
"refund-only category must report 0 spend, not negative"
);
}
#[test]
fn percent_of_budget_is_positive_for_negative_expense() {
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_correctly() {
assert_eq!(percent_of_budget(1.0, 3.0), Some(33));
assert_eq!(percent_of_budget(2.0, 3.0), Some(67));
}
#[test]
fn expense_category_report_includes_positive_percent() {
let txns = vec![make_expense_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));
assert!(
cat.spent >= 0.0,
"spent must be non-negative magnitude, got {}",
cat.spent
);
}
#[test]
fn unbudgeted_expense_category_is_not_flagged_as_over_budget() {
let txns = vec![make_expense_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_expense_txn("Acme Streaming", -49.99, "Subscriptions", "2026-05-14"),
make_expense_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_expense_txn("Acme Streaming", -49.99, "Subscriptions", "2026-05-14"),
make_expense_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_expense_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_expense_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 charge_and_refund_same_merchant_within_14_days_is_paired() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-10"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(
report.possible_reversals.len(),
1,
"expected 1 reversal pair, got {:?}",
report.possible_reversals
);
assert_eq!(report.possible_reversals[0].merchant, "Banfield");
assert!((report.possible_reversals[0].amount - 850.0).abs() < 0.001);
assert_eq!(report.possible_reversals[0].charge_date, "2026-05-01");
assert_eq!(report.possible_reversals[0].refund_date, "2026-05-10");
}
#[test]
fn two_same_amount_charges_without_refund_are_not_paired_as_reversal() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-05"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert!(
report.possible_reversals.is_empty(),
"two charges (no refund) must not be paired as a reversal: {:?}",
report.possible_reversals
);
}
#[test]
fn charge_and_refund_beyond_14_days_are_not_paired() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-20"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert!(
report.possible_reversals.is_empty(),
"charge+refund beyond 14 days must not be paired: {:?}",
report.possible_reversals
);
}
#[test]
fn charge_and_refund_different_merchants_are_not_paired() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("VetClinic", 850.0, "Pets", "2026-05-05"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert!(
report.possible_reversals.is_empty(),
"different merchants must not be paired as a reversal: {:?}",
report.possible_reversals
);
}
#[test]
fn one_charge_and_two_equal_refunds_produces_exactly_one_reversal_pair() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-05"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-06"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(
report.possible_reversals.len(),
1,
"one charge + two refunds must yield exactly one reversal pair, got {:?}",
report.possible_reversals
);
}
#[test]
fn two_charges_and_two_equal_refunds_produces_two_reversal_pairs() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-02"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-05"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-06"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(
report.possible_reversals.len(),
2,
"two charges + two matching refunds must yield two reversal pairs, got {:?}",
report.possible_reversals
);
}
#[test]
fn reversal_pair_does_not_change_total_spent() {
let txns = vec![
make_expense_txn("Banfield", -850.0, "Pets", "2026-05-01"),
make_expense_txn("Banfield", 850.0, "Pets", "2026-05-10"),
];
let report = compute_spending_report(&txns, &[], &zero_cashflow());
assert_eq!(
report.total_spent, 850.0,
"reversal pair must not be netted from total_spent"
);
}
#[test]
fn true_spending_sums_expense_magnitudes() {
let txns = vec![
make_expense_txn("Groceries", -365.0, "Groceries", "2026-05-10"),
make_expense_txn("Dining", -200.0, "Dining", "2026-05-15"),
];
assert_eq!(compute_true_spending(&txns), 565.0);
}
#[test]
fn true_spending_excludes_income_group() {
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks", "2026-05-01"),
make_expense_txn("Groceries", -365.0, "Groceries", "2026-05-10"),
];
assert_eq!(compute_true_spending(&txns), 365.0);
}
#[test]
fn true_spending_excludes_transfer_group() {
let txns = vec![
make_transfer_txn("Chase", -3299.02, "Credit Card Payment", "2026-05-05"),
make_expense_txn("Groceries", -731.27, "Groceries", "2026-05-10"),
];
let result = compute_true_spending(&txns);
assert!(
(result - 731.27).abs() < 0.001,
"expected 731.27, got {result}"
);
}
#[test]
fn true_spending_refund_in_expense_category_contributes_zero() {
let txns = vec![make_expense_txn("Insurer", 50.0, "Medical", "2026-05-15")];
assert_eq!(compute_true_spending(&txns), 0.0);
}
#[test]
fn true_spending_empty_slice_is_zero() {
assert_eq!(compute_true_spending(&[]), 0.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 zero_budget_with_expense_spend_produces_no_percent_and_is_over_budget() {
let txns = vec![make_expense_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_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_expense_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_expense_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_expense_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 budgets = vec![make_budget("Loan Repayment", -1280.0)];
let report = compute_spending_report(&[], &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 unknown_group_type_negative_amount_counts_as_expense_spend() {
let txn = Transaction {
id: "unknown-1".into(),
amount: -100.0,
date: "2026-05-15".into(),
merchant_name: "Mystery Co".into(),
category: Category {
name: "Unknown".into(),
group_type: None,
},
tags: vec![],
notes: String::new(),
needs_review: false,
};
let report = compute_spending_report(&[txn], &[], &zero_cashflow());
assert_eq!(
report.total_spent, 100.0,
"negative-amount transaction with no group_type must count as 100 spend"
);
}
#[test]
fn unknown_group_type_positive_amount_is_not_counted_as_spend() {
let txn = Transaction {
id: "unknown-2".into(),
amount: 200.0,
date: "2026-05-15".into(),
merchant_name: "Mystery Refund".into(),
category: Category {
name: "Unknown".into(),
group_type: None,
},
tags: vec![],
notes: String::new(),
needs_review: false,
};
let report = compute_spending_report(&[txn], &[], &zero_cashflow());
assert_eq!(
report.total_spent, 0.0,
"positive-amount transaction with no group_type must not count as spend"
);
}
#[test]
fn zero_budget_with_zero_spend_produces_no_percent_and_is_not_over_budget() {
let txns = vec![make_expense_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"
);
}
}