use crate::client::Transaction;
use crate::spending_report::transaction_spend_magnitude;
use serde::Serialize;
use std::collections::HashMap;
pub const FIXED_CATEGORY_PATTERNS: &[&str] = &[
"mortgage",
"rent",
"insurance",
"utilities",
"utility",
"loan",
"medical",
"dental",
];
fn category_tokens(name: &str) -> Vec<String> {
name.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase())
.collect()
}
pub fn is_fixed_category(category_name: &str) -> bool {
let name_tokens = category_tokens(category_name);
FIXED_CATEGORY_PATTERNS.iter().any(|pattern| {
let pattern_tokens = category_tokens(pattern);
if pattern_tokens.is_empty() {
return false;
}
name_tokens.windows(pattern_tokens.len()).any(|window| {
window
.iter()
.zip(pattern_tokens.iter())
.all(|(name_tok, pat_tok)| {
name_tok == pat_tok || *name_tok == format!("{pat_tok}s")
})
})
})
}
#[derive(Debug, Serialize, PartialEq)]
pub struct FixedDiscretionarySplit {
pub fixed: f64,
pub discretionary: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct SpendingOutlier {
pub category: String,
pub merchant: String,
pub amount: f64,
pub date: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct MonthlySpend {
pub month: String,
pub total_true_spending: f64,
pub by_category: HashMap<String, f64>,
pub split: FixedDiscretionarySplit,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub outliers: Vec<SpendingOutlier>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct SpendingHistory {
pub months: Vec<MonthlySpend>,
pub range_start: String,
pub range_end: String,
}
fn epoch_days_to_ymd(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
(year, m as u32, d as u32)
}
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) {
29
} else {
28
}
}
_ => 31,
}
}
#[cfg(test)]
fn parse_date_for_test(s: &str) -> Option<i64> {
let mut parts = s.splitn(3, '-');
let year: i64 = parts.next()?.parse().ok()?;
let month: i64 = parts.next()?.parse().ok()?;
let day: i64 = parts.next()?.parse().ok()?;
if !(1..=9999).contains(&year) || !(1..=12).contains(&month) {
return None;
}
let max_day = days_in_month(year, month as u32) as i64;
if day < 1 || day > max_day {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let m = month as u32;
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 }) as i64 + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
fn month_bucket(date: &str) -> Option<String> {
let bytes = date.as_bytes().get(..7)?;
let prefix = std::str::from_utf8(bytes).ok()?;
if prefix.as_bytes().get(4).copied() != Some(b'-') {
return None;
}
Some(prefix.to_string())
}
fn subtract_months(year: i64, month: u32, n: u32) -> (i64, u32) {
let total = year * 12 + (month as i64 - 1) - n as i64;
let new_year = total.div_euclid(12);
let new_month = (total.rem_euclid(12) + 1) as u32; (new_year, new_month)
}
pub fn range_for_months_count(today_day: i64, months: u32) -> (String, String) {
let months = months.max(1);
let (today_year, today_month, _) = epoch_days_to_ymd(today_day);
let (prior_year, prior_month) = subtract_months(today_year, today_month, 1);
let (start_year, start_month) = subtract_months(prior_year, prior_month, months - 1);
let start = format!("{start_year:04}-{start_month:02}-01");
let end_last = days_in_month(prior_year, prior_month);
let end = format!("{prior_year:04}-{prior_month:02}-{end_last:02}");
(start, end)
}
const OUTLIER_FACTOR: f64 = 3.0;
fn find_outliers(transactions: &[&Transaction]) -> Vec<SpendingOutlier> {
let mut by_category: HashMap<&str, Vec<&Transaction>> = HashMap::new();
for txn in transactions {
let magnitude = transaction_spend_magnitude(txn);
if magnitude > 0.0 {
by_category
.entry(txn.category.name.as_str())
.or_default()
.push(txn);
}
}
let mut outliers = Vec::new();
for (category, txns) in &by_category {
if txns.len() < 2 {
continue; }
for i in 0..txns.len() {
let magnitude = transaction_spend_magnitude(txns[i]);
let others_total: f64 = txns
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, t)| transaction_spend_magnitude(t))
.sum();
let others_avg = others_total / (txns.len() - 1) as f64;
if others_avg <= 0.0 {
continue; }
if magnitude >= others_avg * OUTLIER_FACTOR {
outliers.push(SpendingOutlier {
category: category.to_string(),
merchant: txns[i].merchant_name.clone(),
amount: magnitude,
date: txns[i].date.clone(),
});
}
}
}
outliers.sort_by(|a, b| {
a.category
.cmp(&b.category)
.then(a.date.cmp(&b.date))
.then(
b.amount
.partial_cmp(&a.amount)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.merchant.cmp(&b.merchant))
});
outliers
}
pub fn compute_spending_history(
transactions: &[Transaction],
range_start: &str,
range_end: &str,
) -> SpendingHistory {
let month_labels = enumerate_months_in_range(range_start, range_end);
let mut bucket_map: HashMap<String, Vec<&Transaction>> = HashMap::new();
for txn in transactions {
if let Some(bucket) = month_bucket(&txn.date) {
if month_labels.contains(&bucket) {
bucket_map.entry(bucket).or_default().push(txn);
}
}
}
let months = month_labels
.into_iter()
.map(|label| {
let txns = bucket_map.get(&label).map(|v| v.as_slice()).unwrap_or(&[]);
build_monthly_spend(label, txns)
})
.collect();
SpendingHistory {
months,
range_start: range_start.to_string(),
range_end: range_end.to_string(),
}
}
fn build_monthly_spend(month: String, transactions: &[&Transaction]) -> MonthlySpend {
let mut by_category: HashMap<String, f64> = HashMap::new();
let mut fixed_total = 0.0_f64;
let mut discretionary_total = 0.0_f64;
for txn in transactions {
let magnitude = transaction_spend_magnitude(txn);
if magnitude == 0.0 {
continue;
}
*by_category.entry(txn.category.name.clone()).or_insert(0.0) += magnitude;
if is_fixed_category(&txn.category.name) {
fixed_total += magnitude;
} else {
discretionary_total += magnitude;
}
}
let total_true_spending: f64 = by_category.values().sum();
let outliers = find_outliers(transactions);
MonthlySpend {
month,
total_true_spending,
by_category,
split: FixedDiscretionarySplit {
fixed: fixed_total,
discretionary: discretionary_total,
},
outliers,
}
}
fn enumerate_months_in_range(range_start: &str, range_end: &str) -> Vec<String> {
let start_prefix = match range_start
.as_bytes()
.get(..7)
.and_then(|b| std::str::from_utf8(b).ok())
{
Some(p) => p,
None => return vec![],
};
let end_prefix = match range_end
.as_bytes()
.get(..7)
.and_then(|b| std::str::from_utf8(b).ok())
{
Some(p) => p,
None => return vec![],
};
let parse_ym = |s: &str| -> Option<(i64, u32)> {
let mut parts = s.splitn(2, '-');
let y: i64 = parts.next()?.parse().ok()?;
let m: u32 = parts.next()?.parse().ok()?;
Some((y, m))
};
let (mut y, mut m) = match parse_ym(start_prefix) {
Some(v) => v,
None => return vec![],
};
let (end_y, end_m) = match parse_ym(end_prefix) {
Some(v) => v,
None => return vec![],
};
let mut labels = Vec::new();
loop {
if y > end_y || (y == end_y && m > end_m) {
break;
}
labels.push(format!("{y:04}-{m:02}"));
m += 1;
if m > 12 {
m = 1;
y += 1;
}
}
labels
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{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,
}
}
#[test]
fn enumerate_months_single_month() {
let labels = enumerate_months_in_range("2026-03-01", "2026-03-31");
assert_eq!(labels, vec!["2026-03"]);
}
#[test]
fn enumerate_months_three_months() {
let labels = enumerate_months_in_range("2026-01-01", "2026-03-31");
assert_eq!(labels, vec!["2026-01", "2026-02", "2026-03"]);
}
#[test]
fn enumerate_months_crosses_year_boundary() {
let labels = enumerate_months_in_range("2025-11-01", "2026-02-28");
assert_eq!(labels, vec!["2025-11", "2025-12", "2026-01", "2026-02"]);
}
#[test]
fn range_for_months_count_default_6_excludes_current_month() {
let today = parse_date_for_test("2026-05-15").unwrap();
let (start, end) = range_for_months_count(today, 6);
assert_eq!(start, "2025-11-01");
assert_eq!(end, "2026-04-30");
}
#[test]
fn range_for_months_count_1_returns_only_prior_month() {
let today = parse_date_for_test("2026-05-15").unwrap();
let (start, end) = range_for_months_count(today, 1);
assert_eq!(start, "2026-04-01");
assert_eq!(end, "2026-04-30");
}
#[test]
fn range_for_months_count_crosses_year_when_today_is_january() {
let today = parse_date_for_test("2026-01-10").unwrap();
let (start, end) = range_for_months_count(today, 3);
assert_eq!(start, "2025-10-01");
assert_eq!(end, "2025-12-31");
}
#[test]
fn empty_transactions_produces_zero_spend_per_month() {
let history = compute_spending_history(&[], "2026-03-01", "2026-04-30");
assert_eq!(history.months.len(), 2);
for m in &history.months {
assert_eq!(m.total_true_spending, 0.0);
assert!(m.by_category.is_empty());
}
}
#[test]
fn transactions_bucketed_into_correct_months() {
let txns = vec![
make_expense_txn("Grocer", -365.0, "Groceries", "2026-03-10"),
make_expense_txn("Dining", -200.0, "Dining", "2026-04-15"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-04-30");
assert_eq!(history.months.len(), 2);
let march = history
.months
.iter()
.find(|m| m.month == "2026-03")
.unwrap();
let april = history
.months
.iter()
.find(|m| m.month == "2026-04")
.unwrap();
assert_eq!(march.total_true_spending, 365.0);
assert_eq!(april.total_true_spending, 200.0);
}
#[test]
fn income_transactions_excluded_from_all_months() {
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks", "2026-03-01"),
make_expense_txn("Grocer", -365.0, "Groceries", "2026-03-10"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert_eq!(history.months.len(), 1);
assert_eq!(history.months[0].total_true_spending, 365.0);
assert!(!history.months[0].by_category.contains_key("Paychecks"));
}
#[test]
fn transfer_transactions_excluded_from_all_months() {
let txns = vec![
make_transfer_txn("Chase CC", -3000.0, "Credit Card Payment", "2026-03-05"),
make_expense_txn("Grocer", -365.0, "Groceries", "2026-03-10"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert_eq!(history.months[0].total_true_spending, 365.0);
}
#[test]
fn refund_in_expense_category_contributes_zero_to_month_spend() {
let txns = vec![
make_expense_txn("Insurer", 120.0, "Medical", "2026-03-15"), make_expense_txn("Clinic", -80.0, "Medical", "2026-03-20"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert_eq!(history.months[0].total_true_spending, 80.0);
assert_eq!(*history.months[0].by_category.get("Medical").unwrap(), 80.0);
}
#[test]
fn mortgage_payment_classified_as_fixed() {
let txns = vec![make_expense_txn(
"Lender",
-2500.0,
"Mortgage",
"2026-03-01",
)];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let split = &history.months[0].split;
assert_eq!(split.fixed, 2500.0);
assert_eq!(split.discretionary, 0.0);
}
#[test]
fn dining_classified_as_discretionary() {
let txns = vec![make_expense_txn(
"Restaurant",
-85.0,
"Dining",
"2026-03-15",
)];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let split = &history.months[0].split;
assert_eq!(split.fixed, 0.0);
assert_eq!(split.discretionary, 85.0);
}
#[test]
fn mixed_fixed_and_discretionary_split_correctly() {
let txns = vec![
make_expense_txn("Lender", -2500.0, "Mortgage", "2026-03-01"),
make_expense_txn("Utils Co", -150.0, "Utilities", "2026-03-10"),
make_expense_txn("Restaurant", -85.0, "Dining", "2026-03-15"),
make_expense_txn("Amazon", -60.0, "Shopping", "2026-03-20"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let split = &history.months[0].split;
assert_eq!(split.fixed, 2650.0); assert_eq!(split.discretionary, 145.0); }
#[test]
fn insurance_category_classified_as_fixed() {
let txns = vec![make_expense_txn(
"State Farm",
-180.0,
"Auto Insurance",
"2026-03-01",
)];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert_eq!(history.months[0].split.fixed, 180.0);
assert_eq!(history.months[0].split.discretionary, 0.0);
}
#[test]
fn loan_repayment_classified_as_fixed() {
let txns = vec![make_expense_txn("Bank", -1280.0, "Auto Loan", "2026-03-01")];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert_eq!(history.months[0].split.fixed, 1280.0);
}
#[test]
fn multiple_transactions_in_same_category_are_summed() {
let txns = vec![
make_expense_txn("Whole Foods", -365.0, "Groceries", "2026-03-10"),
make_expense_txn("Trader Joes", -220.0, "Groceries", "2026-03-22"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let cat = history.months[0].by_category.get("Groceries").unwrap();
assert_eq!(*cat, 585.0);
}
#[test]
fn by_category_sums_match_total_true_spending() {
let txns = vec![
make_expense_txn("Whole Foods", -365.0, "Groceries", "2026-03-10"),
make_expense_txn("Restaurant", -85.0, "Dining", "2026-03-15"),
make_expense_txn("Lender", -2500.0, "Mortgage", "2026-03-01"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let m = &history.months[0];
let cat_sum: f64 = m.by_category.values().sum();
assert!(
(cat_sum - m.total_true_spending).abs() < 0.001,
"by_category sum {cat_sum} != total_true_spending {}",
m.total_true_spending
);
}
#[test]
fn large_one_off_transaction_surfaced_as_outlier() {
let txns = vec![
make_expense_txn("Casual Diner", -50.0, "Dining", "2026-03-05"),
make_expense_txn("Fast Food", -45.0, "Dining", "2026-03-12"),
make_expense_txn("Fancy Dinner", -300.0, "Dining", "2026-03-20"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let outliers = &history.months[0].outliers;
assert!(
outliers.iter().any(|o| o.merchant == "Fancy Dinner"),
"Expected Fancy Dinner as outlier, got: {outliers:?}"
);
}
#[test]
fn single_transaction_category_not_flagged_as_outlier() {
let txns = vec![make_expense_txn("Dentist", -1200.0, "Dental", "2026-03-15")];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert!(
history.months[0].outliers.is_empty(),
"Single-transaction category must not be flagged as outlier"
);
}
#[test]
fn similar_sized_transactions_not_flagged_as_outliers() {
let txns = vec![
make_expense_txn("Grocer A", -365.0, "Groceries", "2026-03-01"),
make_expense_txn("Grocer B", -380.0, "Groceries", "2026-03-15"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
assert!(
history.months[0].outliers.is_empty(),
"Similar-sized transactions must not produce outliers"
);
}
#[test]
fn six_months_produces_six_monthly_entries_oldest_first() {
let txns = vec![
make_expense_txn("G1", -100.0, "Groceries", "2025-11-15"),
make_expense_txn("G2", -110.0, "Groceries", "2025-12-15"),
make_expense_txn("G3", -120.0, "Groceries", "2026-01-15"),
make_expense_txn("G4", -130.0, "Groceries", "2026-02-15"),
make_expense_txn("G5", -140.0, "Groceries", "2026-03-15"),
make_expense_txn("G6", -150.0, "Groceries", "2026-04-15"),
];
let today = parse_date_for_test("2026-05-15").unwrap();
let (start, end) = range_for_months_count(today, 6);
let history = compute_spending_history(&txns, &start, &end);
assert_eq!(history.months.len(), 6);
assert_eq!(history.months[0].month, "2025-11");
assert_eq!(history.months[5].month, "2026-04");
assert_eq!(history.months[0].total_true_spending, 100.0);
assert_eq!(history.months[5].total_true_spending, 150.0);
}
#[test]
fn transactions_outside_range_are_excluded() {
let txns = vec![
make_expense_txn("Old", -500.0, "Groceries", "2026-01-15"), make_expense_txn("InRange", -200.0, "Groceries", "2026-03-15"),
make_expense_txn("Current", -300.0, "Groceries", "2026-05-15"), ];
let history = compute_spending_history(&txns, "2026-03-01", "2026-04-30");
let total: f64 = history.months.iter().map(|m| m.total_true_spending).sum();
assert_eq!(
total, 200.0,
"Only the in-range transaction should be counted"
);
}
#[test]
fn is_fixed_category_matches_known_patterns() {
assert!(is_fixed_category("Mortgage"));
assert!(is_fixed_category("Home Mortgage"));
assert!(is_fixed_category("Rent"));
assert!(is_fixed_category("Auto Insurance"));
assert!(is_fixed_category("Utilities"));
assert!(is_fixed_category("Electric Utility"));
assert!(is_fixed_category("Auto Loan"));
assert!(is_fixed_category("Loan Repayment"));
assert!(is_fixed_category("Medical Bills"));
assert!(is_fixed_category("Dental Care"));
}
#[test]
fn is_fixed_category_rejects_discretionary_categories() {
assert!(!is_fixed_category("Dining"));
assert!(!is_fixed_category("Shopping"));
assert!(!is_fixed_category("Entertainment"));
assert!(!is_fixed_category("Travel"));
assert!(!is_fixed_category("Groceries"));
assert!(!is_fixed_category("Subscriptions"));
}
#[test]
fn is_fixed_category_is_case_insensitive() {
assert!(is_fixed_category("MORTGAGE"));
assert!(is_fixed_category("auto insurance"));
assert!(is_fixed_category("UTILITIES"));
}
#[test]
fn is_fixed_category_rejects_substring_false_positives() {
assert!(!is_fixed_category("Concert Rentals"));
assert!(!is_fixed_category("Apparent Overspending"));
assert!(!is_fixed_category("Current Subscriptions"));
assert!(!is_fixed_category("Parent Gifts"));
assert!(!is_fixed_category("Accidental Purchases"));
assert!(!is_fixed_category("Reinsurance Hobby"));
}
#[test]
fn is_fixed_category_plural_forms_of_fixed_categories_are_fixed() {
assert!(is_fixed_category("Student Loans"));
assert!(is_fixed_category("Loans"));
assert!(is_fixed_category("Insurances"));
assert!(is_fixed_category("Mortgages"));
}
#[test]
fn is_fixed_category_plural_rule_does_not_break_run1_discretionary_cases() {
assert!(!is_fixed_category("Concert Rentals"));
assert!(!is_fixed_category("Accidental Purchases"));
assert!(!is_fixed_category("Reinsurance Hobby"));
assert!(!is_fixed_category("Apparent Overspending"));
assert!(!is_fixed_category("Current Subscriptions"));
assert!(!is_fixed_category("Parent Gifts"));
}
#[test]
fn range_for_months_count_zero_clamps_to_one_month() {
let today = parse_date_for_test("2026-05-15").unwrap();
let (start, end) = range_for_months_count(today, 0);
assert_eq!(start, "2026-04-01");
assert_eq!(end, "2026-04-30");
assert!(start <= end);
}
#[test]
fn history_preserves_range_start_and_end() {
let history = compute_spending_history(&[], "2026-03-01", "2026-04-30");
assert_eq!(history.range_start, "2026-03-01");
assert_eq!(history.range_end, "2026-04-30");
}
#[test]
fn month_bucket_non_ascii_returns_none_not_panic() {
assert_eq!(month_bucket("2026-0é-01"), None);
assert_eq!(month_bucket("2026-\u{1F600}x-01"), None);
}
#[test]
fn enumerate_months_non_ascii_start_returns_empty_not_panic() {
let labels = enumerate_months_in_range("2026-0é-01", "2026-12-31");
assert!(
labels.is_empty(),
"Expected empty vec for non-ASCII start, got: {labels:?}"
);
}
#[test]
fn enumerate_months_non_ascii_end_returns_empty_not_panic() {
let labels = enumerate_months_in_range("2026-01-01", "2026-0é-30");
assert!(
labels.is_empty(),
"Expected empty vec for non-ASCII end, got: {labels:?}"
);
}
#[test]
fn subtract_months_basic_within_year() {
assert_eq!(subtract_months(2026, 5, 3), (2026, 2));
}
#[test]
fn subtract_months_crosses_year_boundary() {
assert_eq!(subtract_months(2026, 2, 3), (2025, 11));
}
#[test]
fn subtract_months_exactly_one_year() {
assert_eq!(subtract_months(2026, 6, 12), (2025, 6));
}
#[test]
fn subtract_months_more_than_one_year() {
assert_eq!(subtract_months(2026, 5, 18), (2024, 11));
}
#[test]
fn subtract_months_zero_returns_same_month() {
assert_eq!(subtract_months(2026, 7, 0), (2026, 7));
}
#[test]
fn subtract_months_from_january_goes_to_december() {
assert_eq!(subtract_months(2026, 1, 1), (2025, 12));
}
#[test]
fn subtract_months_from_december_within_year() {
assert_eq!(subtract_months(2026, 12, 1), (2026, 11));
}
#[test]
fn subtract_months_large_n_crosses_multiple_years() {
assert_eq!(subtract_months(2026, 3, 24), (2024, 3));
}
#[test]
fn outlier_sort_is_deterministic_when_category_date_amount_are_equal() {
let txns = vec![
make_expense_txn("Alpha Merchant", -900.0, "Dining", "2026-03-15"),
make_expense_txn("Zeta Merchant", -900.0, "Dining", "2026-03-15"),
make_expense_txn("Tiny Bite A", -5.0, "Dining", "2026-03-01"),
make_expense_txn("Tiny Bite B", -5.0, "Dining", "2026-03-02"),
make_expense_txn("Tiny Bite C", -5.0, "Dining", "2026-03-03"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let outliers = &history.months[0].outliers;
assert_eq!(
outliers.len(),
2,
"Both identical-amount transactions should be outliers; got: {outliers:?}"
);
assert_eq!(
outliers[0].merchant, "Alpha Merchant",
"First outlier should be Alpha Merchant (alphabetically first); got: {outliers:?}"
);
assert_eq!(
outliers[1].merchant, "Zeta Merchant",
"Second outlier should be Zeta Merchant; got: {outliers:?}"
);
}
#[test]
fn fixed_plus_discretionary_equals_total_true_spending() {
let txns = vec![
make_expense_txn("Lender", -2500.0, "Mortgage", "2026-03-01"),
make_expense_txn("Utils Co", -150.0, "Utilities", "2026-03-10"),
make_expense_txn("Restaurant", -85.0, "Dining", "2026-03-15"),
make_expense_txn("Amazon", -60.0, "Shopping", "2026-03-20"),
];
let history = compute_spending_history(&txns, "2026-03-01", "2026-03-31");
let m = &history.months[0];
let split_total = m.split.fixed + m.split.discretionary;
assert!(
(split_total - m.total_true_spending).abs() < 0.001,
"fixed ({}) + discretionary ({}) = {split_total} != total_true_spending {}",
m.split.fixed,
m.split.discretionary,
m.total_true_spending
);
}
}