retiretui_engine 0.4.1

Plan model, tax engine, and projection for RetireTui.
Documentation
use serde::{Deserialize, Serialize};

use super::Dollars;
use super::escalation::{ColaAnchor, ColaSpec};
use super::is_default;
use super::triggers::Trigger;

/// What kind of income a source is; the kind decides its tax treatment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IncomeKind {
    /// Wages; ordinary income, source of contributions.
    Salary,
    /// Defined-benefit pension; ordinary income.
    Pension,
    /// Annuity payout; treated as fully ordinary income.
    Annuity,
    /// Net rental income; ordinary income.
    Rental,
    /// Social Security benefit; taxed by the provisional-income rules.
    SocialSecurity,
    /// One-time untaxed receipt (inheritance, gift, sale proceeds).
    Windfall,
    /// Anything else; treated as ordinary income.
    Other,
}

impl IncomeKind {
    /// Every kind, in the order the schema declares them.
    pub const ALL: &'static [Self] = &[
        Self::Salary,
        Self::Pension,
        Self::Annuity,
        Self::Rental,
        Self::SocialSecurity,
        Self::Windfall,
        Self::Other,
    ];

    /// The kind as a plan file spells it.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Salary => "salary",
            Self::Pension => "pension",
            Self::Annuity => "annuity",
            Self::Rental => "rental",
            Self::SocialSecurity => "social-security",
            Self::Windfall => "windfall",
            Self::Other => "other",
        }
    }

    /// Whether the job an income of this kind is paid by can have a
    /// workplace plan that covers its owner: a salary alone.
    #[must_use]
    pub const fn can_be_covered(self) -> bool {
        matches!(self, Self::Salary)
    }

    /// Whether an income of this kind may state its amount as its first
    /// year's: any but a Social Security benefit, which SSA states in
    /// today's dollars and adjusts whether or not it is claimed.
    #[must_use]
    pub const fn can_escalate_from_start(self) -> bool {
        !matches!(self, Self::SocialSecurity)
    }
}

/// An income source, in annual today's dollars unless its `cola_from` says
/// its first year's.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Income {
    /// Unique id, which scenarios and references match the income by; a
    /// plan without one is refused by validation.
    #[serde(default)]
    pub id: String,
    /// Display name; defaults to the id.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// The kind, which decides tax treatment.
    pub kind: IncomeKind,
    /// Owning person id.
    pub owner: String,
    /// Annual amount (for `windfall`, the one-time amount): in today's
    /// dollars, or under `cola_from = "start"` what its first year pays. A
    /// `social-security` income may leave it out, and its annual benefit at
    /// the claim is then computed from the owner's earnings record and the
    /// salary the plan projects; stated, it is used as is.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub amount: Option<Dollars>,
    /// First year received; absent means from plan start.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<Trigger>,
    /// Last year received; absent means through the horizon.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end: Option<Trigger>,
    /// One-time receipt in the trigger's year; excludes `start`/`end`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub on: Option<Trigger>,
    /// How the amount escalates: plan inflation (`true`, default), frozen
    /// nominal (`false`), or a fixed annual rate of its own.
    #[serde(default)]
    pub cola: ColaSpec,
    /// Where `cola` runs from: the plan's start (default), or the income's
    /// first year, which the amount is then stated for. Not for
    /// `social-security`, whose amount is always today's dollars.
    #[serde(default, skip_serializing_if = "is_default")]
    pub cola_from: ColaAnchor,
    /// For a `salary`: the job's workplace plan covers the owner while it
    /// pays, contributing or not, which decides whether a traditional IRA
    /// contribution is deductible.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub covered: bool,
}

impl Income {
    /// Whether the income's benefit is computed rather than stated: a
    /// `social-security` income with no `amount`.
    #[must_use]
    pub fn is_derived(&self) -> bool {
        self.kind == IncomeKind::SocialSecurity && self.amount.is_none()
    }

    /// Whether the income is `person`'s Social Security benefit.
    #[must_use]
    pub fn is_benefit_of(&self, person: &str) -> bool {
        self.kind == IncomeKind::SocialSecurity && self.owner == person
    }
}