use crate::client::{Account, Cashflow, NetWorthHistory, Transaction};
use crate::spending_report::compute_true_spending;
use serde::Serialize;
#[derive(Debug, Serialize, PartialEq)]
pub struct OverviewResult {
pub net_worth: f64,
pub cashflow: CashflowSummary,
pub net_worth_change: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct CashflowSummary {
pub income: f64,
pub spending: f64,
pub net: f64,
}
pub fn compute_overview(
accounts: &[Account],
cashflow: &Cashflow,
transactions: &[Transaction],
history: &NetWorthHistory,
) -> OverviewResult {
let net_worth: f64 = accounts.iter().map(|a| a.current_balance).sum();
let true_spending = compute_true_spending(transactions);
let cash_net = cashflow.income - true_spending;
let net_worth_change = net_worth - history.prior_month_net_worth;
OverviewResult {
net_worth,
cashflow: CashflowSummary {
income: cashflow.income,
spending: true_spending,
net: cash_net,
},
net_worth_change,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{AccountType, Cashflow, Category, NetWorthHistory, Transaction};
fn account(balance: f64) -> Account {
Account {
id: "id".to_string(),
display_name: "Test".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "checking".to_string(),
},
subtype: None,
is_hidden: false,
}
}
fn zero_cashflow() -> Cashflow {
Cashflow {
income: 0.0,
spending: 0.0,
prior_month_spending: 0.0,
}
}
fn zero_history() -> NetWorthHistory {
NetWorthHistory {
prior_month_net_worth: 0.0,
}
}
#[test]
fn net_worth_is_assets_minus_liabilities() {
let accounts = vec![
account(100_000.0), account(-30_000.0), ];
let result = compute_overview(&accounts, &zero_cashflow(), &[], &zero_history());
assert_eq!(result.net_worth, 70_000.0);
}
#[test]
fn net_worth_is_negative_when_liabilities_exceed_assets() {
let accounts = vec![account(10_000.0), account(-30_000.0)];
let result = compute_overview(&accounts, &zero_cashflow(), &[], &zero_history());
assert_eq!(result.net_worth, -20_000.0);
}
#[test]
fn net_worth_is_zero_with_no_accounts() {
let result = compute_overview(&[], &zero_cashflow(), &[], &zero_history());
assert_eq!(result.net_worth, 0.0);
}
#[test]
fn cashflow_net_is_income_minus_spending() {
let txns = vec![
make_expense_txn(-3_500.0, "Groceries"),
make_expense_txn(-3_000.0, "Dining"),
];
let cashflow = Cashflow {
income: 8_000.0,
spending: 9_999.0, prior_month_spending: 0.0,
};
let result = compute_overview(&[], &cashflow, &txns, &zero_history());
assert_eq!(result.cashflow.income, 8_000.0);
assert_eq!(result.cashflow.spending, 6_500.0);
assert_eq!(result.cashflow.net, 1_500.0);
}
#[test]
fn cashflow_net_is_zero_when_no_activity() {
let result = compute_overview(&[], &zero_cashflow(), &[], &zero_history());
assert_eq!(result.cashflow.net, 0.0);
}
#[test]
fn net_worth_change_is_positive_when_worth_grew() {
let accounts = vec![account(70_000.0)];
let history = NetWorthHistory {
prior_month_net_worth: 68_000.0,
};
let result = compute_overview(&accounts, &zero_cashflow(), &[], &history);
assert_eq!(result.net_worth_change, 2_000.0);
}
#[test]
fn net_worth_change_is_negative_when_worth_shrank() {
let accounts = vec![account(60_000.0)];
let history = NetWorthHistory {
prior_month_net_worth: 68_000.0,
};
let result = compute_overview(&accounts, &zero_cashflow(), &[], &history);
assert_eq!(result.net_worth_change, -8_000.0);
}
#[test]
fn net_worth_change_is_zero_when_unchanged() {
let accounts = vec![account(50_000.0)];
let history = NetWorthHistory {
prior_month_net_worth: 50_000.0,
};
let result = compute_overview(&accounts, &zero_cashflow(), &[], &history);
assert_eq!(result.net_worth_change, 0.0);
}
fn make_expense_txn(amount: f64, category: &str) -> Transaction {
Transaction {
id: format!("{category}-{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_transfer_txn(amount: f64, category: &str) -> Transaction {
Transaction {
id: format!("{category}-{amount}"),
amount,
date: "2026-05-15".to_string(),
merchant_name: "Transfer".to_string(),
category: Category {
name: category.to_string(),
group_type: Some("transfer".into()),
},
tags: vec![],
notes: String::new(),
needs_review: false,
}
}
#[test]
fn overview_spending_agrees_with_spending_report_for_same_transactions() {
use crate::spending_report::compute_spending_report;
let txns = vec![
make_expense_txn(-365.0, "Groceries"),
make_expense_txn(-200.0, "Dining"),
];
let cashflow = Cashflow {
income: 5_000.0,
spending: 9_999.0, prior_month_spending: 0.0,
};
let overview = compute_overview(&[], &cashflow, &txns, &zero_history());
let report = compute_spending_report(&txns, &[], &cashflow);
assert_eq!(
overview.cashflow.spending, report.total_spent,
"financial_overview.spending and spending_report.total_spent must agree"
);
assert_eq!(overview.cashflow.spending, 565.0);
}
#[test]
fn overview_spending_excludes_transfer_transactions() {
let txns = vec![
make_transfer_txn(-3_299.02, "Credit Card Payment"),
make_expense_txn(-731.27, "Groceries"),
];
let cashflow = Cashflow {
income: 5_000.0,
spending: 4_030.29, prior_month_spending: 0.0,
};
let result = compute_overview(&[], &cashflow, &txns, &zero_history());
assert!(
(result.cashflow.spending - 731.27).abs() < 0.001,
"transfer must not inflate spending; expected 731.27, got {}",
result.cashflow.spending
);
}
#[test]
fn overview_net_uses_true_spending_not_monarch_aggregate() {
let txns = vec![make_expense_txn(-500.0, "Dining")];
let cashflow = Cashflow {
income: 3_000.0,
spending: 9_000.0, prior_month_spending: 0.0,
};
let result = compute_overview(&[], &cashflow, &txns, &zero_history());
assert_eq!(result.cashflow.net, 2_500.0);
assert_eq!(result.cashflow.spending, 500.0);
}
}