Skip to main content

obbba_breaks/
lib.rs

1//! # obbba-breaks
2//!
3//! Estimate the **One Big Beautiful Bill Act (OBBBA)** individual income-tax
4//! breaks: the **increased (and permanent) standard deduction** plus the
5//! temporary **above-the-line deductions for qualified overtime and tips**
6//! (tax years **2025–2028**).
7//!
8//! ## Important: these are estimates pending final IRS guidance
9//!
10//! OBBBA (signed July 2025) made the larger TCJA standard deduction permanent,
11//! added a small temporary boost for 2025–2028, and introduced the "no tax on
12//! overtime" / "no tax on tips" above-the-line deductions. The headline numbers
13//! below are widely reported, but the IRS was still finalizing implementation
14//! details (exact MAGI definitions, which tips/OT amounts qualify, inflation
15//! indexing for future years) through late 2025 and into 2026. **Every figure
16//! here is an estimate / model input**, not a guarantee of any taxpayer's actual
17//! deduction. Confirm against final IRS guidance and a qualified tax
18//! professional.
19//!
20//! Default estimated parameters used:
21//!
22//! | Parameter                          | Single / HoH | Married Filing Jointly |
23//! |------------------------------------|--------------|------------------------|
24//! | 2025 base standard deduction       | `$15,750`    | `$31,500`              |
25//! | Temporary add-on (2025–2028)       | `$750`       | `$1,500`               |
26//! | Overtime / tips deduction cap      | `$12,500`    | `$25,000`              |
27//! | Overtime / tips MAGI phase-out     | begins `$150,000` (single) / `$300,000` (joint); `$1` lost per `$10` MAGI over |
28//!
29//! ## Quick example
30//! ```
31//! use obbba_breaks::{standard_deduction, overtime_deduction, FilingStatus};
32//!
33//! // 2025 standard deduction for a single filer
34//! let sd = standard_deduction(FilingStatus::Single, 2025);
35//! assert!((sd - 16_500.0).abs() < 1e-6); // 15,750 + 750
36//!
37//! // Qualified overtime deduction (full, under the MAGI threshold)
38//! let od = overtime_deduction(8_000.0, 90_000.0, FilingStatus::Single);
39//! assert!((od - 8_000.0).abs() < 1e-6);
40//! ```
41
42/// Filing status.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum FilingStatus {
45    Single,
46    Joint,
47}
48
49/// Filing-status- and year-dependent **standard deduction** parameters
50/// (estimated OBBBA amounts).
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct StandardDeduction {
53    /// Base (permanent, TCJA-level) standard deduction.
54    pub base: f64,
55    /// Temporary OBBBA add-on available 2025–2028 inclusive.
56    pub temporary_addon: f64,
57}
58
59impl StandardDeduction {
60    /// Estimated **single / head-of-household** standard deduction for 2025.
61    pub const SINGLE_2025: StandardDeduction = StandardDeduction {
62        base: 15_750.0,
63        temporary_addon: 750.0,
64    };
65    /// Estimated **married filing jointly** standard deduction for 2025.
66    pub const JOINT_2025: StandardDeduction = StandardDeduction {
67        base: 31_500.0,
68        temporary_addon: 1_500.0,
69    };
70
71    /// Total standard deduction: `base + temporary_addon`.
72    #[inline]
73    pub fn total(&self) -> f64 {
74        self.base + self.temporary_addon
75    }
76}
77
78/// Whether the temporary OBBBA add-on applies for the given tax year (2025–2028).
79pub fn addon_applies(year: u32) -> bool {
80    (2025..=2028).contains(&year)
81}
82
83/// Estimated **total standard deduction** for a filing status and tax year.
84///
85/// For years inside the 2025–2028 window the temporary add-on is included;
86/// outside the window (e.g., 2029 onward, once the add-on sunsets) only the
87/// permanent base is returned. Amounts are the **2025** estimates and are **not**
88/// inflation-indexed here — callers needing future-year precision should supply
89/// their own indexed [`StandardDeduction`].
90pub fn standard_deduction(status: FilingStatus, year: u32) -> f64 {
91    standard_deduction_with(status, year, |s, _| s.default_2025())
92}
93
94/// Same as [`standard_deduction`] but with a caller-supplied lookup for the
95/// base/addon parameters (e.g., to plug in IRS-inflation-indexed figures).
96pub fn standard_deduction_with<F>(status: FilingStatus, year: u32, lookup: F) -> f64
97where
98    F: Fn(FilingStatus, u32) -> StandardDeduction,
99{
100    let sd = lookup(status, year);
101    if addon_applies(year) {
102        sd.total()
103    } else {
104        sd.base
105    }
106}
107
108impl FilingStatus {
109    /// Default estimated 2025 standard-deduction parameters for this status.
110    pub fn default_2025(self) -> StandardDeduction {
111        match self {
112            FilingStatus::Single => StandardDeduction::SINGLE_2025,
113            FilingStatus::Joint => StandardDeduction::JOINT_2025,
114        }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// Above-the-line overtime / tips deductions
120// ---------------------------------------------------------------------------
121
122/// Parameters for the above-the-line overtime/tips deduction (estimates).
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct QualifiedDeductionParams {
125    /// Maximum deduction when fully eligible.
126    pub cap: f64,
127    /// MAGI at which the phase-out begins.
128    pub phase_out_start: f64,
129    /// Dollars of deduction lost per dollar of MAGI over the threshold
130    /// (`$1 per $10` → `0.1`).
131    pub phase_out_rate: f64,
132}
133
134impl QualifiedDeductionParams {
135    pub const SINGLE: QualifiedDeductionParams = QualifiedDeductionParams {
136        cap: 12_500.0,
137        phase_out_start: 150_000.0,
138        phase_out_rate: 0.1,
139    };
140    pub const JOINT: QualifiedDeductionParams = QualifiedDeductionParams {
141        cap: 25_000.0,
142        phase_out_start: 300_000.0,
143        phase_out_rate: 0.1,
144    };
145
146    #[inline]
147    pub fn phase_out_end(&self) -> f64 {
148        self.phase_out_start + self.cap / self.phase_out_rate
149    }
150}
151
152impl FilingStatus {
153    /// Default estimated overtime/tips deduction parameters for this status.
154    pub fn default_qualified_params(self) -> QualifiedDeductionParams {
155        match self {
156            FilingStatus::Single => QualifiedDeductionParams::SINGLE,
157            FilingStatus::Joint => QualifiedDeductionParams::JOINT,
158        }
159    }
160}
161
162/// Effective cap on a qualified (overtime/tips) deduction at a given MAGI,
163/// before considering how much the taxpayer actually earned.
164pub fn qualified_effective_cap(magi: f64, p: QualifiedDeductionParams) -> f64 {
165    if magi <= p.phase_out_start {
166        p.cap
167    } else if magi >= p.phase_out_end() {
168        0.0
169    } else {
170        let over = magi - p.phase_out_start;
171        (p.cap - over * p.phase_out_rate).max(0.0)
172    }
173}
174
175/// Estimated **qualified overtime** deduction — the lesser of overtime earned
176/// and the effective cap at the taxpayer's MAGI. (Estimates pending final IRS
177/// guidance.)
178pub fn overtime_deduction(qualified_overtime: f64, magi: f64, status: FilingStatus) -> f64 {
179    qualified_deduction(qualified_overtime, magi, status)
180}
181
182/// Estimated **qualified tips** deduction — same structure as overtime, capped
183/// and phased out identically. (Estimates pending final IRS guidance.)
184pub fn tips_deduction(qualified_tips: f64, magi: f64, status: FilingStatus) -> f64 {
185    qualified_deduction(qualified_tips, magi, status)
186}
187
188fn qualified_deduction(amount: f64, magi: f64, status: FilingStatus) -> f64 {
189    let amt = amount.max(0.0);
190    let cap = qualified_effective_cap(magi, status.default_qualified_params());
191    amt.min(cap)
192}
193
194/// Estimated **total** OBBBA deduction = standard deduction + overtime deduction
195/// + tips deduction, all for one filing status and year. This treats the
196/// above-the-line deductions as stacking on top of the standard deduction
197/// (which is how above-the-line deductions work).
198pub fn total_deduction(
199    status: FilingStatus,
200    year: u32,
201    qualified_overtime: f64,
202    qualified_tips: f64,
203    magi: f64,
204) -> f64 {
205    let sd = standard_deduction(status, year);
206    let ot = overtime_deduction(qualified_overtime, magi, status);
207    let tips = tips_deduction(qualified_tips, magi, status);
208    sd + ot + tips
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn standard_deduction_single_2025_includes_addon() {
217        // 15,750 + 750 = 16,500
218        assert!((standard_deduction(FilingStatus::Single, 2025) - 16_500.0).abs() < 1e-6);
219    }
220
221    #[test]
222    fn standard_deduction_joint_2025_includes_addon() {
223        // 31,500 + 1,500 = 33,000
224        assert!((standard_deduction(FilingStatus::Joint, 2025) - 33_000.0).abs() < 1e-6);
225    }
226
227    #[test]
228    fn addon_sunsets_after_2028() {
229        // 2029 → base only, no temporary add-on
230        assert!((standard_deduction(FilingStatus::Single, 2029) - 15_750.0).abs() < 1e-6);
231        assert!((standard_deduction(FilingStatus::Joint, 2029) - 31_500.0).abs() < 1e-6);
232    }
233
234    #[test]
235    fn addon_window_boundaries() {
236        assert!(addon_applies(2025));
237        assert!(addon_applies(2028));
238        assert!(!addon_applies(2024));
239        assert!(!addon_applies(2029));
240    }
241
242    #[test]
243    fn overtime_deduction_under_threshold_full() {
244        let d = overtime_deduction(8_000.0, 90_000.0, FilingStatus::Single);
245        assert!((d - 8_000.0).abs() < 1e-6);
246    }
247
248    #[test]
249    fn overtime_deduction_capped() {
250        // $40k OT single → cap at $12,500
251        let d = overtime_deduction(40_000.0, 90_000.0, FilingStatus::Single);
252        assert!((d - 12_500.0).abs() < 1e-6);
253    }
254
255    #[test]
256    fn tips_deduction_capped_joint() {
257        // $40k tips joint → cap at $25,000
258        let d = tips_deduction(40_000.0, 200_000.0, FilingStatus::Joint);
259        assert!((d - 25_000.0).abs() < 1e-6);
260    }
261
262    #[test]
263    fn qualified_phase_out_linear() {
264        let p = QualifiedDeductionParams::SINGLE;
265        // $10k over → lose $1,000 → cap $11,500
266        assert!((qualified_effective_cap(160_000.0, p) - 11_500.0).abs() < 1e-6);
267        // end at 150k + 125k = 275k
268        assert!((p.phase_out_end() - 275_000.0).abs() < 1e-6);
269        assert_eq!(qualified_effective_cap(300_000.0, p), 0.0);
270    }
271
272    #[test]
273    fn total_deduction_stacks_all_three() {
274        // Single 2025, $8k OT + $5k tips, MAGI $90k
275        // 16,500 + 8,000 + 5,000 = 29,500
276        let t = total_deduction(FilingStatus::Single, 2025, 8_000.0, 5_000.0, 90_000.0);
277        assert!((t - 29_500.0).abs() < 1e-6);
278    }
279
280    #[test]
281    fn total_deduction_respects_caps() {
282        // Single 2025, $40k OT + $40k tips (both capped), MAGI $90k
283        // 16,500 + 12,500 + 12,500 = 41,500
284        let t = total_deduction(FilingStatus::Single, 2025, 40_000.0, 40_000.0, 90_000.0);
285        assert!((t - 41_500.0).abs() < 1e-6);
286    }
287
288    #[test]
289    fn negative_amounts_clamped() {
290        assert_eq!(overtime_deduction(-1_000.0, 90_000.0, FilingStatus::Single), 0.0);
291    }
292}