obbba-breaks 0.1.2

Estimate OBBBA individual tax breaks: the increased standard deduction and the overtime/tips above-the-line deductions (2025-2028).
Documentation
//! # obbba-breaks
//!
//! Estimate the **One Big Beautiful Bill Act (OBBBA)** individual income-tax
//! breaks: the **increased (and permanent) standard deduction** plus the
//! temporary **above-the-line deductions for qualified overtime and tips**
//! (tax years **2025–2028**).
//!
//! ## Important: these are estimates pending final IRS guidance
//!
//! OBBBA (signed July 2025) made the larger TCJA standard deduction permanent,
//! added a small temporary boost for 2025–2028, and introduced the "no tax on
//! overtime" / "no tax on tips" above-the-line deductions. The headline numbers
//! below are widely reported, but the IRS was still finalizing implementation
//! details (exact MAGI definitions, which tips/OT amounts qualify, inflation
//! indexing for future years) through late 2025 and into 2026. **Every figure
//! here is an estimate / model input**, not a guarantee of any taxpayer's actual
//! deduction. Confirm against final IRS guidance and a qualified tax
//! professional.
//!
//! Default estimated parameters used:
//!
//! | Parameter                          | Single / HoH | Married Filing Jointly |
//! |------------------------------------|--------------|------------------------|
//! | 2025 base standard deduction       | `$15,750`    | `$31,500`              |
//! | Temporary add-on (2025–2028)       | `$750`       | `$1,500`               |
//! | Overtime / tips deduction cap      | `$12,500`    | `$25,000`              |
//! | Overtime / tips MAGI phase-out     | begins `$150,000` (single) / `$300,000` (joint); `$1` lost per `$10` MAGI over |
//!
//! ## Quick example
//! ```
//! use obbba_breaks::{standard_deduction, overtime_deduction, FilingStatus};
//!
//! // 2025 standard deduction for a single filer
//! let sd = standard_deduction(FilingStatus::Single, 2025);
//! assert!((sd - 16_500.0).abs() < 1e-6); // 15,750 + 750
//!
//! // Qualified overtime deduction (full, under the MAGI threshold)
//! let od = overtime_deduction(8_000.0, 90_000.0, FilingStatus::Single);
//! assert!((od - 8_000.0).abs() < 1e-6);
//! ```

/// Filing status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilingStatus {
    Single,
    Joint,
}

/// Filing-status- and year-dependent **standard deduction** parameters
/// (estimated OBBBA amounts).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StandardDeduction {
    /// Base (permanent, TCJA-level) standard deduction.
    pub base: f64,
    /// Temporary OBBBA add-on available 2025–2028 inclusive.
    pub temporary_addon: f64,
}

impl StandardDeduction {
    /// Estimated **single / head-of-household** standard deduction for 2025.
    pub const SINGLE_2025: StandardDeduction = StandardDeduction {
        base: 15_750.0,
        temporary_addon: 750.0,
    };
    /// Estimated **married filing jointly** standard deduction for 2025.
    pub const JOINT_2025: StandardDeduction = StandardDeduction {
        base: 31_500.0,
        temporary_addon: 1_500.0,
    };

    /// Total standard deduction: `base + temporary_addon`.
    #[inline]
    pub fn total(&self) -> f64 {
        self.base + self.temporary_addon
    }
}

/// Whether the temporary OBBBA add-on applies for the given tax year (2025–2028).
pub fn addon_applies(year: u32) -> bool {
    (2025..=2028).contains(&year)
}

/// Estimated **total standard deduction** for a filing status and tax year.
///
/// For years inside the 2025–2028 window the temporary add-on is included;
/// outside the window (e.g., 2029 onward, once the add-on sunsets) only the
/// permanent base is returned. Amounts are the **2025** estimates and are **not**
/// inflation-indexed here — callers needing future-year precision should supply
/// their own indexed [`StandardDeduction`].
pub fn standard_deduction(status: FilingStatus, year: u32) -> f64 {
    standard_deduction_with(status, year, |s, _| s.default_2025())
}

/// Same as [`standard_deduction`] but with a caller-supplied lookup for the
/// base/addon parameters (e.g., to plug in IRS-inflation-indexed figures).
pub fn standard_deduction_with<F>(status: FilingStatus, year: u32, lookup: F) -> f64
where
    F: Fn(FilingStatus, u32) -> StandardDeduction,
{
    let sd = lookup(status, year);
    if addon_applies(year) {
        sd.total()
    } else {
        sd.base
    }
}

impl FilingStatus {
    /// Default estimated 2025 standard-deduction parameters for this status.
    pub fn default_2025(self) -> StandardDeduction {
        match self {
            FilingStatus::Single => StandardDeduction::SINGLE_2025,
            FilingStatus::Joint => StandardDeduction::JOINT_2025,
        }
    }
}

// ---------------------------------------------------------------------------
// Above-the-line overtime / tips deductions
// ---------------------------------------------------------------------------

/// Parameters for the above-the-line overtime/tips deduction (estimates).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QualifiedDeductionParams {
    /// Maximum deduction when fully eligible.
    pub cap: f64,
    /// MAGI at which the phase-out begins.
    pub phase_out_start: f64,
    /// Dollars of deduction lost per dollar of MAGI over the threshold
    /// (`$1 per $10` → `0.1`).
    pub phase_out_rate: f64,
}

impl QualifiedDeductionParams {
    pub const SINGLE: QualifiedDeductionParams = QualifiedDeductionParams {
        cap: 12_500.0,
        phase_out_start: 150_000.0,
        phase_out_rate: 0.1,
    };
    pub const JOINT: QualifiedDeductionParams = QualifiedDeductionParams {
        cap: 25_000.0,
        phase_out_start: 300_000.0,
        phase_out_rate: 0.1,
    };

    #[inline]
    pub fn phase_out_end(&self) -> f64 {
        self.phase_out_start + self.cap / self.phase_out_rate
    }
}

impl FilingStatus {
    /// Default estimated overtime/tips deduction parameters for this status.
    pub fn default_qualified_params(self) -> QualifiedDeductionParams {
        match self {
            FilingStatus::Single => QualifiedDeductionParams::SINGLE,
            FilingStatus::Joint => QualifiedDeductionParams::JOINT,
        }
    }
}

/// Effective cap on a qualified (overtime/tips) deduction at a given MAGI,
/// before considering how much the taxpayer actually earned.
pub fn qualified_effective_cap(magi: f64, p: QualifiedDeductionParams) -> f64 {
    if magi <= p.phase_out_start {
        p.cap
    } else if magi >= p.phase_out_end() {
        0.0
    } else {
        let over = magi - p.phase_out_start;
        (p.cap - over * p.phase_out_rate).max(0.0)
    }
}

/// Estimated **qualified overtime** deduction — the lesser of overtime earned
/// and the effective cap at the taxpayer's MAGI. (Estimates pending final IRS
/// guidance.)
pub fn overtime_deduction(qualified_overtime: f64, magi: f64, status: FilingStatus) -> f64 {
    qualified_deduction(qualified_overtime, magi, status)
}

/// Estimated **qualified tips** deduction — same structure as overtime, capped
/// and phased out identically. (Estimates pending final IRS guidance.)
pub fn tips_deduction(qualified_tips: f64, magi: f64, status: FilingStatus) -> f64 {
    qualified_deduction(qualified_tips, magi, status)
}

fn qualified_deduction(amount: f64, magi: f64, status: FilingStatus) -> f64 {
    let amt = amount.max(0.0);
    let cap = qualified_effective_cap(magi, status.default_qualified_params());
    amt.min(cap)
}

/// Estimated **total** OBBBA deduction = standard deduction + overtime deduction
/// + tips deduction, all for one filing status and year. This treats the
/// above-the-line deductions as stacking on top of the standard deduction
/// (which is how above-the-line deductions work).
pub fn total_deduction(
    status: FilingStatus,
    year: u32,
    qualified_overtime: f64,
    qualified_tips: f64,
    magi: f64,
) -> f64 {
    let sd = standard_deduction(status, year);
    let ot = overtime_deduction(qualified_overtime, magi, status);
    let tips = tips_deduction(qualified_tips, magi, status);
    sd + ot + tips
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn standard_deduction_single_2025_includes_addon() {
        // 15,750 + 750 = 16,500
        assert!((standard_deduction(FilingStatus::Single, 2025) - 16_500.0).abs() < 1e-6);
    }

    #[test]
    fn standard_deduction_joint_2025_includes_addon() {
        // 31,500 + 1,500 = 33,000
        assert!((standard_deduction(FilingStatus::Joint, 2025) - 33_000.0).abs() < 1e-6);
    }

    #[test]
    fn addon_sunsets_after_2028() {
        // 2029 → base only, no temporary add-on
        assert!((standard_deduction(FilingStatus::Single, 2029) - 15_750.0).abs() < 1e-6);
        assert!((standard_deduction(FilingStatus::Joint, 2029) - 31_500.0).abs() < 1e-6);
    }

    #[test]
    fn addon_window_boundaries() {
        assert!(addon_applies(2025));
        assert!(addon_applies(2028));
        assert!(!addon_applies(2024));
        assert!(!addon_applies(2029));
    }

    #[test]
    fn overtime_deduction_under_threshold_full() {
        let d = overtime_deduction(8_000.0, 90_000.0, FilingStatus::Single);
        assert!((d - 8_000.0).abs() < 1e-6);
    }

    #[test]
    fn overtime_deduction_capped() {
        // $40k OT single → cap at $12,500
        let d = overtime_deduction(40_000.0, 90_000.0, FilingStatus::Single);
        assert!((d - 12_500.0).abs() < 1e-6);
    }

    #[test]
    fn tips_deduction_capped_joint() {
        // $40k tips joint → cap at $25,000
        let d = tips_deduction(40_000.0, 200_000.0, FilingStatus::Joint);
        assert!((d - 25_000.0).abs() < 1e-6);
    }

    #[test]
    fn qualified_phase_out_linear() {
        let p = QualifiedDeductionParams::SINGLE;
        // $10k over → lose $1,000 → cap $11,500
        assert!((qualified_effective_cap(160_000.0, p) - 11_500.0).abs() < 1e-6);
        // end at 150k + 125k = 275k
        assert!((p.phase_out_end() - 275_000.0).abs() < 1e-6);
        assert_eq!(qualified_effective_cap(300_000.0, p), 0.0);
    }

    #[test]
    fn total_deduction_stacks_all_three() {
        // Single 2025, $8k OT + $5k tips, MAGI $90k
        // 16,500 + 8,000 + 5,000 = 29,500
        let t = total_deduction(FilingStatus::Single, 2025, 8_000.0, 5_000.0, 90_000.0);
        assert!((t - 29_500.0).abs() < 1e-6);
    }

    #[test]
    fn total_deduction_respects_caps() {
        // Single 2025, $40k OT + $40k tips (both capped), MAGI $90k
        // 16,500 + 12,500 + 12,500 = 41,500
        let t = total_deduction(FilingStatus::Single, 2025, 40_000.0, 40_000.0, 90_000.0);
        assert!((t - 41_500.0).abs() < 1e-6);
    }

    #[test]
    fn negative_amounts_clamped() {
        assert_eq!(overtime_deduction(-1_000.0, 90_000.0, FilingStatus::Single), 0.0);
    }
}