use crate::account_inventory::compute_account_inventory;
use crate::asset_allocation::compute_asset_allocation;
use crate::budget_review::compute_budget_review;
use crate::cashflow_forecast::compute_forecast;
use crate::client::MonarchClient;
use crate::error::MonarchError;
use crate::financial_overview::compute_overview;
use crate::goals::Goals;
use crate::inspect_transactions::{blank_to_none, compute_inspection, InspectFilter};
use crate::net_worth_trend::compute_trend;
use crate::progress_vs_goals::compute_progress;
use crate::recurring_scan::compute_scan;
use crate::retirement_readiness::{
compute_retirement_readiness, invested_financial_accounts, validate_withdrawal_rate,
WITHDRAWAL_RATE_DEFAULT,
};
use crate::savings_rate::{compute_savings_rate, SavingsRateResult};
use crate::spending_history::{compute_spending_history, range_for_months_count, SpendingHistory};
use crate::spending_report::compute_spending_report;
use crate::spending_report::compute_true_spending;
use crate::subscription_audit::compute_subscription_audit;
use crate::triage::{
build_category_suggestion_map, parse_raw_changes, partition_changeset, propose_changes,
resolve_category_names,
};
use rmcp::schemars;
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
service::RequestContext,
tool, tool_router, ErrorData as McpError, RoleServer, ServerHandler,
};
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct NetWorthTrendParams {
pub months: u32,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SpendingHistoryParams {
pub months: Option<u32>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SavingsRateParams {
pub months: Option<u32>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RetirementReadinessParams {
pub months: Option<u32>,
pub withdrawal_rate: Option<f64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ApplyChangesetParams {
pub changes: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct InspectTransactionsParams {
pub category: Option<String>,
pub merchant: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
#[derive(Clone)]
pub struct MonarchTools {
#[allow(dead_code)] tool_router: ToolRouter<MonarchTools>,
}
#[tool_router]
impl MonarchTools {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Return a snapshot of the household's current financial position: \
net worth, month-over-month change, this-month cash flow (income/spending/net), \
and balances by account type. Start every advising session here."
)]
async fn financial_overview(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute(&client).await {
Ok(overview) => serde_json::to_value(&overview)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Break down spending for a period by category, compare against \
budget and the prior period, surface anomalies and over-budget flags."
)]
async fn spending_report(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_spending(&client).await {
Ok(report) => serde_json::to_value(&report)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Identify uncategorized transactions and suggest category/tags/notes \
based on the household's own history. Returns a proposed changeset for review — \
nothing is written until apply_changeset is called."
)]
async fn triage_uncategorized(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_triage(&client).await {
Ok(result) => serde_json::to_value(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Apply an approved changeset, updating only category, tags, and notes. \
Category values are supplied as human-readable names (e.g. \"Pets\") and are resolved \
to Monarch category UUIDs server-side before the mutation is sent. Unknown category \
names are rejected and reported back — they are never sent to the API. \
Any other field (amount, account, merchant, date, or unknown fields) is also forbidden — \
entries containing them are rejected and reported back with the original transaction id. \
The set of transaction ids is never altered."
)]
async fn apply_changeset(
&self,
_ctx: RequestContext<RoleServer>,
Parameters(ApplyChangesetParams { changes }): Parameters<ApplyChangesetParams>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match apply_approved_changeset(&client, changes).await {
Ok(result) => serde_json::to_value(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Project the household's month-end cash position from current account \
balances, income and spending so far this period, and scheduled recurring charges. \
Flags a shortfall when upcoming bills are on track to exceed available funds."
)]
async fn cashflow_forecast(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_forecast(&client).await {
Ok(forecast) => serde_json::to_value(&forecast)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Show net worth month-by-month over a requested period, broken down \
by account type (depository, brokerage, credit, loan, etc.), with the biggest \
single mover and a total assets-versus-liabilities split."
)]
async fn net_worth_trend(
&self,
_ctx: RequestContext<RoleServer>,
Parameters(params): Parameters<NetWorthTrendParams>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_trend(&client, params.months).await {
Ok(trend) => serde_json::to_value(&trend)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Scan recurring charges for amount drift ('creeping' subscriptions \
whose price has quietly changed) and list upcoming renewals due this period. \
Stable subscriptions are reported but not flagged."
)]
async fn recurring_scan(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_scan(&client).await {
Ok(scan) => serde_json::to_value(&scan)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "List every recurring charge ranked by annualized cost, with \
total monthly and yearly subscription burn. Use this to answer: 'what is my \
full subscription load and what's the fat to cut?' Each entry includes a \
monthly-equivalent amount (normalized from the stream's cadence) and the \
annualized cost. Approximate streams (utilities, variable charges) are \
included and flagged. Income streams are excluded. \
Pairs with recurring_scan for the anomaly lens (creeping/upcoming)."
)]
async fn subscription_audit(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_audit(&client).await {
Ok(audit) => serde_json::to_value(&audit)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Drill into transactions for a date range, optionally narrowed by \
category and/or merchant. Returns every matching transaction including its \
**id** (required by apply_changeset to re-categorize), plus a compound summary \
with total count, net amount, and an inflow-vs-outflow split so refunds are \
visible alongside charges.\n\n\
Two-step re-categorization workflow:\n\
1. Call inspect_transactions(category=\"Pets\") to see all Pets transactions \
with their ids and amounts.\n\
2. Call apply_changeset(changes=[{\"id\": \"<id>\", \"category\": \"Veterinary\"}]) \
to correct any mis-categorized entry.\n\n\
This tool is read-only: it never modifies data. Use it to diagnose anomalies \
(e.g. a $12k Pets spike or a $3.3k Medical charge) or to surface ids for \
already-categorized transactions that need correction via apply_changeset."
)]
async fn inspect_transactions(
&self,
_ctx: RequestContext<RoleServer>,
Parameters(params): Parameters<InspectTransactionsParams>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_inspection(&client, params).await {
Ok(result) => serde_json::to_value(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "List all accounts grouped into retirement-planning buckets: \
tax_advantaged (401k, Roth IRA, HSA), taxable_brokerage, cash (depository), \
other_assets (vehicles), and liabilities (credit cards, loans). \
Each account shows its balance, Monarch type/subtype, hidden flag, and whether \
its subtype was recognized. Includes a net-worth rollup for cross-checking \
against financial_overview."
)]
async fn account_inventory(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_inventory(&client).await {
Ok(inventory) => serde_json::to_value(&inventory)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Split net worth by asset class: equities (all brokerage accounts — \
401k, Roth, taxable), cash (depository), real_estate, crypto, other_assets (vehicles), \
and liabilities. Each class shows its dollar total and percent of gross assets. \
Includes gross_assets, total_liabilities, and net_worth rollup. \
Note: Monarch does not expose per-holding data, so equity vs. bond breakdown within \
an account is not available — all brokerage accounts are classified as equities. \
Unrecognized account subtypes are bucketed as 'other' and flagged. \
Use as the asset-class lens alongside account_inventory (the tax-treatment lens)."
)]
async fn asset_allocation(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_allocation(&client).await {
Ok(allocation) => serde_json::to_value(&allocation)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Retirement-readiness snapshot: compares the investable portfolio \
against a safe-withdrawal-rate projection of the annualised spending baseline. \
Reports sustainable_annual_withdrawal, coverage_ratio (withdrawal / spend), \
target_portfolio (the 25x spend number at 4%), and surplus_or_gap. \
Invested assets = Equities-class accounts only (brokerage, 401k, Roth, HSA, \
stock plan) — real estate, cash, crypto, vehicles, and liabilities are excluded \
from the SWR base (ADR 0016). All assumptions (withdrawal rate, spend window, \
what counts as invested) are surfaced in the response so the numbers are \
self-interpreting.\n\n\
Params (all optional):\n\
- months: trailing complete months for the spend baseline (default 6, max 24)\n\
- withdrawal_rate: decimal fraction, default 0.04 (4% rule), range [0.02, 0.10]"
)]
async fn retirement_readiness(
&self,
_ctx: RequestContext<RoleServer>,
Parameters(params): Parameters<RetirementReadinessParams>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_retirement_readiness(&client, params).await {
Ok(result) => serde_json::to_value(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(MonarchError::InvalidInput(msg)) => {
json!({ "error": msg })
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Show mid-month budget pacing per expense category: how much of each \
category's budget has been spent relative to how far through the month we are. \
Returns per-category pace_status (under/on_track/over/over_budget), budget, spent, \
remaining, and a rollup with totals and counts. Use this during the month to catch \
categories tracking hot before they go over budget."
)]
async fn budget_review(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_budget_review(&client).await {
Ok(review) => serde_json::to_value(&review)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Compute per-month true spending over a multi-month range (default: \
last 6 complete months). Returns compact per-month aggregates — total true spending, \
by-category breakdown, and a fixed-vs-discretionary split — never raw transactions. \
Income and transfers are excluded (same exclusion rules as spending_report). \
Use this for retirement-spending analysis or building a multi-month baseline.\n\n\
Params (all optional):\n\
- months: last N complete months (default 6, max 24)\n\
- start_date / end_date: explicit ISO-8601 range (overrides months)"
)]
async fn spending_history(
&self,
_ctx: RequestContext<RoleServer>,
Parameters(params): Parameters<SpendingHistoryParams>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_history(&client, params).await {
Ok(history) => serde_json::to_value(&history)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(MonarchError::InvalidInput(msg)) => {
json!({ "error": msg })
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Compute monthly income, true spending, net savings, and savings rate \
over a multi-month range (default: last 6 complete months). Returns compact \
per-month aggregates — never raw transactions. Income = positive income-group \
transactions; true spending uses the same exclusion rules as spending_history \
(transfers and credit-card payments excluded). A per-month savings rate and a \
window-average rate are included; months with zero income omit the rate field.\n\n\
Params (all optional):\n\
- months: last N complete months (default 6, max 24)\n\
- start_date / end_date: explicit ISO-8601 range (overrides months)"
)]
async fn savings_rate(
&self,
_ctx: RequestContext<RoleServer>,
Parameters(params): Parameters<SavingsRateParams>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_savings_rate(&client, params).await {
Ok(result) => serde_json::to_value(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(MonarchError::InvalidInput(msg)) => {
json!({ "error": msg })
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
#[tool(
description = "Measure actual finances against the household's remembered goals \
(savings rate, emergency-fund runway, debt payoff). Reports each goal as \
on-track, drifting, or off, with the lever to pull."
)]
async fn progress_vs_goals(
&self,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let payload = match fetch_and_compute_progress(&client).await {
Ok(progress) => serde_json::to_value(&progress)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
Err(MonarchError::SessionExpired) => {
json!({
"error": "Session expired — re-authenticate by running `monarch-mcp login`"
})
}
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
};
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&payload)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
)]))
}
}
impl Default for MonarchTools {
fn default() -> Self {
Self::new()
}
}
fn civil_to_epoch_day(year: i64, month: i64, day: i64) -> i64 {
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;
era * 146_097 + doe - 719_468
}
fn parse_iso_date_to_epoch_day(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) {
return None;
}
if !(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;
}
Some(civil_to_epoch_day(year, month, day))
}
fn today_epoch_day() -> i64 {
if let Ok(now_override) = std::env::var("MONARCH_NOW") {
if let Some(day) = parse_iso_date_to_epoch_day(&now_override) {
return day;
}
}
use chrono::{Datelike, Local};
let today = Local::now().date_naive();
civil_to_epoch_day(
today.year() as i64,
today.month() as i64,
today.day() as i64,
)
}
fn current_month_range_for_day(day: i64) -> (String, String) {
let (year, month, _) = epoch_days_to_ymd(day);
let start = format!("{year:04}-{month:02}-01");
let last_day = days_in_month(year, month);
let end = format!("{year:04}-{month:02}-{last_day:02}");
(start, end)
}
fn prior_month_range_for_day(day: i64) -> (String, String) {
let (mut year, mut month, _) = epoch_days_to_ymd(day);
if month == 1 {
year -= 1;
month = 12;
} else {
month -= 1;
}
let last_day = days_in_month(year, month);
let start = format!("{year:04}-{month:02}-01");
let end = format!("{year:04}-{month:02}-{last_day:02}");
(start, end)
}
fn current_month_range() -> (String, String) {
current_month_range_for_day(today_epoch_day())
}
fn prior_month_range() -> (String, String) {
prior_month_range_for_day(today_epoch_day())
}
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,
}
}
async fn fetch_current_month_transactions(
client: &MonarchClient,
start: &str,
end: &str,
) -> Result<Vec<crate::client::Transaction>, MonarchError> {
client.get_transactions(start, end, i32::MAX as u32).await
}
async fn fetch_and_compute(
client: &MonarchClient,
) -> Result<crate::financial_overview::OverviewResult, MonarchError> {
let (cur_start, cur_end) = current_month_range();
let (pri_start, pri_end) = prior_month_range();
let (accounts, cashflow, history) = tokio::try_join!(
client.get_accounts(),
client.get_cashflow(&cur_start, &cur_end, &pri_start, &pri_end),
client.get_net_worth_history(&pri_start, &pri_end),
)?;
let transactions = fetch_current_month_transactions(client, &cur_start, &cur_end).await?;
Ok(compute_overview(
&accounts,
&cashflow,
&transactions,
&history,
))
}
async fn fetch_and_compute_spending(
client: &MonarchClient,
) -> Result<crate::spending_report::SpendingReport, MonarchError> {
let (cur_start, cur_end) = current_month_range();
let (pri_start, pri_end) = prior_month_range();
let (budgets, cashflow) = tokio::try_join!(
client.get_budgets(&cur_start, &cur_end),
client.get_cashflow(&cur_start, &cur_end, &pri_start, &pri_end),
)?;
let transactions = fetch_current_month_transactions(client, &cur_start, &cur_end).await?;
Ok(compute_spending_report(&transactions, &budgets, &cashflow))
}
async fn fetch_and_compute_triage(
client: &MonarchClient,
) -> Result<crate::triage::TriageResult, MonarchError> {
let (cur_start, cur_end) = current_month_range();
let (all_transactions, uncategorized) = tokio::try_join!(
client.get_transactions(&cur_start, &cur_end, 500),
client.get_transactions_needing_review(),
)?;
let suggestion_map = build_category_suggestion_map(&all_transactions);
Ok(propose_changes(&uncategorized, &suggestion_map))
}
async fn fetch_and_compute_progress(
client: &MonarchClient,
) -> Result<crate::progress_vs_goals::GoalsProgress, MonarchError> {
let goals = Goals::load_from_env().map_err(|e| MonarchError::Internal(e.to_string()))?;
let today_day = today_epoch_day();
let (cur_start, cur_end) = current_month_range_for_day(today_day);
let (pri_start, pri_end) = prior_month_range_for_day(today_day);
let (today_year, today_month, _) = epoch_days_to_ymd(today_day);
let prior_month_label = &pri_start[..7]; let current_month_label = &cur_start[..7];
let snapshots = if goals.debt_payoff.is_some() {
client.get_snapshots_by_account_type(&pri_start).await?
} else {
vec![]
};
let (accounts, cashflow) = tokio::try_join!(
client.get_accounts(),
client.get_cashflow(&cur_start, &cur_end, &pri_start, &pri_end),
)?;
Ok(compute_progress(
&goals,
&accounts,
&cashflow,
&snapshots,
prior_month_label,
current_month_label,
(today_year, today_month),
))
}
async fn fetch_and_compute_scan(
client: &MonarchClient,
) -> Result<crate::recurring_scan::ScanResult, MonarchError> {
let (cur_start, cur_end) = current_month_range();
let items = client.get_recurring_for_scan(&cur_start, &cur_end).await?;
Ok(compute_scan(&items))
}
fn audit_window_for_day(day: i64) -> (String, String) {
let (year, month, dom) = epoch_days_to_ymd(day);
let start = format!("{year:04}-{month:02}-{dom:02}");
let mut ey = year;
let mut em = month;
for _ in 0..12 {
if em == 12 {
ey += 1;
em = 1;
} else {
em += 1;
}
}
let last_day = days_in_month(ey, em);
let end = format!("{ey:04}-{em:02}-{last_day:02}");
(start, end)
}
async fn fetch_and_compute_audit(
client: &MonarchClient,
) -> Result<crate::subscription_audit::AuditResult, MonarchError> {
let (audit_start, audit_end) = audit_window_for_day(today_epoch_day());
let items = client
.get_recurring_for_audit(&audit_start, &audit_end)
.await?;
Ok(compute_subscription_audit(&items))
}
async fn fetch_and_compute_inspection(
client: &MonarchClient,
params: InspectTransactionsParams,
) -> Result<crate::inspect_transactions::InspectionResult, MonarchError> {
let (default_start, default_end) = current_month_range();
let start = blank_to_none(params.start_date).unwrap_or(default_start);
let end = blank_to_none(params.end_date).unwrap_or(default_end);
let transactions = client.get_transactions(&start, &end, 500).await?;
let filter = InspectFilter {
category: blank_to_none(params.category),
merchant: blank_to_none(params.merchant),
};
Ok(compute_inspection(&transactions, &filter))
}
async fn fetch_and_compute_forecast(
client: &MonarchClient,
) -> Result<crate::cashflow_forecast::ForecastResult, MonarchError> {
let (cur_start, cur_end) = current_month_range();
let (accounts, recurring) = tokio::try_join!(
client.get_accounts(),
client.get_recurring(&cur_start, &cur_end),
)?;
let current_balance: f64 = accounts
.iter()
.filter(|a| a.account_type.name == "depository")
.map(|a| a.current_balance)
.sum();
Ok(compute_forecast(current_balance, &recurring))
}
async fn fetch_and_compute_inventory(
client: &MonarchClient,
) -> Result<crate::account_inventory::AccountInventory, MonarchError> {
let accounts = client.get_accounts().await?;
Ok(compute_account_inventory(&accounts))
}
async fn fetch_and_compute_allocation(
client: &MonarchClient,
) -> Result<crate::asset_allocation::AssetAllocation, MonarchError> {
let accounts = client.get_accounts().await?;
Ok(compute_asset_allocation(&accounts))
}
async fn fetch_and_compute_budget_review(
client: &MonarchClient,
) -> Result<crate::budget_review::BudgetReview, MonarchError> {
let today_day = today_epoch_day();
let (cur_start, cur_end) = current_month_range_for_day(today_day);
let (year, month, today_day_of_month) = epoch_days_to_ymd(today_day);
let dim = days_in_month(year, month);
let budgets = client.get_budgets(&cur_start, &cur_end).await?;
let transactions = fetch_current_month_transactions(client, &cur_start, &cur_end).await?;
Ok(compute_budget_review(
&budgets,
&transactions,
today_day_of_month,
dim,
))
}
async fn fetch_and_compute_savings_rate(
client: &MonarchClient,
params: SavingsRateParams,
) -> Result<SavingsRateResult, MonarchError> {
let today_day = today_epoch_day();
let (start, end) =
resolve_history_range(today_day, params.months, params.start_date, params.end_date)
.map_err(MonarchError::InvalidInput)?;
let transactions = client
.get_transactions(&start, &end, i32::MAX as u32)
.await?;
Ok(compute_savings_rate(&transactions, &start, &end))
}
async fn fetch_and_compute_retirement_readiness(
client: &MonarchClient,
params: RetirementReadinessParams,
) -> Result<crate::retirement_readiness::RetirementReadiness, MonarchError> {
let withdrawal_rate =
validate_withdrawal_rate(params.withdrawal_rate.unwrap_or(WITHDRAWAL_RATE_DEFAULT))
.map_err(MonarchError::InvalidInput)?;
let today_day = today_epoch_day();
let n_months = params.months.unwrap_or(6).clamp(1, 24);
let (start, end) = range_for_months_count(today_day, n_months);
let (accounts_result, transactions_result) = tokio::join!(
client.get_accounts(),
client.get_transactions(&start, &end, i32::MAX as u32),
);
let accounts = accounts_result?;
let transactions = transactions_result?;
let invested_assets: f64 = invested_financial_accounts(&accounts)
.iter()
.map(|a| a.current_balance)
.sum();
let window_true_spending = compute_true_spending(&transactions);
let annual_baseline_spend = (window_true_spending / f64::from(n_months)) * 12.0;
Ok(compute_retirement_readiness(
invested_assets,
annual_baseline_spend,
withdrawal_rate,
n_months,
))
}
async fn fetch_and_compute_trend(
client: &MonarchClient,
months: u32,
) -> Result<crate::net_worth_trend::TrendResult, MonarchError> {
let start_date = months_ago_start(months);
let snapshots = client.get_snapshots_by_account_type(&start_date).await?;
Ok(compute_trend(&snapshots))
}
fn months_ago_start_for_day(day: i64, n: u32) -> String {
let (mut year, mut month, _) = epoch_days_to_ymd(day);
for _ in 0..n {
if month == 1 {
year -= 1;
month = 12;
} else {
month -= 1;
}
}
format!("{year:04}-{month:02}-01")
}
fn months_ago_start(n: u32) -> String {
months_ago_start_for_day(today_epoch_day(), n)
}
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 resolve_history_range(
today_day: i64,
months: Option<u32>,
start_date: Option<String>,
end_date: Option<String>,
) -> Result<(String, String), String> {
match (start_date, end_date) {
(Some(s), Some(e)) => {
let start_day = parse_iso_date_to_epoch_day(&s)
.ok_or_else(|| format!("invalid start_date {s:?}: must be YYYY-MM-DD"))?;
let end_day = parse_iso_date_to_epoch_day(&e)
.ok_or_else(|| format!("invalid end_date {e:?}: must be YYYY-MM-DD"))?;
if start_day > end_day {
return Err(format!(
"start_date {s:?} is after end_date {e:?}: range must be start ≤ end"
));
}
Ok((s, e))
}
(Some(_), None) => Err(
"provide BOTH start_date and end_date, or neither (got only start_date)".to_string(),
),
(None, Some(_)) => {
Err("provide BOTH start_date and end_date, or neither (got only end_date)".to_string())
}
(None, None) => {
let n = months.unwrap_or(6).clamp(1, 24);
Ok(range_for_months_count(today_day, n))
}
}
}
async fn fetch_and_compute_history(
client: &MonarchClient,
params: SpendingHistoryParams,
) -> Result<SpendingHistory, MonarchError> {
let today_day = today_epoch_day();
let (start, end) =
resolve_history_range(today_day, params.months, params.start_date, params.end_date)
.map_err(MonarchError::InvalidInput)?;
let transactions = client
.get_transactions(&start, &end, i32::MAX as u32)
.await?;
Ok(compute_spending_history(&transactions, &start, &end))
}
async fn apply_approved_changeset(
client: &MonarchClient,
raw_changes: Vec<serde_json::Value>,
) -> Result<crate::triage::ApplyResult, MonarchError> {
let entries = parse_raw_changes(raw_changes);
let mut result = partition_changeset(&entries);
if result.applied_changes.iter().any(|c| c.category.is_some()) {
let categories = client.get_categories().await?;
let (resolved, new_rejections) =
resolve_category_names(&categories, result.applied_changes);
result.applied_changes = resolved;
result.rejected_changes.extend(new_rejections);
result.transaction_count = result.applied_changes.len() + result.rejected_changes.len();
}
for change in &result.applied_changes {
client
.update_transaction(
&change.id,
change.category.as_deref(),
change.tags.clone(),
change.notes.as_deref(),
)
.await?;
}
Ok(result)
}
#[rmcp::tool_handler]
impl ServerHandler for MonarchTools {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(
"monarch-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_protocol_version(ProtocolVersion::V_2024_11_05)
.with_instructions(
"Monarch Money budgeting advisor. Tools: financial_overview, \
spending_report, budget_review, spending_history, savings_rate, \
triage_uncategorized, inspect_transactions, apply_changeset, \
progress_vs_goals, cashflow_forecast, net_worth_trend, recurring_scan, \
subscription_audit, account_inventory, asset_allocation, \
retirement_readiness."
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn day(date: &str) -> i64 {
parse_iso_date_to_epoch_day(date).unwrap_or_else(|| panic!("bad test date: {date}"))
}
#[test]
fn civil_to_epoch_day_agrees_with_parse_for_unix_epoch() {
assert_eq!(civil_to_epoch_day(1970, 1, 1), 0);
assert_eq!(
civil_to_epoch_day(1970, 1, 1),
parse_iso_date_to_epoch_day("1970-01-01").unwrap()
);
}
#[test]
fn civil_to_epoch_day_agrees_with_parse_for_mid_month() {
let via_parse = parse_iso_date_to_epoch_day("2026-05-15").unwrap();
assert_eq!(civil_to_epoch_day(2026, 5, 15), via_parse);
}
#[test]
fn civil_to_epoch_day_agrees_with_parse_for_leap_day() {
let via_parse = parse_iso_date_to_epoch_day("2024-02-29").unwrap();
assert_eq!(civil_to_epoch_day(2024, 2, 29), via_parse);
}
#[test]
fn civil_to_epoch_day_agrees_with_parse_for_year_end() {
let via_parse = parse_iso_date_to_epoch_day("2025-12-31").unwrap();
assert_eq!(civil_to_epoch_day(2025, 12, 31), via_parse);
}
#[test]
fn civil_to_epoch_day_round_trips_through_epoch_days_to_ymd() {
use chrono::{Datelike, Local};
let today = Local::now().date_naive();
let d = civil_to_epoch_day(
today.year() as i64,
today.month() as i64,
today.day() as i64,
);
let (ry, rm, rd) = epoch_days_to_ymd(d);
assert_eq!(
(ry, rm, rd),
(today.year() as i64, today.month(), today.day())
);
}
#[test]
fn local_tz_wins_over_utc_at_month_boundary() {
use chrono::{Datelike, FixedOffset, TimeZone, Utc};
let instant = Utc.with_ymd_and_hms(2026, 6, 1, 1, 0, 0).unwrap();
let local = instant
.with_timezone(&FixedOffset::west_opt(7 * 3600).unwrap())
.date_naive();
assert_eq!(
(local.year(), local.month(), local.day()),
(2026, 5, 31),
"UTC-07 should see May 31, not June 1"
);
let local_day = civil_to_epoch_day(
local.year() as i64,
local.month() as i64,
local.day() as i64,
);
assert_eq!(
current_month_range_for_day(local_day),
("2026-05-01".to_string(), "2026-05-31".to_string()),
"local date must produce May range"
);
let utc = instant.date_naive();
let utc_day = civil_to_epoch_day(utc.year() as i64, utc.month() as i64, utc.day() as i64);
assert_eq!(
current_month_range_for_day(utc_day),
("2026-06-01".to_string(), "2026-06-30".to_string()),
"UTC date produces June range"
);
}
#[test]
fn ahead_of_utc_local_rolls_to_next_month() {
use chrono::{Datelike, FixedOffset, TimeZone, Utc};
let instant = Utc.with_ymd_and_hms(2026, 5, 31, 23, 0, 0).unwrap();
let local = instant
.with_timezone(&FixedOffset::east_opt(14 * 3600).unwrap())
.date_naive();
assert_eq!(
(local.year(), local.month(), local.day()),
(2026, 6, 1),
"UTC+14 should see June 1, not May 31"
);
let local_day = civil_to_epoch_day(
local.year() as i64,
local.month() as i64,
local.day() as i64,
);
assert_eq!(
current_month_range_for_day(local_day),
("2026-06-01".to_string(), "2026-06-30".to_string()),
"local date must produce June range"
);
let utc = instant.date_naive();
let utc_day = civil_to_epoch_day(utc.year() as i64, utc.month() as i64, utc.day() as i64);
assert_eq!(
current_month_range_for_day(utc_day),
("2026-05-01".to_string(), "2026-05-31".to_string()),
"UTC date produces May range"
);
}
#[test]
fn parse_iso_date_round_trips_known_epoch() {
assert_eq!(parse_iso_date_to_epoch_day("1970-01-01"), Some(0));
}
#[test]
fn parse_iso_date_round_trips_2026_05_15() {
let d = parse_iso_date_to_epoch_day("2026-05-15").unwrap();
assert_eq!(epoch_days_to_ymd(d), (2026, 5, 15));
}
#[test]
fn parse_iso_date_round_trips_2024_02_29_leap() {
let d = parse_iso_date_to_epoch_day("2024-02-29").unwrap();
assert_eq!(epoch_days_to_ymd(d), (2024, 2, 29));
}
#[test]
fn parse_iso_date_returns_none_for_too_few_parts() {
assert_eq!(parse_iso_date_to_epoch_day("2026-13"), None);
}
#[test]
fn parse_iso_date_returns_none_for_garbage() {
assert_eq!(parse_iso_date_to_epoch_day("garbage"), None);
}
#[test]
fn parse_iso_date_rejects_out_of_range_month() {
assert_eq!(parse_iso_date_to_epoch_day("2026-13-01"), None);
assert_eq!(parse_iso_date_to_epoch_day("2026-13-40"), None);
}
#[test]
fn parse_iso_date_rejects_zero_month_and_day() {
assert_eq!(parse_iso_date_to_epoch_day("2026-00-00"), None);
assert_eq!(parse_iso_date_to_epoch_day("2026-00-15"), None);
assert_eq!(parse_iso_date_to_epoch_day("2026-05-00"), None);
}
#[test]
fn parse_iso_date_rejects_day_past_month_end() {
assert_eq!(parse_iso_date_to_epoch_day("2026-02-30"), None);
assert_eq!(parse_iso_date_to_epoch_day("2026-04-31"), None);
assert_eq!(parse_iso_date_to_epoch_day("2026-06-31"), None);
}
#[test]
fn parse_iso_date_year_range_boundaries() {
assert_eq!(parse_iso_date_to_epoch_day("0000-06-15"), None);
assert!(parse_iso_date_to_epoch_day("0001-06-15").is_some());
assert!(parse_iso_date_to_epoch_day("9999-06-15").is_some());
assert_eq!(parse_iso_date_to_epoch_day("10000-06-15"), None);
}
#[test]
fn parse_iso_date_does_not_panic_on_huge_year() {
assert_eq!(
parse_iso_date_to_epoch_day("9223372036854775807-06-15"),
None
);
assert_eq!(parse_iso_date_to_epoch_day("999999-06-15"), None);
}
#[test]
fn parse_iso_date_accepts_valid_dates() {
assert!(parse_iso_date_to_epoch_day("2026-04-30").is_some());
assert!(parse_iso_date_to_epoch_day("2024-02-29").is_some());
assert!(parse_iso_date_to_epoch_day("2026-05-31").is_some());
}
#[test]
fn current_month_range_last_day_of_may_2026() {
let (start, end) = current_month_range_for_day(day("2026-05-31"));
assert_eq!(start, "2026-05-01");
assert_eq!(end, "2026-05-31");
}
#[test]
fn current_month_range_for_mid_month() {
let (start, end) = current_month_range_for_day(day("2026-05-15"));
assert_eq!(start, "2026-05-01");
assert_eq!(end, "2026-05-31");
}
#[test]
fn current_month_range_leap_february() {
let (start, end) = current_month_range_for_day(day("2024-02-10"));
assert_eq!(start, "2024-02-01");
assert_eq!(end, "2024-02-29");
}
#[test]
fn current_month_range_non_leap_february() {
let (start, end) = current_month_range_for_day(day("2026-02-10"));
assert_eq!(start, "2026-02-01");
assert_eq!(end, "2026-02-28");
}
#[test]
fn prior_month_range_last_day_of_may_2026() {
let (start, end) = prior_month_range_for_day(day("2026-05-31"));
assert_eq!(start, "2026-04-01");
assert_eq!(end, "2026-04-30");
}
#[test]
fn prior_month_range_crosses_year_boundary() {
let (start, end) = prior_month_range_for_day(day("2026-01-15"));
assert_eq!(start, "2025-12-01");
assert_eq!(end, "2025-12-31");
}
#[test]
fn prior_month_range_march_2024_gives_leap_february() {
let (start, end) = prior_month_range_for_day(day("2024-03-10"));
assert_eq!(start, "2024-02-01");
assert_eq!(end, "2024-02-29");
}
#[test]
fn months_ago_start_n0_returns_current_month_start() {
assert_eq!(months_ago_start_for_day(day("2026-05-15"), 0), "2026-05-01");
}
#[test]
fn months_ago_start_n12_crosses_year() {
assert_eq!(
months_ago_start_for_day(day("2026-05-15"), 12),
"2025-05-01"
);
}
#[test]
fn months_ago_start_n6_from_february_crosses_year() {
assert_eq!(months_ago_start_for_day(day("2026-02-15"), 6), "2025-08-01");
}
#[tokio::test]
async fn financial_overview_concurrent_burst_exercises_production_fetch_path() {
if std::env::var("MONARCH_LIVE")
.map(|v| v == "1")
.unwrap_or(false)
{
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = crate::client::MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let overview = fetch_and_compute(&client).await.expect(
"fetch_and_compute must succeed with i32::MAX limit — \
if this fails, the i32::MAX fix may have been reverted to u32::MAX",
);
assert!(
overview.net_worth.is_finite() && overview.net_worth_change.is_finite(),
"net_worth and net_worth_change must be finite, got {} / {}",
overview.net_worth,
overview.net_worth_change,
);
let cf = &overview.cashflow;
assert!(
(cf.net - (cf.income - cf.spending)).abs() < 1e-6,
"cashflow identity net == income − spending must hold: net={}, income={}, spending={}",
cf.net,
cf.income,
cf.spending,
);
eprintln!(
"net_worth: {:.2}, income: {:.2}, spending: {:.2}, net: {:.2}",
overview.net_worth, cf.income, cf.spending, cf.net,
);
} else {
eprintln!("SKIP: set MONARCH_LIVE=1 to run live integration tests");
}
}
#[tokio::test]
async fn spending_report_concurrent_burst_exercises_production_fetch_path() {
if std::env::var("MONARCH_LIVE")
.map(|v| v == "1")
.unwrap_or(false)
{
let base = std::env::var("MONARCH_BASE").ok().filter(|s| !s.is_empty());
let mut client = crate::client::MonarchClient::new(base);
client.resolve_token_from_env_or_disk();
let report = fetch_and_compute_spending(&client).await.expect(
"fetch_and_compute_spending must succeed with i32::MAX limit — \
if this fails, the i32::MAX fix may have been reverted to u32::MAX",
);
let category_sum: f64 = report.by_category.values().map(|c| c.spent).sum();
assert!(
(report.total_spent - category_sum).abs() < 1e-6,
"total_spent must equal the sum of per-category magnitudes: \
total_spent={}, category_sum={}",
report.total_spent,
category_sum,
);
eprintln!(
"total_spent: {:.2}, category_sum: {:.2}",
report.total_spent, category_sum,
);
} else {
eprintln!("SKIP: set MONARCH_LIVE=1 to run live integration tests");
}
}
#[test]
fn resolve_history_range_valid_explicit_range_passes_through() {
let today = day("2026-06-08");
let result = resolve_history_range(
today,
None,
Some("2026-01-01".into()),
Some("2026-05-31".into()),
);
assert_eq!(
result,
Ok(("2026-01-01".to_string(), "2026-05-31".to_string()))
);
}
#[test]
fn resolve_history_range_garbage_start_returns_err() {
let today = day("2026-06-08");
let result = resolve_history_range(
today,
None,
Some("garbage".into()),
Some("2026-05-31".into()),
);
assert!(
result.is_err(),
"Expected Err for garbage start, got: {result:?}"
);
}
#[test]
fn resolve_history_range_garbage_end_returns_err() {
let today = day("2026-06-08");
let result = resolve_history_range(
today,
None,
Some("2026-01-01".into()),
Some("garbage".into()),
);
assert!(
result.is_err(),
"Expected Err for garbage end, got: {result:?}"
);
}
#[test]
fn resolve_history_range_invalid_month_13_returns_err() {
let today = day("2026-06-08");
let result = resolve_history_range(
today,
None,
Some("2026-13-01".into()),
Some("2026-05-31".into()),
);
assert!(
result.is_err(),
"Expected Err for month=13, got: {result:?}"
);
}
#[test]
fn resolve_history_range_reversed_dates_returns_err() {
let today = day("2026-06-08");
let result = resolve_history_range(
today,
None,
Some("2026-05-01".into()),
Some("2026-01-31".into()),
);
assert!(
result.is_err(),
"Expected Err for start > end, got: {result:?}"
);
}
#[test]
fn resolve_history_range_no_explicit_dates_falls_back_to_months_default() {
let today = day("2026-06-08");
let result = resolve_history_range(today, None, None, None);
assert_eq!(
result,
Ok(("2025-12-01".to_string(), "2026-05-31".to_string()))
);
}
#[test]
fn resolve_history_range_explicit_months_overrides_default() {
let today = day("2026-06-08");
let result = resolve_history_range(today, Some(3), None, None);
assert_eq!(
result,
Ok(("2026-03-01".to_string(), "2026-05-31".to_string()))
);
}
#[test]
fn resolve_history_range_only_start_date_returns_err() {
let today = day("2026-06-08");
let result = resolve_history_range(today, None, Some("2026-01-01".into()), None);
assert!(
result.is_err(),
"Expected Err when only start_date is provided, got: {result:?}"
);
let msg = result.unwrap_err();
assert!(
msg.contains("start_date"),
"Error message should mention start_date, got: {msg:?}"
);
}
#[test]
fn resolve_history_range_only_end_date_returns_err() {
let today = day("2026-06-08");
let result = resolve_history_range(today, None, None, Some("2026-05-31".into()));
assert!(
result.is_err(),
"Expected Err when only end_date is provided, got: {result:?}"
);
let msg = result.unwrap_err();
assert!(
msg.contains("end_date"),
"Error message should mention end_date, got: {msg:?}"
);
}
#[test]
fn audit_window_starts_today_and_ends_12_months_forward() {
let (start, end) = audit_window_for_day(day("2026-06-10"));
assert_eq!(start, "2026-06-10");
assert_eq!(end, "2027-06-30");
}
#[test]
fn audit_window_spans_at_least_12_months() {
let start_day = day("2026-01-01");
let (start, end) = audit_window_for_day(start_day);
assert_eq!(start, "2026-01-01");
assert_eq!(end, "2027-01-31");
let end_day = parse_iso_date_to_epoch_day(&end).unwrap();
assert!(
end_day - start_day >= 365,
"audit window must span at least 365 days, got {}",
end_day - start_day
);
}
#[test]
fn audit_window_crosses_year_boundary_correctly() {
let (start, end) = audit_window_for_day(day("2026-09-15"));
assert_eq!(start, "2026-09-15");
assert_eq!(end, "2027-09-30");
}
#[test]
fn audit_window_handles_december_start() {
let (start, end) = audit_window_for_day(day("2026-12-01"));
assert_eq!(start, "2026-12-01");
assert_eq!(end, "2027-12-31");
}
#[test]
fn audit_window_end_has_correct_last_day_for_february() {
let (start, end) = audit_window_for_day(day("2026-02-15"));
assert_eq!(start, "2026-02-15");
assert_eq!(end, "2027-02-28");
}
#[test]
fn audit_window_end_has_correct_last_day_for_leap_february() {
let (start, end) = audit_window_for_day(day("2027-02-01"));
assert_eq!(start, "2027-02-01");
assert_eq!(end, "2028-02-29");
}
}