use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::plan::{Dollars, FilingStatus};
mod index;
pub use index::Inflation;
use index::inflate;
pub(crate) use index::scale;
const PARAMS_SCHEMA_VERSION: u32 = 1;
const EMBEDDED: &[&str] = &[include_str!("../../tax/2026.toml")];
#[derive(Debug, thiserror::Error)]
pub enum ParamsError {
#[error("failed to read {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("failed to parse {path}: {source}")]
Parse {
path: String,
source: Box<toml::de::Error>,
},
#[error(
"{path}: unsupported schema version {found} (this build reads {PARAMS_SCHEMA_VERSION})"
)]
Schema {
path: String,
found: u32,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PerStatus<T> {
pub single: T,
pub married_joint: T,
}
impl<T> PerStatus<T> {
#[must_use]
pub fn for_status(&self, status: FilingStatus) -> &T {
match status {
FilingStatus::Single => &self.single,
FilingStatus::MarriedJoint => &self.married_joint,
}
}
}
impl<T: Copy> PerStatus<T> {
#[must_use]
pub fn get(&self, status: FilingStatus) -> T {
*self.for_status(status)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Bracket {
pub over: Dollars,
pub rate: f64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unindexed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Deductions {
pub standard: PerStatus<Dollars>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Ltcg {
pub middle_rate: f64,
pub top_rate: f64,
pub zero_until: PerStatus<Dollars>,
pub fifteen_until: PerStatus<Dollars>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SocialSecurityThresholds {
pub provisional_base: PerStatus<Dollars>,
pub provisional_upper: PerStatus<Dollars>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub benefit: Option<BenefitParams>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct BenefitParams {
pub wage_growth: f64,
pub wage_index: BTreeMap<i16, f64>,
}
const WAGE_BASE_1994: Dollars = 60_600;
const WAGE_BASE_YEAR: i16 = 1994;
const WAGE_BASE_STEP: Dollars = 300;
const BEND_POINTS_1979: [Dollars; 2] = [180, 1_085];
const BEND_POINTS_YEAR: i16 = 1979;
const INDEX_LAG_YEARS: i16 = 2;
impl BenefitParams {
#[must_use]
pub fn average_wage(&self, year: i16) -> Option<f64> {
if let Some(&wage) = self.wage_index.get(&year) {
return Some(wage);
}
let (&last_year, &last) = self.wage_index.last_key_value()?;
(year > last_year)
.then(|| last * (1.0 + self.wage_growth).powi(i32::from(year - last_year)))
}
#[must_use]
pub fn wage_base(&self, year: i16) -> Dollars {
let scaled = WAGE_BASE_1994 as f64 * self.index_ratio(year, WAGE_BASE_YEAR);
(scaled / WAGE_BASE_STEP as f64).round() as Dollars * WAGE_BASE_STEP
}
#[must_use]
pub fn bend_points(&self, eligibility_year: i16) -> [Dollars; 2] {
let ratio = self.index_ratio(eligibility_year, BEND_POINTS_YEAR);
BEND_POINTS_1979.map(|point| (point as f64 * ratio).round() as Dollars)
}
#[must_use]
pub fn wage_ratio(&self, year: i16, over: i16) -> Option<f64> {
Some(self.average_wage(year)? / self.average_wage(over)?)
}
fn index_ratio(&self, year: i16, base_year: i16) -> f64 {
self.wage_ratio(year - INDEX_LAG_YEARS, base_year - INDEX_LAG_YEARS)
.unwrap_or(1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EarlyWithdrawal {
pub penalty: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RmdDivisor {
pub age: u8,
pub divisor: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RmdTable {
pub divisors: Vec<RmdDivisor>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PhaseOut {
pub from: Dollars,
pub to: Dollars,
}
impl PhaseOut {
#[must_use]
pub fn position(self, magi: Dollars) -> f64 {
if magi <= self.from {
return 0.0;
}
if magi >= self.to || self.to <= self.from {
return 1.0;
}
(magi - self.from) as f64 / (self.to - self.from) as f64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct ContributionLimits {
pub employer_plan: Dollars,
pub employer_plan_catch_up_50: Dollars,
pub employer_plan_catch_up_60: Dollars,
pub overall_plan: Dollars,
pub simple: Dollars,
pub simple_catch_up_50: Dollars,
pub simple_catch_up_60: Dollars,
pub ira: Dollars,
pub ira_catch_up_50: Dollars,
pub hsa_self: Dollars,
pub hsa_family: Dollars,
pub hsa_catch_up_55: Dollars,
pub roth_ira_phase_out: PerStatus<PhaseOut>,
pub ira_deduction_phase_out: PerStatus<PhaseOut>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct IrmaaTier {
pub magi_over: PerStatus<Dollars>,
pub part_b: Dollars,
pub part_d: Dollars,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct TaxParams {
pub schema: u32,
pub year: i16,
pub deductions: Deductions,
pub brackets: PerStatus<Vec<Bracket>>,
pub ltcg: Ltcg,
pub social_security: SocialSecurityThresholds,
pub early_withdrawal: EarlyWithdrawal,
pub limits: ContributionLimits,
pub rmd: RmdTable,
#[serde(default)]
pub irmaa: Vec<IrmaaTier>,
#[serde(default)]
pub states: BTreeMap<String, StateParams>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct StateParams {
pub deduction: PerStatus<Dollars>,
pub brackets: PerStatus<Vec<Bracket>>,
pub taxes_social_security: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaxTables {
years: BTreeMap<i16, TaxParams>,
}
impl TaxTables {
#[must_use]
pub fn embedded() -> Self {
let mut tables = Self {
years: BTreeMap::new(),
};
for text in EMBEDDED {
tables
.add_source(text, "embedded")
.expect("embedded tax parameter files are valid");
}
tables
}
pub fn add_source(&mut self, text: &str, label: &str) -> Result<(), ParamsError> {
let params: TaxParams = toml::from_str(text).map_err(|source| ParamsError::Parse {
path: label.to_owned(),
source: Box::new(source),
})?;
if params.schema != PARAMS_SCHEMA_VERSION {
return Err(ParamsError::Schema {
path: label.to_owned(),
found: params.schema,
});
}
self.years.insert(params.year, params);
Ok(())
}
pub fn add_dir(&mut self, dir: &Path) -> Result<(), ParamsError> {
if !dir.is_dir() {
return Ok(());
}
let entries = std::fs::read_dir(dir).map_err(|source| ParamsError::Io {
path: dir.display().to_string(),
source,
})?;
for entry in entries {
let path = entry
.map_err(|source| ParamsError::Io {
path: dir.display().to_string(),
source,
})?
.path();
if path.extension().is_some_and(|ext| ext == "toml") {
let text = std::fs::read_to_string(&path).map_err(|source| ParamsError::Io {
path: path.display().to_string(),
source,
})?;
self.add_source(&text, &path.display().to_string())?;
}
}
Ok(())
}
#[must_use]
pub fn latest_known_year(&self) -> Option<i16> {
self.years.keys().next_back().copied()
}
#[must_use]
pub fn params_for(&self, year: i16, inflation: &Inflation) -> TaxParams {
let (&base_year, base) = self
.years
.range(..=year)
.next_back()
.or_else(|| self.years.iter().next())
.expect("at least one tax table is loaded");
if year <= base_year {
let mut params = base.clone();
params.year = year;
return params;
}
inflate(base, year, inflation.factor(base_year, year))
}
}