use crate::client::{Account, Cashflow};
use crate::goals::Goals;
use crate::net_worth_trend::AccountTypeSnapshot;
use serde::Serialize;
#[derive(Debug, Serialize, PartialEq)]
pub struct GoalsProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub savings_rate: Option<GoalStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emergency_fund: Option<GoalStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debt_payoff: Option<DebtPayoffStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct GoalStatus {
pub status: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct DebtPayoffStatus {
pub status: String,
pub total_debt: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub months_to_target: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_monthly: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned_monthly: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_schedule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_paydown: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_pace: Option<String>,
}
const DEBT_TYPES: &[&str] = &["credit", "loan"];
pub fn months_to_target_from_date(
target_date: &str,
today_year: i64,
today_month: u32,
) -> Option<i64> {
let mut parts = target_date.splitn(3, '-');
let target_year: i64 = parts.next()?.parse().ok()?;
let target_month: i64 = parts.next()?.parse().ok()?;
if !(1..=12).contains(&target_month) {
return None;
}
Some((target_year - today_year) * 12 + target_month - today_month as i64)
}
pub fn classify_goal(actual: f64, goal: f64) -> &'static str {
if actual >= goal {
"on track"
} else if actual < goal / 2.0 {
"off"
} else {
"drifting"
}
}
fn status_rank(status: &str) -> u8 {
match status {
"on track" => 0,
"drifting" => 1,
_ => 2, }
}
#[allow(clippy::too_many_arguments)]
pub fn compute_debt_payoff(
goal: &crate::goals::DebtPayoffGoal,
accounts: &[Account],
snapshots: &[AccountTypeSnapshot],
prior_month: &str,
current_month: &str,
today_year: i64,
today_month: u32,
) -> DebtPayoffStatus {
let total_debt = total_debt_from_accounts(accounts);
let months_to_target = months_to_target_from_date(&goal.target_date, today_year, today_month);
let planned_monthly = goal.monthly_payment;
let required_monthly = if total_debt == 0.0 {
Some(0.0)
} else {
match months_to_target {
Some(m) if m > 0 => Some(total_debt / m as f64),
_ => None,
}
};
let on_schedule: Option<String> = match (planned_monthly, required_monthly) {
(Some(planned), Some(required)) => Some(classify_goal(planned, required).to_string()),
_ => None,
};
let actual_paydown = actual_paydown_from_snapshots(snapshots, prior_month, current_month);
let on_pace: Option<String> = match (actual_paydown, planned_monthly) {
(Some(paydown), Some(planned)) if paydown > 0.0 => {
Some(classify_goal(paydown, planned).to_string())
}
(Some(_), Some(_)) => Some("off".to_string()), _ => None,
};
let status = if total_debt == 0.0 {
"on track".to_string()
} else if matches!(months_to_target, Some(m) if m <= 0) && total_debt > 0.0 {
"off".to_string()
} else {
let sub_statuses: Vec<&str> = [on_schedule.as_deref(), on_pace.as_deref()]
.iter()
.flatten()
.copied()
.collect();
if sub_statuses.is_empty() {
"unknown".to_string()
} else {
sub_statuses
.iter()
.max_by_key(|s| status_rank(s))
.copied()
.unwrap_or("unknown")
.to_string()
}
};
DebtPayoffStatus {
status,
total_debt,
months_to_target,
required_monthly,
planned_monthly,
on_schedule,
actual_paydown,
on_pace,
}
}
pub fn actual_savings_rate_pct(cashflow: &Cashflow) -> f64 {
if cashflow.income == 0.0 {
return 0.0;
}
(cashflow.income - cashflow.spending) / cashflow.income * 100.0
}
pub fn actual_paydown_from_snapshots(
snapshots: &[AccountTypeSnapshot],
prior_month: &str,
current_month: &str,
) -> Option<f64> {
let prior_owed: f64 = snapshots
.iter()
.filter(|s| s.month == prior_month && DEBT_TYPES.contains(&s.account_type.as_str()))
.map(|s| (-s.balance).max(0.0))
.sum();
let has_prior = snapshots
.iter()
.any(|s| s.month == prior_month && DEBT_TYPES.contains(&s.account_type.as_str()));
if !has_prior {
return None;
}
let current_owed: f64 = snapshots
.iter()
.filter(|s| s.month == current_month && DEBT_TYPES.contains(&s.account_type.as_str()))
.map(|s| (-s.balance).max(0.0))
.sum();
Some(prior_owed - current_owed)
}
pub fn total_debt_from_accounts(accounts: &[Account]) -> f64 {
accounts
.iter()
.filter(|a| DEBT_TYPES.contains(&a.account_type.name.as_str()))
.map(|a| (-a.current_balance).max(0.0))
.sum()
}
pub fn actual_reserve_months(accounts: &[Account], cashflow: &Cashflow) -> f64 {
if cashflow.spending == 0.0 {
return 0.0;
}
const LIQUID_TYPES: &[&str] = &["savings", "checking", "depository", "money_market"];
let liquid_balance: f64 = accounts
.iter()
.filter(|a| LIQUID_TYPES.contains(&a.account_type.name.as_str()))
.map(|a| a.current_balance)
.sum();
liquid_balance / cashflow.spending
}
pub fn compute_progress(
goals: &Goals,
accounts: &[Account],
cashflow: &Cashflow,
snapshots: &[AccountTypeSnapshot],
prior_month: &str,
current_month: &str,
today: (i64, u32),
) -> GoalsProgress {
let savings_rate = goals.savings_rate.as_ref().map(|g| {
let actual = actual_savings_rate_pct(cashflow);
GoalStatus {
status: classify_goal(actual, g.target_percent).to_string(),
}
});
let emergency_fund = goals.emergency_fund.as_ref().map(|g| {
let actual = actual_reserve_months(accounts, cashflow);
GoalStatus {
status: classify_goal(actual, g.target_months).to_string(),
}
});
let debt_payoff = goals.debt_payoff.as_ref().map(|g| {
compute_debt_payoff(
g,
accounts,
snapshots,
prior_month,
current_month,
today.0,
today.1,
)
});
let has_no_goals = savings_rate.is_none() && emergency_fund.is_none() && debt_payoff.is_none();
let guidance = has_no_goals.then(|| {
"No goals configured yet. Add a goals.toml file and set \
MONARCH_GOALS_FILE to its path to start tracking progress."
.to_string()
});
GoalsProgress {
savings_rate,
emergency_fund,
debt_payoff,
guidance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{AccountType, Cashflow};
use crate::goals::{EmergencyFundGoal, Goals, SavingsRateGoal};
#[test]
fn classify_at_goal_is_on_track() {
assert_eq!(classify_goal(20.0, 20.0), "on track");
}
#[test]
fn classify_above_goal_is_on_track() {
assert_eq!(classify_goal(25.0, 20.0), "on track");
}
#[test]
fn classify_at_half_goal_is_drifting() {
assert_eq!(classify_goal(10.0, 20.0), "drifting");
}
#[test]
fn classify_just_below_half_is_off() {
assert_eq!(classify_goal(9.9, 20.0), "off");
}
#[test]
fn classify_midrange_is_drifting() {
assert_eq!(classify_goal(17.0, 20.0), "drifting");
}
#[test]
fn classify_far_below_half_is_off() {
assert_eq!(classify_goal(6.0, 20.0), "off");
}
#[test]
fn classify_zero_actual_is_off() {
assert_eq!(classify_goal(0.0, 20.0), "off");
}
#[test]
fn classify_emergency_fund_at_goal_is_on_track() {
assert_eq!(classify_goal(6.0, 6.0), "on track");
}
#[test]
fn classify_emergency_fund_above_goal_is_on_track() {
assert_eq!(classify_goal(7.0, 6.0), "on track");
}
#[test]
fn classify_emergency_fund_drifting() {
assert_eq!(classify_goal(4.0, 6.0), "drifting");
}
#[test]
fn classify_emergency_fund_off() {
assert_eq!(classify_goal(2.0, 6.0), "off");
}
fn cashflow(income: f64, spending: f64) -> Cashflow {
Cashflow {
income,
spending,
prior_month_spending: 0.0,
}
}
#[test]
fn savings_rate_pct_is_correct() {
assert_eq!(actual_savings_rate_pct(&cashflow(10000.0, 7500.0)), 25.0);
}
#[test]
fn savings_rate_pct_zero_income_returns_zero() {
assert_eq!(actual_savings_rate_pct(&cashflow(0.0, 0.0)), 0.0);
}
#[test]
fn savings_rate_pct_full_spending_returns_zero() {
assert_eq!(actual_savings_rate_pct(&cashflow(10000.0, 10000.0)), 0.0);
}
fn savings_account(balance: f64) -> Account {
Account {
id: "s1".to_string(),
display_name: "Emergency Fund".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "savings".to_string(),
},
subtype: None,
is_hidden: false,
}
}
fn checking_account(balance: f64) -> Account {
Account {
id: "c1".to_string(),
display_name: "Checking".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "checking".to_string(),
},
subtype: None,
is_hidden: false,
}
}
fn money_market_account(balance: f64) -> Account {
Account {
id: "mm1".to_string(),
display_name: "HYSA".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "money_market".to_string(),
},
subtype: None,
is_hidden: false,
}
}
fn brokerage_account(balance: f64) -> Account {
Account {
id: "b1".to_string(),
display_name: "Brokerage".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "brokerage".to_string(),
},
subtype: None,
is_hidden: false,
}
}
fn retirement_account(balance: f64) -> Account {
Account {
id: "r1".to_string(),
display_name: "401k".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "retirement".to_string(),
},
subtype: None,
is_hidden: false,
}
}
#[test]
fn reserve_months_counts_money_market() {
let accounts = vec![money_market_account(20000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 4.0);
}
#[test]
fn reserve_months_counts_checking() {
let accounts = vec![checking_account(10000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 2.0);
}
#[test]
fn reserve_months_combines_all_liquid_types() {
let accounts = vec![
savings_account(30000.0),
checking_account(10000.0),
money_market_account(20000.0),
];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 12.0);
}
#[test]
fn reserve_months_excludes_brokerage() {
let accounts = vec![savings_account(30000.0), brokerage_account(100000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_excludes_retirement() {
let accounts = vec![savings_account(30000.0), retirement_account(200000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_divides_savings_by_spending() {
let accounts = vec![savings_account(30000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_ignores_non_liquid_accounts() {
let accounts = vec![savings_account(30000.0), brokerage_account(10000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_zero_spending_returns_zero() {
let accounts = vec![savings_account(30000.0)];
let cf = cashflow(0.0, 0.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 0.0);
}
#[test]
fn reserve_months_no_liquid_accounts_returns_zero() {
let accounts = vec![brokerage_account(10000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 0.0);
}
fn goals_with_savings_rate(pct: f64) -> Goals {
Goals {
savings_rate: Some(SavingsRateGoal {
target_percent: pct,
}),
emergency_fund: None,
debt_payoff: None,
}
}
fn goals_with_emergency_fund(months: f64) -> Goals {
Goals {
savings_rate: None,
emergency_fund: Some(EmergencyFundGoal {
target_months: months,
}),
debt_payoff: None,
}
}
fn empty_goals() -> Goals {
Goals {
savings_rate: None,
emergency_fund: None,
debt_payoff: None,
}
}
#[test]
fn savings_rate_on_track_when_actual_exceeds_goal() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 7500.0);
let result = compute_progress(&goals, &[], &cf, &[], "2026-04", "2026-05", (2026, 5));
assert_eq!(result.savings_rate.unwrap().status, "on track");
assert!(result.emergency_fund.is_none());
}
#[test]
fn savings_rate_drifting_when_between_half_and_goal() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 8300.0);
let result = compute_progress(&goals, &[], &cf, &[], "2026-04", "2026-05", (2026, 5));
assert_eq!(result.savings_rate.unwrap().status, "drifting");
}
#[test]
fn savings_rate_off_when_below_half_goal() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 9400.0);
let result = compute_progress(&goals, &[], &cf, &[], "2026-04", "2026-05", (2026, 5));
assert_eq!(result.savings_rate.unwrap().status, "off");
}
#[test]
fn emergency_fund_on_track_when_reserves_cover_goal() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(42000.0)]; let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf, &[], "2026-04", "2026-05", (2026, 5));
assert_eq!(result.emergency_fund.unwrap().status, "on track");
}
#[test]
fn emergency_fund_drifting_when_between_half_and_goal() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(20000.0)]; let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf, &[], "2026-04", "2026-05", (2026, 5));
assert_eq!(result.emergency_fund.unwrap().status, "drifting");
}
#[test]
fn emergency_fund_off_when_below_half_target() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(10000.0)]; let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf, &[], "2026-04", "2026-05", (2026, 5));
assert_eq!(result.emergency_fund.unwrap().status, "off");
}
#[test]
fn unset_goal_is_not_reported() {
let goals = empty_goals();
let result = compute_progress(
&goals,
&[],
&cashflow(10000.0, 8000.0),
&[],
"2026-04",
"2026-05",
(2026, 5),
);
assert!(result.savings_rate.is_none());
assert!(result.emergency_fund.is_none());
}
#[test]
fn empty_goals_returns_guidance_message() {
let goals = empty_goals();
let result = compute_progress(
&goals,
&[],
&cashflow(10000.0, 8000.0),
&[],
"2026-04",
"2026-05",
(2026, 5),
);
let guidance = result.guidance.as_deref().unwrap_or("");
assert!(
guidance.contains("no goals") || guidance.contains("goals.toml"),
"expected guidance about missing goals, got: {guidance:?}"
);
}
#[test]
fn goals_present_means_no_guidance() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 7500.0);
let result = compute_progress(&goals, &[], &cf, &[], "2026-04", "2026-05", (2026, 5));
assert!(
result.guidance.is_none(),
"guidance should be absent when goals are configured"
);
}
#[test]
fn debt_payoff_only_config_returns_debt_payoff_not_guidance() {
use crate::goals::DebtPayoffGoal;
let goals = Goals {
savings_rate: None,
emergency_fund: None,
debt_payoff: Some(DebtPayoffGoal {
target_date: "2027-12-01".to_string(),
monthly_payment: Some(500.0),
}),
};
let cf = cashflow(10000.0, 8000.0);
let result = compute_progress(&goals, &[], &cf, &[], "2026-04", "2026-05", (2026, 5));
assert!(
result.debt_payoff.is_some(),
"debt_payoff must be Some when the goal is configured"
);
assert!(
result.guidance.is_none(),
"guidance must be absent when debt_payoff goal is set; got: {:?}",
result.guidance
);
}
#[test]
fn savings_rate_only_config_has_no_guidance() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 8000.0);
let result = compute_progress(&goals, &[], &cf, &[], "2026-04", "2026-05", (2026, 5));
assert!(
result.guidance.is_none(),
"guidance must be absent when savings_rate goal is set"
);
}
#[test]
fn months_to_target_8_months_away() {
assert_eq!(months_to_target_from_date("2027-01-01", 2026, 5), Some(8));
}
#[test]
fn months_to_target_same_month_is_zero() {
assert_eq!(months_to_target_from_date("2026-05-01", 2026, 5), Some(0));
}
#[test]
fn months_to_target_past_date_is_negative() {
assert_eq!(months_to_target_from_date("2026-04-01", 2026, 5), Some(-1));
}
#[test]
fn months_to_target_crosses_year_boundary() {
assert_eq!(months_to_target_from_date("2027-12-01", 2026, 5), Some(19));
}
#[test]
fn months_to_target_garbage_is_none() {
assert_eq!(months_to_target_from_date("garbage", 2026, 5), None);
}
#[test]
fn months_to_target_empty_string_is_none() {
assert_eq!(months_to_target_from_date("", 2026, 5), None);
}
#[test]
fn months_to_target_year_only_is_none() {
assert_eq!(months_to_target_from_date("2027", 2026, 5), None);
}
#[test]
fn months_to_target_month_13_is_none() {
assert_eq!(months_to_target_from_date("2027-13", 2026, 5), None);
}
#[test]
fn months_to_target_month_zero_is_none() {
assert_eq!(months_to_target_from_date("2027-00", 2026, 5), None);
}
#[test]
fn months_to_target_valid_full_date_is_some() {
assert_eq!(months_to_target_from_date("2027-12-01", 2026, 5), Some(19));
}
#[test]
fn months_to_target_same_month_some_zero() {
assert_eq!(months_to_target_from_date("2026-05-01", 2026, 5), Some(0));
}
#[test]
fn months_to_target_past_valid_date_some_negative() {
assert_eq!(months_to_target_from_date("2026-04-01", 2026, 5), Some(-1));
}
fn credit_account(balance: f64) -> Account {
Account {
id: "cc1".to_string(),
display_name: "Credit Card".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "credit".to_string(),
},
subtype: None,
is_hidden: false,
}
}
fn loan_account(balance: f64) -> Account {
Account {
id: "l1".to_string(),
display_name: "Car Loan".to_string(),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: "loan".to_string(),
},
subtype: None,
is_hidden: false,
}
}
#[test]
fn total_debt_from_negative_credit_balance() {
let accounts = vec![credit_account(-5000.0)];
assert_eq!(total_debt_from_accounts(&accounts), 5000.0);
}
#[test]
fn total_debt_includes_loan_accounts() {
let accounts = vec![credit_account(-3000.0), loan_account(-10000.0)];
assert_eq!(total_debt_from_accounts(&accounts), 13000.0);
}
#[test]
fn total_debt_overpaid_credit_contributes_zero() {
let accounts = vec![credit_account(50.0)];
assert_eq!(total_debt_from_accounts(&accounts), 0.0);
}
#[test]
fn total_debt_excludes_savings_accounts() {
let accounts = vec![savings_account(30000.0), credit_account(-2000.0)];
assert_eq!(total_debt_from_accounts(&accounts), 2000.0);
}
#[test]
fn total_debt_no_debt_accounts_is_zero() {
let accounts = vec![savings_account(10000.0)];
assert_eq!(total_debt_from_accounts(&accounts), 0.0);
}
fn debt_snap(month: &str, balance: f64) -> AccountTypeSnapshot {
AccountTypeSnapshot {
account_type: "credit".to_string(),
month: month.to_string(),
balance,
}
}
#[test]
fn actual_paydown_positive_when_debt_reduced() {
let prior_month = "2026-04";
let current_month = "2026-05";
let snaps = vec![
debt_snap(prior_month, -8000.0),
debt_snap(current_month, -7000.0),
];
let result = actual_paydown_from_snapshots(&snaps, prior_month, current_month);
assert_eq!(result, Some(1000.0));
}
#[test]
fn actual_paydown_none_when_no_prior_snapshot() {
let snaps = vec![debt_snap("2026-05", -7000.0)];
let result = actual_paydown_from_snapshots(&snaps, "2026-04", "2026-05");
assert_eq!(result, None);
}
#[test]
fn actual_paydown_negative_when_debt_grew() {
let snaps = vec![debt_snap("2026-04", -7000.0), debt_snap("2026-05", -8000.0)];
let result = actual_paydown_from_snapshots(&snaps, "2026-04", "2026-05");
assert_eq!(result, Some(-1000.0));
}
#[test]
fn actual_paydown_sums_multiple_debt_types() {
let snaps = vec![
AccountTypeSnapshot {
account_type: "credit".to_string(),
month: "2026-04".to_string(),
balance: -3000.0,
},
AccountTypeSnapshot {
account_type: "loan".to_string(),
month: "2026-04".to_string(),
balance: -10000.0,
},
AccountTypeSnapshot {
account_type: "credit".to_string(),
month: "2026-05".to_string(),
balance: -2500.0,
},
AccountTypeSnapshot {
account_type: "loan".to_string(),
month: "2026-05".to_string(),
balance: -9600.0,
},
];
let result = actual_paydown_from_snapshots(&snaps, "2026-04", "2026-05");
assert!((result.unwrap() - 900.0).abs() < 0.01);
}
#[test]
fn actual_paydown_ignores_non_debt_snapshots() {
let snaps = vec![
AccountTypeSnapshot {
account_type: "depository".to_string(),
month: "2026-04".to_string(),
balance: 10000.0,
},
debt_snap("2026-04", -5000.0),
AccountTypeSnapshot {
account_type: "depository".to_string(),
month: "2026-05".to_string(),
balance: 11000.0,
},
debt_snap("2026-05", -4500.0),
];
let result = actual_paydown_from_snapshots(&snaps, "2026-04", "2026-05");
assert_eq!(result, Some(500.0));
}
use crate::goals::DebtPayoffGoal;
fn debt_goal(target_date: &str, monthly_payment: Option<f64>) -> DebtPayoffGoal {
DebtPayoffGoal {
target_date: target_date.to_string(),
monthly_payment,
}
}
fn prior_and_current_snaps(prior_owed: f64, current_owed: f64) -> Vec<AccountTypeSnapshot> {
vec![
AccountTypeSnapshot {
account_type: "credit".to_string(),
month: "2026-04".to_string(),
balance: -prior_owed,
},
AccountTypeSnapshot {
account_type: "credit".to_string(),
month: "2026-05".to_string(),
balance: -current_owed,
},
]
}
#[test]
fn compute_debt_payoff_on_track_when_both_signals_on_track() {
let goal = debt_goal("2027-01-01", Some(1000.0));
let accounts = vec![credit_account(-7000.0)];
let snaps = prior_and_current_snaps(8000.0, 7000.0);
let result = compute_debt_payoff(&goal, &accounts, &snaps, "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "on track");
assert_eq!(result.on_schedule.as_deref(), Some("on track"));
assert_eq!(result.on_pace.as_deref(), Some("on track"));
}
#[test]
fn compute_debt_payoff_worst_of_picks_drifting() {
let goal = debt_goal("2027-01-01", Some(500.0));
let accounts = vec![credit_account(-7000.0)];
let snaps = prior_and_current_snaps(7500.0, 7000.0);
let result = compute_debt_payoff(&goal, &accounts, &snaps, "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "drifting");
assert_eq!(result.on_schedule.as_deref(), Some("drifting"));
assert_eq!(result.on_pace.as_deref(), Some("on track"));
}
#[test]
fn compute_debt_payoff_worst_of_picks_off() {
let goal = debt_goal("2027-01-01", Some(200.0));
let accounts = vec![credit_account(-7000.0)];
let snaps = prior_and_current_snaps(7200.0, 7000.0);
let result = compute_debt_payoff(&goal, &accounts, &snaps, "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "off");
assert_eq!(result.on_schedule.as_deref(), Some("off"));
assert_eq!(result.on_pace.as_deref(), Some("on track"));
}
#[test]
fn compute_debt_payoff_paid_off_is_on_track() {
let goal = debt_goal("2027-01-01", Some(500.0));
let accounts: Vec<Account> = vec![];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "on track");
assert_eq!(result.total_debt, 0.0);
assert_eq!(result.required_monthly, Some(0.0));
}
#[test]
fn compute_debt_payoff_target_passed_with_debt_is_off() {
let goal = debt_goal("2026-04-01", Some(500.0));
let accounts = vec![credit_account(-3000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "off");
assert!(result.months_to_target.unwrap_or(0) <= 0);
assert_eq!(result.required_monthly, None);
}
#[test]
fn compute_debt_payoff_no_monthly_payment_yields_unknown() {
let goal = debt_goal("2027-01-01", None);
let accounts = vec![credit_account(-7000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "unknown");
assert!(result.on_schedule.is_none());
assert!(result.on_pace.is_none());
assert_eq!(result.total_debt, 7000.0);
assert_eq!(result.months_to_target, Some(8));
assert!(result.required_monthly.is_some());
}
#[test]
fn compute_debt_payoff_no_prior_snapshot_uses_only_on_schedule() {
let goal = debt_goal("2027-01-01", Some(500.0));
let accounts = vec![credit_account(-7000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "drifting");
assert_eq!(result.on_schedule.as_deref(), Some("drifting"));
assert_eq!(result.on_pace, None);
}
#[test]
fn compute_debt_payoff_no_prior_snapshot_off_schedule() {
let goal = debt_goal("2027-01-01", Some(200.0));
let accounts = vec![credit_account(-7000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "off");
assert_eq!(result.on_schedule.as_deref(), Some("off"));
assert_eq!(result.on_pace, None);
}
#[test]
fn compute_debt_payoff_debt_grew_is_off_pace() {
let goal = debt_goal("2027-01-01", Some(1000.0));
let accounts = vec![credit_account(-8000.0)];
let snaps = prior_and_current_snaps(7000.0, 8000.0); let result = compute_debt_payoff(&goal, &accounts, &snaps, "2026-04", "2026-05", 2026, 5);
assert_eq!(result.on_pace.as_deref(), Some("off"));
}
#[test]
fn compute_debt_payoff_worst_of_driven_by_on_pace_off() {
let goal = debt_goal("2027-01-01", Some(1000.0));
let accounts = vec![credit_account(-8000.0)];
let snaps = prior_and_current_snaps(7000.0, 8000.0);
let result = compute_debt_payoff(&goal, &accounts, &snaps, "2026-04", "2026-05", 2026, 5);
assert_eq!(result.on_schedule.as_deref(), Some("on track"));
assert_eq!(result.on_pace.as_deref(), Some("off"));
assert_eq!(
result.status, "off",
"worst-of must surface on_pace 'off' over on_schedule 'on track'"
);
}
#[test]
fn months_to_target_malformed_dates_are_none() {
assert_eq!(months_to_target_from_date("not-a-date", 2026, 5), None);
assert_eq!(months_to_target_from_date("", 2026, 5), None);
assert_eq!(months_to_target_from_date("2027", 2026, 5), None);
}
#[test]
fn compute_debt_payoff_target_this_month_with_debt_is_off() {
let goal = debt_goal("2026-05-01", Some(500.0));
let accounts = vec![credit_account(-3000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "off");
assert_eq!(result.months_to_target, Some(0));
assert_eq!(result.required_monthly, None); }
#[test]
fn compute_debt_payoff_malformed_date_no_snapshot_is_unknown() {
let goal = debt_goal("garbage", Some(500.0));
let accounts = vec![credit_account(-5000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(
result.status, "unknown",
"malformed date must not produce 'off'"
);
assert_eq!(
result.months_to_target, None,
"months_to_target must be absent for malformed date"
);
assert_eq!(
result.required_monthly, None,
"required_monthly must be None when months unknown"
);
}
#[test]
fn compute_debt_payoff_empty_date_no_snapshot_is_unknown() {
let goal = debt_goal("", Some(500.0));
let accounts = vec![credit_account(-3000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "unknown");
assert_eq!(result.months_to_target, None);
}
#[test]
fn compute_debt_payoff_year_only_date_no_snapshot_is_unknown() {
let goal = debt_goal("2027", Some(500.0));
let accounts = vec![credit_account(-3000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "unknown");
assert_eq!(result.months_to_target, None);
}
#[test]
fn compute_debt_payoff_malformed_date_with_good_paydown_uses_on_pace() {
let goal = debt_goal("2027", Some(500.0));
let accounts = vec![credit_account(-7000.0)];
let snaps = prior_and_current_snaps(7600.0, 7000.0); let result = compute_debt_payoff(&goal, &accounts, &snaps, "2026-04", "2026-05", 2026, 5);
assert_ne!(
result.status, "off",
"malformed date must not force 'off' when pace is good"
);
assert_eq!(result.months_to_target, None);
assert_eq!(result.on_schedule, None);
assert_eq!(result.on_pace.as_deref(), Some("on track"));
assert_eq!(result.status, "on track");
}
#[test]
fn compute_debt_payoff_genuinely_past_valid_date_is_off() {
let goal = debt_goal("2026-04-01", Some(500.0));
let accounts = vec![credit_account(-3000.0)];
let result = compute_debt_payoff(&goal, &accounts, &[], "2026-04", "2026-05", 2026, 5);
assert_eq!(result.status, "off");
assert_eq!(result.months_to_target, Some(-1));
}
#[test]
fn unset_savings_rate_absent_even_when_emergency_fund_present() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(30000.0)];
let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf, &[], "2026-04", "2026-05", (2026, 5));
assert!(
result.savings_rate.is_none(),
"savings_rate should be absent when goal not set"
);
assert!(result.emergency_fund.is_some());
}
}