use crate::client::Transaction;
use crate::spending_report::compute_true_spending;
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Serialize, PartialEq)]
pub struct MonthlySavings {
pub month: String,
pub income: f64,
pub true_spending: f64,
pub net_savings: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub savings_rate: Option<f64>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct SavingsRateResult {
pub months: Vec<MonthlySavings>,
pub range_start: String,
pub range_end: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_average_savings_rate: Option<f64>,
}
fn transaction_income_amount(txn: &Transaction) -> f64 {
match txn.category.group_type.as_deref() {
Some("income") => txn.amount.max(0.0),
_ => 0.0,
}
}
fn compute_income(transactions: &[Transaction]) -> f64 {
transactions.iter().map(transaction_income_amount).sum()
}
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 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
}
pub fn compute_savings_rate(
transactions: &[Transaction],
range_start: &str,
range_end: &str,
) -> SavingsRateResult {
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.clone());
}
}
}
let mut months: Vec<MonthlySavings> = month_labels
.into_iter()
.map(|label| {
let txns = bucket_map.get(&label).map_or(&[][..], Vec::as_slice);
build_monthly_savings(label, txns)
})
.collect();
months.sort_by(|a, b| a.month.cmp(&b.month));
let window_average_savings_rate = compute_window_average(&months);
SavingsRateResult {
months,
range_start: range_start.to_string(),
range_end: range_end.to_string(),
window_average_savings_rate,
}
}
fn build_monthly_savings(month: String, transactions: &[Transaction]) -> MonthlySavings {
let income = compute_income(transactions);
let true_spending = compute_true_spending(transactions);
let net_savings = income - true_spending;
let savings_rate = if income > 0.0 && income.is_finite() {
Some((net_savings / income) * 100.0)
} else {
None
};
MonthlySavings {
month,
income,
true_spending,
net_savings,
savings_rate,
}
}
fn compute_window_average(months: &[MonthlySavings]) -> Option<f64> {
let rates: Vec<f64> = months.iter().filter_map(|m| m.savings_rate).collect();
if rates.is_empty() {
None
} else {
Some(rates.iter().sum::<f64>() / rates.len() as f64)
}
}
#[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 income_sums_positive_income_group_amounts() {
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks", "2026-04-01"),
make_income_txn("Freelance", 1000.0, "Other Income", "2026-04-15"),
];
assert_eq!(compute_income(&txns), 6000.0);
}
#[test]
fn income_excludes_expense_group_transactions() {
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks", "2026-04-01"),
make_expense_txn("Whole Foods", -365.0, "Groceries", "2026-04-10"),
];
assert_eq!(compute_income(&txns), 5000.0);
}
#[test]
fn income_excludes_transfer_group_transactions() {
let txns = vec![
make_income_txn("Employer", 5000.0, "Paychecks", "2026-04-01"),
make_transfer_txn("Chase", 2000.0, "Credit Card Payment", "2026-04-05"),
];
assert_eq!(compute_income(&txns), 5000.0);
}
#[test]
fn income_does_not_count_expense_category_refunds_as_income() {
let txns = vec![make_expense_txn("Insurer", 200.0, "Medical", "2026-04-15")];
assert_eq!(compute_income(&txns), 0.0);
}
#[test]
fn income_zero_for_empty_slice() {
assert_eq!(compute_income(&[]), 0.0);
}
#[test]
fn savings_rate_is_25_percent_for_6000_income_4500_spending() {
let txns = vec![
make_income_txn("Employer", 6000.0, "Paychecks", "2026-04-01"),
make_expense_txn("Various", -4500.0, "Expenses", "2026-04-15"),
];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
assert_eq!(result.months.len(), 1);
let m = &result.months[0];
assert_eq!(m.income, 6000.0);
assert_eq!(m.true_spending, 4500.0);
assert_eq!(m.net_savings, 1500.0);
let rate = m
.savings_rate
.expect("savings_rate must be Some when income > 0");
assert!((rate - 25.0).abs() < 0.001, "expected 25%, got {rate}");
}
#[test]
fn savings_rate_is_none_when_income_is_zero() {
let txns = vec![make_expense_txn(
"Various",
-2000.0,
"Expenses",
"2026-04-15",
)];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
assert_eq!(result.months.len(), 1);
let m = &result.months[0];
assert_eq!(m.income, 0.0);
assert!(
m.savings_rate.is_none(),
"savings_rate must be None when income is zero, not inf/NaN"
);
}
#[test]
fn savings_rate_is_none_for_empty_month() {
let result = compute_savings_rate(&[], "2026-04-01", "2026-04-30");
assert_eq!(result.months.len(), 1);
let m = &result.months[0];
assert_eq!(m.income, 0.0);
assert_eq!(m.true_spending, 0.0);
assert_eq!(m.net_savings, 0.0);
assert!(m.savings_rate.is_none());
}
#[test]
fn credit_card_payment_excluded_from_true_spending() {
let txns = vec![
make_income_txn("Employer", 6000.0, "Paychecks", "2026-04-01"),
make_expense_txn("Various", -4000.0, "Expenses", "2026-04-15"),
make_transfer_txn("Chase", -2000.0, "Credit Card Payment", "2026-04-20"),
];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
let m = &result.months[0];
assert_eq!(
m.true_spending, 4000.0,
"CC payment must not inflate true_spending"
);
let rate = m.savings_rate.unwrap();
assert!(
(rate - 100.0 / 3.0).abs() < 0.01,
"expected ~33.33%, got {rate}"
);
}
#[test]
fn three_months_produces_three_entries_oldest_first() {
let txns = vec![
make_income_txn("Employer", 6000.0, "Paychecks", "2026-02-01"),
make_expense_txn("G1", -3000.0, "Groceries", "2026-02-15"),
make_income_txn("Employer", 6000.0, "Paychecks", "2026-03-01"),
make_expense_txn("G2", -3600.0, "Groceries", "2026-03-15"),
make_income_txn("Employer", 6000.0, "Paychecks", "2026-04-01"),
make_expense_txn("G3", -4500.0, "Groceries", "2026-04-15"),
];
let result = compute_savings_rate(&txns, "2026-02-01", "2026-04-30");
assert_eq!(result.months.len(), 3);
assert_eq!(result.months[0].month, "2026-02");
assert_eq!(result.months[1].month, "2026-03");
assert_eq!(result.months[2].month, "2026-04");
}
#[test]
fn window_average_is_mean_of_monthly_rates() {
let txns = vec![
make_income_txn("Employer", 6000.0, "Paychecks", "2026-02-01"),
make_expense_txn("G1", -3000.0, "Groceries", "2026-02-15"),
make_income_txn("Employer", 6000.0, "Paychecks", "2026-03-01"),
make_expense_txn("G2", -3600.0, "Groceries", "2026-03-15"),
make_income_txn("Employer", 6000.0, "Paychecks", "2026-04-01"),
make_expense_txn("G3", -4500.0, "Groceries", "2026-04-15"),
];
let result = compute_savings_rate(&txns, "2026-02-01", "2026-04-30");
let avg = result
.window_average_savings_rate
.expect("window_average must be Some when months have income");
let expected = (50.0 + 40.0 + 25.0) / 3.0;
assert!(
(avg - expected).abs() < 0.01,
"expected {expected:.2}%, got {avg:.2}%"
);
}
#[test]
fn window_average_is_none_when_no_month_has_income() {
let txns = vec![make_expense_txn("G1", -3000.0, "Groceries", "2026-04-15")];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
assert!(
result.window_average_savings_rate.is_none(),
"window_average must be None when no month has income"
);
}
#[test]
fn window_average_skips_zero_income_months() {
let txns = vec![
make_expense_txn("G1", -1000.0, "Groceries", "2026-01-15"),
make_income_txn("Employer", 6000.0, "Paychecks", "2026-02-01"),
make_expense_txn("G2", -3000.0, "Groceries", "2026-02-15"),
];
let result = compute_savings_rate(&txns, "2026-01-01", "2026-02-28");
let avg = result
.window_average_savings_rate
.expect("window_average must be Some for Feb");
assert!((avg - 50.0).abs() < 0.01, "expected 50%, got {avg}");
}
#[test]
fn result_preserves_range_start_and_end() {
let result = compute_savings_rate(&[], "2026-03-01", "2026-04-30");
assert_eq!(result.range_start, "2026-03-01");
assert_eq!(result.range_end, "2026-04-30");
}
#[test]
fn result_months_contain_only_aggregates_not_transaction_lists() {
let txns = vec![
make_income_txn("Employer", 6000.0, "Paychecks", "2026-04-01"),
make_expense_txn("G1", -4500.0, "Groceries", "2026-04-15"),
];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
let json = serde_json::to_value(&result).unwrap();
let months = json["months"].as_array().unwrap();
for m in months {
assert!(
m.get("transactions").is_none(),
"monthly entry must not contain a transactions key: {m}"
);
}
}
#[test]
fn negative_savings_rate_when_spending_exceeds_income() {
let txns = vec![
make_income_txn("Employer", 3000.0, "Paychecks", "2026-04-01"),
make_expense_txn("Various", -5000.0, "Expenses", "2026-04-15"),
];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
let m = &result.months[0];
assert_eq!(m.net_savings, -2000.0);
let rate = m.savings_rate.unwrap();
assert!(
rate < 0.0,
"rate must be negative when spending > income, got {rate}"
);
assert!(
(rate - (-2000.0 / 3000.0 * 100.0)).abs() < 0.001,
"expected -66.67%, got {rate}"
);
}
#[test]
fn transactions_outside_range_are_excluded() {
let txns = vec![
make_income_txn("Employer", 6000.0, "Paychecks", "2026-03-01"), make_income_txn("Employer", 6000.0, "Paychecks", "2026-04-01"), make_expense_txn("G1", -4500.0, "Groceries", "2026-04-15"), make_expense_txn("G2", -1000.0, "Groceries", "2026-05-05"), ];
let result = compute_savings_rate(&txns, "2026-04-01", "2026-04-30");
assert_eq!(result.months.len(), 1);
let m = &result.months[0];
assert_eq!(m.income, 6000.0, "only in-range income counted");
assert_eq!(m.true_spending, 4500.0, "only in-range spending counted");
}
}