use crate::client::{Budget, Transaction};
use crate::spending_report::transaction_spend_magnitude;
use serde::Serialize;
use std::collections::HashMap;
pub const PACE_TOLERANCE_PP: f64 = 10.0;
#[derive(Debug, Serialize, PartialEq, Clone)]
#[serde(rename_all = "snake_case")]
pub enum PaceStatus {
Under,
OnTrack,
Over,
OverBudget,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct CategoryPacing {
pub budget: f64,
pub spent: f64,
pub remaining: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub percent_spent: Option<i64>,
pub pace_status: PaceStatus,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct PaceRollup {
pub total_budget: f64,
pub total_spent: f64,
pub on_track_count: usize,
pub over_count: usize,
pub over_budget_count: usize,
pub under_count: usize,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct BudgetReview {
pub by_category: HashMap<String, CategoryPacing>,
pub rollup: PaceRollup,
}
pub fn compute_budget_review(
budgets: &[Budget],
transactions: &[Transaction],
today_day_of_month: u32,
days_in_month: u32,
) -> BudgetReview {
let spending_by_category = aggregate_expense_spending(transactions);
let by_category = build_category_pacings(
budgets,
&spending_by_category,
today_day_of_month,
days_in_month,
);
let rollup = compute_rollup(&by_category);
BudgetReview {
by_category,
rollup,
}
}
fn aggregate_expense_spending(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_category_pacings(
budgets: &[Budget],
spending: &HashMap<String, f64>,
today_day_of_month: u32,
days_in_month: u32,
) -> HashMap<String, CategoryPacing> {
budgets
.iter()
.filter_map(|b| {
if matches!(
b.category.group_type.as_deref(),
Some("income") | Some("transfer")
) {
return None;
}
let budget_mag = b.amount.abs();
let spent = *spending.get(&b.category.name).unwrap_or(&0.0);
let remaining = budget_mag - spent;
let percent_spent = compute_percent_spent(spent, budget_mag);
let pace_status = classify_pace(spent, budget_mag, today_day_of_month, days_in_month);
Some((
b.category.name.clone(),
CategoryPacing {
budget: budget_mag,
spent,
remaining,
percent_spent,
pace_status,
},
))
})
.collect()
}
fn compute_percent_spent(spent: f64, budget_mag: f64) -> Option<i64> {
if budget_mag == 0.0 {
return None;
}
Some(((spent / budget_mag) * 100.0).round() as i64)
}
fn classify_pace(
spent: f64,
budget_mag: f64,
today_day_of_month: u32,
days_in_month: u32,
) -> PaceStatus {
if spent >= budget_mag && budget_mag > 0.0 {
return PaceStatus::OverBudget;
}
if budget_mag == 0.0 {
return if spent > 0.0 {
PaceStatus::OverBudget
} else {
PaceStatus::Under
};
}
let percent_f = (spent / budget_mag) * 100.0;
let pace_pct = (today_day_of_month as f64 / days_in_month as f64) * 100.0;
if percent_f > pace_pct + PACE_TOLERANCE_PP {
PaceStatus::Over
} else if percent_f < pace_pct - PACE_TOLERANCE_PP {
PaceStatus::Under
} else {
PaceStatus::OnTrack
}
}
fn compute_rollup(by_category: &HashMap<String, CategoryPacing>) -> PaceRollup {
let mut total_budget = 0.0;
let mut total_spent = 0.0;
let mut on_track_count = 0;
let mut over_count = 0;
let mut over_budget_count = 0;
let mut under_count = 0;
for pacing in by_category.values() {
total_budget += pacing.budget;
total_spent += pacing.spent;
match pacing.pace_status {
PaceStatus::OnTrack => on_track_count += 1,
PaceStatus::Over => over_count += 1,
PaceStatus::OverBudget => over_budget_count += 1,
PaceStatus::Under => under_count += 1,
}
}
PaceRollup {
total_budget,
total_spent,
on_track_count,
over_count,
over_budget_count,
under_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Budget, Category, Transaction};
fn make_expense_txn(merchant: &str, amount: f64, category: &str) -> Transaction {
Transaction {
id: format!("{merchant}-{amount}"),
amount,
date: "2026-05-15".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) -> Transaction {
Transaction {
id: format!("{merchant}-{amount}"),
amount,
date: "2026-05-01".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) -> Transaction {
Transaction {
id: format!("{merchant}-{amount}"),
amount,
date: "2026-05-05".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_expense_budget(category: &str, amount: f64) -> Budget {
Budget {
category: Category {
name: category.to_string(),
group_type: Some("expense".into()),
},
amount,
}
}
fn make_income_budget(category: &str, amount: f64) -> Budget {
Budget {
category: Category {
name: category.to_string(),
group_type: Some("income".into()),
},
amount,
}
}
fn make_transfer_budget(category: &str, amount: f64) -> Budget {
Budget {
category: Category {
name: category.to_string(),
group_type: Some("transfer".into()),
},
amount,
}
}
#[test]
fn category_spent_faster_than_month_elapsed_is_over() {
let budgets = vec![make_expense_budget("Restaurants", -400.0)];
let txns = vec![make_expense_txn("Restaurant Co", -360.0, "Restaurants")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Restaurants").unwrap();
assert_eq!(cat.pace_status, PaceStatus::Over, "{cat:?}");
assert_eq!(cat.remaining, 40.0);
assert_eq!(cat.spent, 360.0);
assert_eq!(cat.budget, 400.0);
}
#[test]
fn category_tracking_with_calendar_is_on_track() {
let budgets = vec![make_expense_budget("Groceries", -600.0)];
let txns = vec![make_expense_txn("Grocery Store", -290.0, "Groceries")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Groceries").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OnTrack, "{cat:?}");
}
#[test]
fn category_already_over_budget_is_flagged_over_budget() {
let budgets = vec![make_expense_budget("Phone", -200.0)];
let txns = vec![make_expense_txn("Phone Co", -210.0, "Phone")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Phone").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OverBudget, "{cat:?}");
assert!(
(cat.remaining - (-10.0)).abs() < 0.001,
"remaining should be -10, got {}",
cat.remaining
);
}
#[test]
fn category_exactly_at_budget_is_over_budget() {
let budgets = vec![make_expense_budget("Utilities", -300.0)];
let txns = vec![make_expense_txn("Utility Co", -300.0, "Utilities")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Utilities").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OverBudget, "{cat:?}");
}
#[test]
fn exactly_at_upper_tolerance_edge_is_on_track() {
let budgets = vec![make_expense_budget("Dining", -100.0)];
let txns = vec![make_expense_txn("Diner", -60.0, "Dining")]; let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Dining").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OnTrack, "{cat:?}");
}
#[test]
fn one_cent_above_upper_tolerance_edge_is_over() {
let budgets = vec![make_expense_budget("Dining", -100.0)];
let txns = vec![make_expense_txn("Diner", -61.0, "Dining")]; let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Dining").unwrap();
assert_eq!(cat.pace_status, PaceStatus::Over, "{cat:?}");
}
#[test]
fn exactly_at_lower_tolerance_edge_is_on_track() {
let budgets = vec![make_expense_budget("Dining", -100.0)];
let txns = vec![make_expense_txn("Diner", -40.0, "Dining")]; let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Dining").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OnTrack, "{cat:?}");
}
#[test]
fn one_cent_below_lower_tolerance_edge_is_under() {
let budgets = vec![make_expense_budget("Dining", -100.0)];
let txns = vec![make_expense_txn("Diner", -39.0, "Dining")]; let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Dining").unwrap();
assert_eq!(cat.pace_status, PaceStatus::Under, "{cat:?}");
}
#[test]
fn day_1_of_30_pace_is_3_percent() {
let budgets = vec![make_expense_budget("Groceries", -200.0)];
let txns = vec![make_expense_txn("Grocery Store", -20.0, "Groceries")]; let result = compute_budget_review(&budgets, &txns, 1, 30);
let cat = result.by_category.get("Groceries").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OnTrack, "{cat:?}");
}
#[test]
fn day_1_of_30_with_no_spending_is_on_track() {
let budgets = vec![make_expense_budget("Groceries", -200.0)];
let result = compute_budget_review(&budgets, &[], 1, 30);
let cat = result.by_category.get("Groceries").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OnTrack, "{cat:?}");
}
#[test]
fn last_day_of_30_with_full_spend_is_on_track() {
let budgets = vec![make_expense_budget("Rent", -1500.0)];
let txns = vec![make_expense_txn("Landlord", -1500.0, "Rent")];
let result = compute_budget_review(&budgets, &txns, 30, 30);
let cat = result.by_category.get("Rent").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OverBudget, "{cat:?}");
}
#[test]
fn last_day_of_30_with_99_percent_spend_is_on_track() {
let budgets = vec![make_expense_budget("Rent", -1500.0)];
let txns = vec![make_expense_txn("Landlord", -1485.0, "Rent")]; let result = compute_budget_review(&budgets, &txns, 30, 30);
let cat = result.by_category.get("Rent").unwrap();
assert_eq!(cat.pace_status, PaceStatus::OnTrack, "{cat:?}");
}
#[test]
fn income_budget_is_excluded_from_pacing() {
let budgets = vec![
make_income_budget("Paychecks", 5000.0),
make_expense_budget("Groceries", -600.0),
];
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks"),
make_expense_txn("Grocery Store", -300.0, "Groceries"),
];
let result = compute_budget_review(&budgets, &txns, 15, 30);
assert!(
!result.by_category.contains_key("Paychecks"),
"income category must not appear in pacing: {:?}",
result.by_category.keys().collect::<Vec<_>>()
);
assert!(result.by_category.contains_key("Groceries"));
}
#[test]
fn transfer_budget_is_excluded_from_pacing() {
let budgets = vec![
make_transfer_budget("Credit Card Payment", -2000.0),
make_expense_budget("Dining", -400.0),
];
let txns = vec![
make_transfer_txn("Chase CC", -2000.0, "Credit Card Payment"),
make_expense_txn("Diner", -200.0, "Dining"),
];
let result = compute_budget_review(&budgets, &txns, 15, 30);
assert!(
!result.by_category.contains_key("Credit Card Payment"),
"transfer category must not appear in pacing"
);
assert!(result.by_category.contains_key("Dining"));
}
#[test]
fn income_and_transfer_transactions_do_not_count_as_spend() {
let budgets = vec![make_expense_budget("Groceries", -600.0)];
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks"),
make_transfer_txn("Chase CC", -2000.0, "Credit Card Payment"),
make_expense_txn("Grocery Store", -300.0, "Groceries"),
];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Groceries").unwrap();
assert_eq!(
cat.spent, 300.0,
"spent must be 300, not inflated by income/transfer"
);
}
#[test]
fn category_with_spend_but_no_budget_is_not_in_pacing() {
let budgets = vec![make_expense_budget("Groceries", -600.0)];
let txns = vec![
make_expense_txn("Airline", -500.0, "Travel"),
make_expense_txn("Grocery Store", -300.0, "Groceries"),
];
let result = compute_budget_review(&budgets, &txns, 15, 30);
assert!(
!result.by_category.contains_key("Travel"),
"unbudgeted Travel must not appear in pacing"
);
assert!(result.by_category.contains_key("Groceries"));
}
#[test]
fn budgeted_category_with_zero_spend_is_included_and_on_track_early() {
let budgets = vec![make_expense_budget("Gym", -50.0)];
let result = compute_budget_review(&budgets, &[], 1, 30);
let cat = result.by_category.get("Gym").unwrap();
assert_eq!(cat.spent, 0.0);
assert_eq!(cat.budget, 50.0);
assert_eq!(cat.remaining, 50.0);
}
#[test]
fn budgeted_category_with_zero_spend_mid_month_is_under() {
let budgets = vec![make_expense_budget("Gym", -50.0)];
let result = compute_budget_review(&budgets, &[], 20, 30);
let cat = result.by_category.get("Gym").unwrap();
assert_eq!(cat.pace_status, PaceStatus::Under, "{cat:?}");
}
#[test]
fn pace_fraction_changes_with_day_of_month() {
let budgets = vec![make_expense_budget("Misc", -100.0)];
let txns = vec![make_expense_txn("Misc Co", -60.0, "Misc")];
let result_day10 = compute_budget_review(&budgets, &txns, 10, 30);
let cat_day10 = result_day10.by_category.get("Misc").unwrap();
assert_eq!(
cat_day10.pace_status,
PaceStatus::Over,
"day 10: {cat_day10:?}"
);
let result_day20 = compute_budget_review(&budgets, &txns, 20, 30);
let cat_day20 = result_day20.by_category.get("Misc").unwrap();
assert_eq!(
cat_day20.pace_status,
PaceStatus::OnTrack,
"day 20: {cat_day20:?}"
);
}
#[test]
fn percent_spent_rounds_correctly() {
let budgets = vec![make_expense_budget("Restaurants", -400.0)];
let txns = vec![make_expense_txn("Restaurant Co", -360.0, "Restaurants")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Restaurants").unwrap();
assert_eq!(cat.percent_spent, Some(90));
}
#[test]
fn percent_spent_is_none_for_zero_budget() {
let budgets = vec![make_expense_budget("Misc", 0.0)];
let result = compute_budget_review(&budgets, &[], 15, 30);
let cat = result.by_category.get("Misc").unwrap();
assert_eq!(cat.percent_spent, None);
}
#[test]
fn zero_budget_with_no_spend_is_under_and_finite() {
let budgets = vec![make_expense_budget("Misc", 0.0)];
let result = compute_budget_review(&budgets, &[], 15, 30);
let cat = result.by_category.get("Misc").unwrap();
assert_eq!(
cat.pace_status,
PaceStatus::Under,
"zero budget with no spend should be Under: {cat:?}"
);
assert_eq!(cat.remaining, 0.0);
assert!(cat.remaining.is_finite(), "remaining should be finite");
}
#[test]
fn zero_budget_with_spend_is_over_budget_and_finite() {
let budgets = vec![make_expense_budget("Misc", 0.0)];
let txns = vec![make_expense_txn("Misc Co", -25.0, "Misc")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Misc").unwrap();
assert_eq!(
cat.pace_status,
PaceStatus::OverBudget,
"zero budget with any spend should be OverBudget: {cat:?}"
);
assert_eq!(
cat.percent_spent, None,
"percent_spent must be None for zero budget"
);
assert_eq!(cat.spent, 25.0);
assert_eq!(cat.remaining, -25.0);
assert!(cat.remaining.is_finite(), "remaining should be finite");
}
#[test]
fn zero_budget_with_spend_counts_as_over_budget_in_rollup() {
let budgets = vec![make_expense_budget("Misc", 0.0)];
let txns = vec![make_expense_txn("Misc Co", -25.0, "Misc")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let rollup = &result.rollup;
assert_eq!(
rollup.over_budget_count, 1,
"zero budget with spend should count in over_budget_count: {rollup:?}"
);
assert_eq!(
rollup.on_track_count, 0,
"on_track_count should be 0: {rollup:?}"
);
assert_eq!(rollup.over_count, 0, "over_count should be 0: {rollup:?}");
assert_eq!(rollup.under_count, 0, "under_count should be 0: {rollup:?}");
}
#[test]
fn rollup_counts_are_correct() {
let budgets = vec![
make_expense_budget("Restaurants", -400.0),
make_expense_budget("Groceries", -600.0),
make_expense_budget("Phone", -200.0),
make_expense_budget("Gym", -50.0),
];
let txns = vec![
make_expense_txn("Restaurant Co", -360.0, "Restaurants"),
make_expense_txn("Grocery Store", -290.0, "Groceries"),
make_expense_txn("Phone Co", -210.0, "Phone"),
];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let rollup = &result.rollup;
assert_eq!(rollup.over_count, 1, "over: {:?}", rollup);
assert_eq!(rollup.on_track_count, 1, "on_track: {:?}", rollup);
assert_eq!(rollup.over_budget_count, 1, "over_budget: {:?}", rollup);
assert_eq!(rollup.under_count, 1, "under: {:?}", rollup);
assert_eq!(rollup.total_budget, 1250.0, "total_budget: {:?}", rollup);
assert!(
(rollup.total_spent - 860.0).abs() < 0.001,
"total_spent: {:?}",
rollup
);
}
#[test]
fn negative_budget_magnitude_is_used_correctly() {
let budgets = vec![make_expense_budget("Loan Repayment", -1280.0)];
let txns = vec![make_expense_txn("Loan Co", -1000.0, "Loan Repayment")];
let result = compute_budget_review(&budgets, &txns, 15, 30);
let cat = result.by_category.get("Loan Repayment").unwrap();
assert_eq!(cat.budget, 1280.0, "budget should be the magnitude");
assert_eq!(cat.percent_spent, Some(78));
assert_eq!(cat.pace_status, PaceStatus::Over, "{cat:?}");
}
}