use std::collections::BTreeMap;
use std::path::PathBuf;
use clap::Args;
use retiretui_engine::params::TaxTables;
use retiretui_engine::plan::Plan;
use retiretui_engine::project::{
Action, ContributionNote, Projection, YearRow, irmaa_purchase, project,
};
use retiretui_engine::tax;
use schemars::JsonSchema;
use serde::Serialize;
use super::project::OutputFormat;
use super::table::{account_name, income_name, money, rate};
#[derive(Args)]
pub struct ActionsArgs {
pub plan: PathBuf,
#[arg(long)]
pub year: Option<i16>,
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
pub format: OutputFormat,
#[arg(long)]
pub tax_dir: Vec<PathBuf>,
}
#[derive(Serialize, JsonSchema)]
pub struct ActionsReply {
pub year: i16,
pub ages: BTreeMap<String, u8>,
pub actions: Vec<Action>,
pub warnings: Vec<String>,
}
impl ActionsReply {
pub fn new(row: &YearRow, warnings: Vec<String>) -> Self {
Self {
year: row.year,
ages: row.ages.clone(),
actions: row.actions.clone(),
warnings,
}
}
}
pub fn run(args: &ActionsArgs) -> anyhow::Result<()> {
let tables = super::load_tables(&args.tax_dir)?;
let plan = super::load_validated_plan(&args.plan, &tables)?;
let projection = project(&plan, &tables);
let year = args.year.unwrap_or_else(current_year);
let row = year_row(&projection, year).map_err(anyhow::Error::msg)?;
let warnings = collect_warnings(&plan, &tables, row);
match args.format {
OutputFormat::Json => {
let reply = ActionsReply::new(row, warnings);
println!("{}", serde_json::to_string_pretty(&reply)?);
}
OutputFormat::Table => print!("{}", render(&plan, row, &warnings)),
}
Ok(())
}
pub(crate) fn current_year() -> i16 {
jiff::Zoned::now().date().year()
}
pub(crate) fn year_row(projection: &Projection, year: i16) -> Result<&YearRow, String> {
projection
.years
.iter()
.find(|row| row.year == year)
.ok_or_else(|| {
let first = projection.years.first().map_or(year, |row| row.year);
let last = projection.years.last().map_or(year, |row| row.year);
format!("{year} is outside the projection; the plan covers {first}-{last}")
})
}
fn render(plan: &Plan, row: &YearRow, warnings: &[String]) -> String {
use std::fmt::Write;
let mut out = format!(
"Actions for {} (ages {})\n\n",
row.year,
super::table::ages_text(plan, row)
);
if row.actions.is_empty() {
out.push_str("Nothing scheduled.\n");
} else {
for action in &row.actions {
let _ = writeln!(out, "{}", sentence(plan, action));
}
}
if !warnings.is_empty() {
out.push('\n');
}
for warning in warnings {
let _ = writeln!(out, "! {warning}");
}
out
}
pub(crate) fn sentence(plan: &Plan, action: &Action) -> String {
let named = |id: &str| account_name(plan, id).to_owned();
match action {
Action::Transfer { from, to, amount } => {
format!(
"Transfer {} from {} to {}",
money(*amount),
named(from),
named(to)
)
}
Action::Rmd { account, amount } => format!(
"Take the required distribution of {} from {}",
money(*amount),
named(account)
),
Action::Contribution {
account,
employee,
employer,
notes,
} => {
let total = money(employee + employer);
let account = named(account);
let mut said = Vec::new();
if *employer > 0 {
let (yours, theirs) = (money(*employee), money(*employer));
said.push(format!("{yours} yours, {theirs} employer"));
}
said.extend(notes.iter().map(|note| note_phrase(plan, note)));
if said.is_empty() {
format!("Contribute {total} to {account}")
} else {
format!("Contribute {total} to {account} ({})", said.join("; "))
}
}
Action::Conversion { from, to, amount } => {
format!(
"Convert {} from {} to {}",
money(*amount),
named(from),
named(to)
)
}
Action::Withdrawal { account, amount } => {
format!("Withdraw {} from {}", money(*amount), named(account))
}
Action::Surplus { account, amount } => {
format!("Save the unspent {} in {}", money(*amount), named(account))
}
}
}
pub(crate) fn note_phrase(plan: &Plan, note: &ContributionNote) -> String {
match note {
ContributionNote::Share { rate: share, of } => {
format!("{} of {}", rate(*share), income_name(plan, of))
}
ContributionNote::Maximum => "the maximum".to_owned(),
ContributionNote::Match {
rate: share,
up_to,
of,
} => format!(
"{} match up to {} of {}",
rate(*share),
rate(*up_to),
income_name(plan, of)
),
ContributionNote::AfterTax { amount } => format!("{} after tax", money(*amount)),
ContributionNote::HeldToLimit => "held to the limit".to_owned(),
ContributionNote::HeldToOverall => "employer share held to the plan's cap".to_owned(),
ContributionNote::RothIraPhaseOut => "over the Roth IRA income limit".to_owned(),
ContributionNote::NotDeducted { amount } => format!("{} not deductible", money(*amount)),
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub(crate) enum Held {
ToLimit,
PhasedOut,
NotDeducted,
}
impl Held {
const fn of(note: &ContributionNote) -> Option<Self> {
match note {
ContributionNote::HeldToLimit | ContributionNote::HeldToOverall => Some(Self::ToLimit),
ContributionNote::RothIraPhaseOut => Some(Self::PhasedOut),
ContributionNote::NotDeducted { .. } => Some(Self::NotDeducted),
ContributionNote::Share { .. }
| ContributionNote::Maximum
| ContributionNote::Match { .. }
| ContributionNote::AfterTax { .. } => None,
}
}
const fn warning(self) -> &'static str {
match self {
Self::ToLimit => "contributions were held to a limit this year",
Self::PhasedOut => "this year's MAGI is over the Roth IRA contribution limit",
Self::NotDeducted => "part of the IRA contribution is not deductible this year",
}
}
}
pub(crate) fn held_contributions(row: &YearRow) -> impl Iterator<Item = (&str, Held)> {
row.actions.iter().filter_map(|action| {
let Action::Contribution { account, notes, .. } = action else {
return None;
};
Some((account.as_str(), notes.iter().find_map(Held::of)?))
})
}
pub(crate) fn collect_warnings(plan: &Plan, tables: &TaxTables, row: &YearRow) -> Vec<String> {
let mut warnings: Vec<String> = held_contributions(row)
.map(|(account, held)| format!("{}: {}", account_name(plan, account), held.warning()))
.collect();
if row.unfunded > 0 {
warnings.push(format!(
"Unfunded: spending exceeds available money by {}",
money(row.unfunded)
));
}
if row.medicare > 0 {
warnings.push(format!(
"Medicare surcharges and cliff costs paid this year: {}",
money(row.medicare)
));
}
let purchase = irmaa_purchase(plan, tables, row.year, row.taxes.magi);
if purchase > 0 {
warnings.push(format!(
"This year's MAGI ({}) buys {} in IRMAA surcharges in {}",
money(row.taxes.magi),
money(purchase),
row.year + tax::IRMAA_LOOKBACK_YEARS
));
}
warnings
}