mod guide;
mod panes;
#[cfg(test)]
pub(crate) mod tests;
use std::path::PathBuf;
use bevy_app::{App, Update};
use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut};
use bevy_ecs::prelude::{Commands, In, IntoScheduleConfigs, Local, Res, ResMut, World};
use retiretui_engine::optimize::{
BracketSweep, LadderStep, OptimizeOptions, SweptBracket, apply_ladder, is_ladder,
ladder_overlay, optimize_conversions, rank_key, sweep_brackets,
};
use retiretui_engine::params::TaxTables;
use retiretui_engine::plan::{Issue, Plan, TreatmentClass};
use retiretui_engine::project::Projection;
use serde::Deserialize;
use super::options::{CURRENT_PLAN, FIGURES, Laid, figures};
use super::{Found, NOTHING_SEARCHED_YET, Tool, ToolPage, write};
use crate::commands::optimize::LadderConstraints;
use crate::commands::tui::command::Outcome;
use crate::commands::tui::confirm::{Answer, Confirm};
use crate::commands::tui::documents::{Browsing, Pickers};
use crate::commands::tui::edit::{self, Draft, DraftEditor, FieldSpec, FormButton, Ops, RefSource};
use crate::commands::tui::journal;
use crate::commands::tui::nav::{ActivePage, Page};
use crate::commands::tui::present::compact_dollars;
use crate::commands::tui::session::Session;
pub type Ladders = Tool<Swept>;
pub fn plugin(app: &mut App) {
super::install::<Swept>(app, &PAGE);
super::options::plugin::<Swept>(app);
app.add_systems(Update, search_by_itself.before(super::poll_search::<Swept>));
panes::plugin(app);
guide::plugin(app);
}
#[derive(Deserialize)]
struct Constraints {
from: Option<String>,
to: Option<String>,
bracket: Option<u8>,
#[serde(flatten)]
held: LadderConstraints,
}
const FIELDS: &[FieldSpec] = &[
FieldSpec::refers("from", "Convert from", RefSource::DeferredAccount)
.blank("Every deferred account")
.help("The tax-deferred account to convert out of."),
FieldSpec::refers("to", "Convert to", RefSource::RothAccount)
.help("The Roth account the conversions land in."),
FieldSpec::whole("bracket", "Fill bracket")
.help("Fill this tax bracket, as a percent such as 22. Blank tries every bracket."),
FieldSpec::whole("start_year", "First year")
.help("The first year to convert in. Blank means the start of the plan."),
FieldSpec::whole("end_year", "Last year")
.help("The last year to convert in. Blank means the end of the plan."),
FieldSpec::money("annual_max", "Annual cap")
.help("The most to convert in any one year. Blank sets no cap."),
FieldSpec::money("total_max", "Total cap")
.help("The most to convert over the whole ladder. Blank sets no cap."),
FieldSpec::money("headroom", "Headroom")
.help("Dollars to stay below the top of the bracket, as a margin for error."),
FieldSpec::whole("irmaa_tier", "IRMAA tier")
.help("Stay under this Medicare surcharge tier; 0 avoids them all. Blank ignores it."),
FieldSpec::money("max_magi", "MAGI cap")
.help("Keep every year's MAGI (modified adjusted gross income) under this."),
];
impl edit::ToolAnswers for Constraints {
const SLOT: &'static str = "optimizer";
}
const OPS: Ops = Ops::tool::<Constraints>(Some(Page::RothConversions), "Constraints", FIELDS)
.acting(["Discard", "Apply"], act);
const _: () = assert!(
edit::help_fits(OPS),
"a field's help is missing or too long"
);
const PAGE: ToolPage = ToolPage {
surface: Page::RothConversions,
panes: panes::spawn_panes,
};
const NO_DESTINATION: &str = "no destination account";
const NO_BRACKET: &str = "no bracket can be filled";
impl Constraints {
fn options(self, plan: &Plan) -> Result<(OptimizeOptions, Option<f64>), String> {
let destination = self.to.ok_or_else(|| NO_DESTINATION.to_owned())?;
let sources = if let Some(source) = self.from {
vec![source]
} else {
let owner = plan.account(&destination).map(|account| &account.owner);
plan.accounts
.iter()
.filter(|account| {
account.treatment() == TreatmentClass::Deferred && Some(&account.owner) == owner
})
.map(|account| account.id.clone())
.collect()
};
let options = self.held.options(&sources, &destination);
let bracket = self.bracket.map(|percent| f64::from(percent) / 100.0);
Ok((options, bracket))
}
}
const DESTINATION: &str = "to";
pub(crate) fn held_answers(draft: &Draft) -> toml::Table {
let mut answers = draft.answers::<Constraints>();
answers.remove(DESTINATION);
answers
}
pub(crate) fn options_into(
plan: &Plan,
held: &toml::Table,
destination: &str,
) -> Option<(OptimizeOptions, Option<f64>)> {
let mut answers = held.clone();
answers.insert(DESTINATION.to_owned(), destination.into());
let constraints = edit::from_table::<Constraints>(answers).ok()?;
constraints.options(plan).ok()
}
pub(crate) fn aim_at(draft: &mut Draft, destination: &str) {
let mut answers = draft.answers::<Constraints>();
answers.insert(DESTINATION.to_owned(), destination.into());
draft.tools.insert(
<Constraints as edit::ToolAnswers>::SLOT.to_owned(),
toml::Value::Table(answers),
);
}
fn held(draft: &Draft) -> Result<(OptimizeOptions, Option<f64>), String> {
edit::from_table::<Constraints>(draft.answers::<Constraints>())
.and_then(|constraints| constraints.options(&draft.plan))
}
pub struct Swept {
sweep: BracketSweep,
options: OptimizeOptions,
}
impl Found for Swept {
const NOTHING_SEARCHED: &'static str = "Ranked here once Convert to names a Roth account.";
fn laid(&self, _plan: &Plan, nominal: bool) -> Laid {
let deflated = !nominal;
let header = ["Bracket", "converted"]
.into_iter()
.chain(FIGURES)
.map(str::to_owned)
.collect();
let row = |label: String, projection: &Projection| {
let summary = projection.summary(deflated);
let converted = compact_dollars(summary.lifetime_conversions);
[label, converted]
.into_iter()
.chain(figures(&summary))
.collect()
};
let options = self
.sweep
.brackets
.iter()
.map(|bracket| row(rate_label(bracket.rate), &bracket.optimized));
Laid {
header,
current: row(CURRENT_PLAN.to_owned(), &self.sweep.baseline),
options: options.collect(),
}
}
}
impl Tool<Swept> {
fn highlighted_bracket(&self) -> Option<&SweptBracket> {
let brackets = &self.found()?.sweep.brackets;
let highlighted = self.highlighted().and_then(|at| brackets.get(at));
highlighted.or_else(|| brackets.first())
}
}
pub(crate) fn rate_label(rate: f64) -> String {
format!("{:.0}%", rate * 100.0)
}
fn act(which: FormButton, commands: &mut Commands) {
if which == FormButton::Apply {
commands.run_system_cached(mark_applied);
}
}
fn mark_applied(mut ladders: ResMut<Ladders>) {
ladders.set_changed();
}
fn search_by_itself(
(draft, session): (Res<Draft>, Res<Session>),
active: Res<ActivePage>,
mut searched: Local<Option<(Plan, toml::Table)>>,
mut ladders: ResMut<Ladders>,
) {
let is_moved = draft.is_changed() || active.is_changed() || ladders.is_changed();
if !is_moved || active.0 != Page::RothConversions || ladders.is_running() {
return;
}
let answers = draft.answers::<Constraints>();
let is_same =
|(plan, searched): &(Plan, toml::Table)| *plan == draft.plan && *searched == answers;
if !super::is_due(&draft, searched.as_ref(), is_same) {
return;
}
let Ok((options, rate)) = held(&draft) else {
return;
};
*searched = Some((draft.plan.clone(), answers));
let tables = session.tables.clone();
ladders.start(draft.plan.clone(), move |plan| {
search(plan, &tables, &options, rate).map(|sweep| Swept { sweep, options })
});
}
pub(crate) fn search(
plan: &Plan,
tables: &TaxTables,
options: &OptimizeOptions,
rate: Option<f64>,
) -> Result<BracketSweep, Vec<Issue>> {
let mut sweep = match rate {
Some(rate) => {
optimize_conversions(plan, tables, options, rate).map(|ladder| BracketSweep {
baseline: ladder.baseline,
brackets: vec![ladder.ladder],
})
}
None => sweep_brackets(plan, tables, options),
}?;
sweep
.brackets
.sort_by_cached_key(|bracket| rank_key(&bracket.optimized));
Ok(sweep)
}
pub fn adopt(ladders: Res<Ladders>, draft: Res<Draft>, mut confirm: ResMut<Confirm>) -> Outcome {
if let Some(refusal) = draft.refuse_if_read_only() {
return refusal;
}
let Some(swept) = ladders.found() else {
return Outcome::Refused(NOTHING_SEARCHED_YET.to_owned());
};
let Some(bracket) = ladders.highlighted_bracket() else {
return Outcome::Refused(NO_BRACKET.to_owned());
};
let replacing = if draft.plan.conversions.iter().any(is_ladder) {
", in place of the ladder taken before"
} else {
""
};
let question = format!(
"Take the {} ladder? {}{replacing}.",
rate_label(bracket.rate),
said(&bracket.steps)
);
let asked = (swept.options.clone(), bracket.steps.clone());
let answers = vec![
Answer::closing("Cancel"),
Answer::running("Take", move |commands| {
commands.run_system_cached_with(take, asked);
})
.primary(),
];
confirm.ask_among(question, answers);
Outcome::Done
}
fn take(In((options, steps)): In<(OptimizeOptions, Vec<LadderStep>)>, mut editor: DraftEditor) {
apply_ladder(&mut editor.draft.plan, &options, &steps);
editor.commit();
journal::say(format!("took {} conversion(s) into the plan", steps.len()));
}
fn said(steps: &[LadderStep]) -> String {
match (steps.first(), steps.last()) {
(Some(first), Some(last)) => format!(
"{} conversion(s), {}–{}",
steps.len(),
first.year,
last.year
),
_ => "It converts nothing".to_owned(),
}
}
pub fn write_picker(
pickers: Res<Pickers>,
ladders: Res<Ladders>,
draft: Res<Draft>,
mut browsing: ResMut<Browsing>,
) -> Outcome {
if ladders.found().is_none() {
return Outcome::Refused(NOTHING_SEARCHED_YET.to_owned());
}
if ladders.highlighted_bracket().is_none() {
return Outcome::Refused(NO_BRACKET.to_owned());
}
write::open_picker(pickers.ladder, &draft, &mut browsing)
}
pub fn write_overlay(In(path): In<PathBuf>, world: &mut World) {
let text = write::base_of(world, &path).and_then(|base| {
let ladders = world.resource::<Ladders>();
let over = &world.resource::<Draft>().plan;
let (swept, bracket) = ladders
.found()
.zip(ladders.highlighted_bracket())
.ok_or_else(|| NOTHING_SEARCHED_YET.to_owned())?;
ladder_overlay(&base, over, &swept.options, &bracket.steps)
.map_err(|error| format!("not written: {error}"))
});
write::write(world, path, text);
}