obbba-overtime 0.1.2

Estimate the OBBBA above-the-line deduction for qualified overtime compensation, including the MAGI phase-out (2025-2028).
Documentation
//! # obbba-overtime
//!
//! Estimate the **One Big Beautiful Bill Act (OBBBA)** above-the-line deduction
//! for *qualified overtime compensation* — the so-called "no tax on overtime" —
//! including its Modified Adjusted Gross Income (MAGI) phase-out.
//!
//! ## Important: these are estimates pending final IRS guidance
//!
//! OBBBA created a temporary (tax years **2025–2028**) above-the-line deduction
//! for FLSA-qualified overtime pay. The headline parameters below are widely
//! reported from the statute and early IRS guidance, but the IRS was still
//! issuing implementation details (e.g., exactly how MAGI is computed for this
//! purpose, treatment of bonuses, and the precise phase-out slope) through late
//! 2025 and into 2026. **Every figure here is an estimate / model input — not a
//! guarantee of any taxpayer's actual deduction.** Always confirm against the
//! final IRS guidance and a qualified tax professional before relying on a
//! number.
//!
//! The figures this crate uses by default:
//!
//! | Parameter                       | Single / HoH | Married Filing Jointly |
//! |---------------------------------|--------------|------------------------|
//! | Maximum deduction (cap)         | `$12,500`    | `$25,000`              |
//! | MAGI phase-out begins           | `$150,000`   | `$300,000`             |
//! | Phase-out reduction             | `$1` of deduction lost per `$10` of MAGI over the threshold |
//!
//! ## Quick example
//! ```
//! use obbba_overtime::{deduction, FilingStatus};
//!
//! let d = deduction(8_000.0, 90_000.0, FilingStatus::Single);
//! // $8,000 of qualified OT, MAGI well under the $150k threshold → full amount
//! assert!((d - 8_000.0).abs() < 1e-6);
//!
//! let capped = deduction(40_000.0, 90_000.0, FilingStatus::Single);
//! // OT above the $12,500 single cap is not deductible
//! assert!((capped - 12_500.0).abs() < 1e-6);
//! ```

/// Filing-status-dependent deduction parameters.
///
/// All fields are the **estimated** statutory values; they are configurable so
/// the crate can track IRS guidance as it finalizes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Params {
    /// Maximum deduction for the full (under-threshold) overtime amount.
    pub cap: f64,
    /// MAGI at which the phase-out *begins* (deduction starts shrinking).
    pub phase_out_start: f64,
    /// Phase-out rate: dollars of deduction lost **per dollar** of MAGI above
    /// the threshold. The widely reported "$1 per $10 of MAGI" → `0.1`.
    pub phase_out_rate: f64,
}

impl Params {
    /// Estimated parameters for **Single / Head of Household** filers.
    pub const SINGLE: Params = Params {
        cap: 12_500.0,
        phase_out_start: 150_000.0,
        phase_out_rate: 0.1, // $1 deduction lost per $10 MAGI over threshold
    };

    /// Estimated parameters for **Married Filing Jointly**.
    pub const JOINT: Params = Params {
        cap: 25_000.0,
        phase_out_start: 300_000.0,
        phase_out_rate: 0.1,
    };

    /// MAGI at which the deduction is fully phased out (reaches $0) given the
    /// configured rate.
    #[inline]
    pub fn phase_out_end(&self) -> f64 {
        self.phase_out_start + self.cap / self.phase_out_rate
    }
}

/// Filing status, used to select default [`Params`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilingStatus {
    Single,
    Joint,
}

impl FilingStatus {
    /// Default (estimated) parameters for this filing status.
    pub fn default_params(self) -> Params {
        match self {
            FilingStatus::Single => Params::SINGLE,
            FilingStatus::Joint => Params::JOINT,
        }
    }
}

/// The maximum deduction available to a taxpayer with the given MAGI, *before*
/// considering how much overtime they actually earned (i.e., the lesser of the
/// statutory cap and what the phase-out leaves available).
///
/// Returns the **effective cap** at that income. Below the phase-out start it
/// equals `params.cap`; above the phase-out end it equals `0`.
pub fn effective_cap(magi: f64, params: Params) -> f64 {
    if magi <= params.phase_out_start {
        params.cap
    } else if magi >= params.phase_out_end() {
        0.0
    } else {
        let over = magi - params.phase_out_start;
        (params.cap - over * params.phase_out_rate).max(0.0)
    }
}

/// Estimated **overtime deduction** for a taxpayer.
///
/// `qualified_overtime` is the FLSA overtime compensation received (typically
/// the *premium* half-time portion, 0.5 × OT hours × hourly rate, but callers
/// should follow the IRS definition). `magi` is Modified Adjusted Gross Income.
/// The deduction is the **lesser** of the overtime earned and the effective cap
/// at the taxpayer's MAGI.
///
/// All inputs are clamped to be non-negative. Result is the estimated
/// above-the-line deduction in dollars.
pub fn deduction(qualified_overtime: f64, magi: f64, status: FilingStatus) -> f64 {
    deduction_with(qualified_overtime, magi, status.default_params())
}

/// Same as [`deduction`] but with explicit [`Params`] (override the defaults).
pub fn deduction_with(qualified_overtime: f64, magi: f64, params: Params) -> f64 {
    let ot = qualified_overtime.max(0.0);
    let cap = effective_cap(magi, params);
    ot.min(cap)
}

/// Estimated federal income-tax savings from the overtime deduction, given a
/// **marginal tax rate** (e.g., 0.22 for the 22% bracket).
///
/// `savings = deduction × marginal_rate`. This is a simplified estimate: it
/// assumes the deduction reduces income taxed entirely at the marginal rate and
/// ignores interactions with other phase-outs, AMT, and state tax.
pub fn tax_savings(qualified_overtime: f64, magi: f64, marginal_rate: f64, status: FilingStatus) -> f64 {
    deduction(qualified_overtime, magi, status) * marginal_rate.max(0.0)
}

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

    #[test]
    fn under_threshold_full_ot() {
        // $8k OT, $90k MAGI single → deduct all $8k
        let d = deduction(8_000.0, 90_000.0, FilingStatus::Single);
        assert!((d - 8_000.0).abs() < 1e-6);
    }

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

    #[test]
    fn joint_cap_is_25k() {
        let d = deduction(50_000.0, 200_000.0, FilingStatus::Joint);
        assert!((d - 25_000.0).abs() < 1e-6);
    }

    #[test]
    fn phase_out_reduces_linearly() {
        // Single, MAGI $160k → $10k over threshold → lose $1,000 → cap $11,500
        let p = Params::SINGLE;
        assert!((effective_cap(160_000.0, p) - 11_500.0).abs() < 1e-6);
        // $20k over → lose $2,000 → cap $10,500
        assert!((effective_cap(170_000.0, p) - 10_500.0).abs() < 1e-6);
    }

    #[test]
    fn phase_out_end_is_zero() {
        let p = Params::SINGLE;
        // cap / rate = 12,500 / 0.1 = 125,000 over threshold → end at $275k
        assert!((p.phase_out_end() - 275_000.0).abs() < 1e-6);
        assert!(effective_cap(275_000.0, p).abs() < 1e-6);
        assert_eq!(effective_cap(300_000.0, p), 0.0);
    }

    #[test]
    fn deduction_at_phase_out_is_min_of_ot_and_effective_cap() {
        // Single, $15k OT, $160k MAGI → effective cap $11,500 → deduct $11,500
        let d = deduction(15_000.0, 160_000.0, FilingStatus::Single);
        assert!((d - 11_500.0).abs() < 1e-6);
    }

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

    #[test]
    fn tax_savings_uses_marginal_rate() {
        // $10k OT fully deductible at 22% → $2,200 savings
        let s = tax_savings(10_000.0, 80_000.0, 0.22, FilingStatus::Single);
        assert!((s - 2_200.0).abs() < 1e-6);
    }

    #[test]
    fn custom_params_override() {
        let custom = Params {
            cap: 5_000.0,
            phase_out_start: 100_000.0,
            phase_out_rate: 0.2,
        };
        // $20k OT, $90k MAGI → capped at custom $5k
        assert!((deduction_with(20_000.0, 90_000.0, custom) - 5_000.0).abs() < 1e-6);
    }
}