diff --git a/changelog.d/reform-pass-through-owners-draw-seca.added.md b/changelog.d/reform-pass-through-owners-draw-seca.added.md new file mode 100644 index 00000000000..a55ca5b5ff2 --- /dev/null +++ b/changelog.d/reform-pass-through-owners-draw-seca.added.md @@ -0,0 +1,5 @@ +Add an opt-in contributed reform applying SECA to owners' draws from pass-through profits, with configurable phase-in, cap, HI, deduction, and benefit-credit treatment. + +Return zero draw in inactive historical years so the default AIME lookback does not read reporting-floor parameters before they exist. + +Connect the actual owners' draw exclusion to the active-business NIIT reform, respecting OASDI room, optional HI and explicit net-earnings factors. diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md new file mode 100644 index 00000000000..4118ea5d0f4 --- /dev/null +++ b/docs/contributed_reforms/owners_draw.md @@ -0,0 +1,179 @@ +# Owners' draw SECA reform + +This contributed reform brings eligible pass-through profits into the SECA +OASDI base. It is off by default. The example configuration phases in a share +of 0.1 in 2027, rising by 0.1 annually to 1 in 2036. These are custom example +settings, not enacted law or a complete implementation of an OACT provision. +Population scoring uses the tax side only. + +## Parameters + +All settings are under `gov.contrib.ssa.owners_draw`. + +| Parameter | Default | Meaning | +| --- | --- | --- | +| `in_effect` | `false` | Enable the reform. | +| `start_year` | `2027` | First affected calendar year. | +| `phase_in_years` | `10` | Positive duration of the linear phase-in. | +| `phase_in_share` | `-1` | Derive the linear share; an annual value in [0, 1] overrides it. | +| `net_earnings_factor` | `0` | Derive the standard SECA factor; a value in (0, 1] overrides it. | +| `material_participation_only` | `true` | Use nonpassive income as the participation proxy. | +| `include_hi` | `false` | Also tax adjusted draw earnings at the uncapped SECA HI rate. | +| `benefit_credit` | `true` | Add adjusted annual draw earnings to the existing AIME earnings history before its annual cap. | +| `half_seca_deduction` | `true` | Apply the existing section 164(f) deductible share to added SECA. | +| `room_cap_basis` | `0` | Preserve the current-law OASDI cap; `1` removes the draw ceiling. | + +The derived share is `min(max((year - start_year + 1) / phase_in_years, 0), 1)`. +Activation and start-year checks still apply to explicit share overrides. +The current-law cap is copied before user reforms, so removing the general +payroll cap does not silently remove this draw ceiling. + +## Input contract and signed losses + +For each Person, define total signed entity income +`T = s_corp_income + partnership_income`, passive subset +`Q = passive_partnership_s_corp_income`, and already-SE partnership subset +`K = partnership_self_employment_net_earnings`. Both Q and K are already in T; +K is a gross source amount before SECA's net-earnings adjustment. + +With disjoint passive and already-SE subsets, the signed active residual is +`G = T - Q - K`. If both subsets are nonzero, either supply +`owners_draw_subsets_are_disjoint: true` or the signed intersection in +`owners_draw_passive_se_overlap`. The latter yields +`K_active = K - overlap` and `G = T - Q - K_active`. Ambiguous classifications +raise an error. If either subset is zero, disjointness follows without an +additional assertion. With `material_participation_only: false`, include the +passive subset and subtract the entire already-SE K instead. + +Signed profits and losses net within the owner's supplied entity totals +before flooring the residual. This is an approximation without entity-level +detail. A negative residual does not offset wages or existing SE earnings. +[26 U.S.C. 1402(a)](https://www.law.cornell.edu/uscode/text/26/1402#a) +provides the comparable income-minus-deductions approach for net SE earnings; +its existing exclusions are not a legal basis for this contributed expansion. +Nonpassive status is a material-participation proxy and does not identify +every legal limited-partner class. + +## Gross draw, adjusted earnings, and cap priority + +The planned gross draw is `D_planned = share * max(G, 0)`. The default +netting factor is the existing model expression: + +```text +q = 1 - gov.irs.ald.misc.employer_share + * (gov.irs.self_employment.rate.social_security + + gov.irs.self_employment.rate.medicare) +``` + +Current rates produce 0.9235. Rate reforms flow through q, including when HI +on the incremental draw is off. An explicit factor override is available. +The planned adjusted draw `q * D_planned` must independently meet the existing +`gov.irs.self_employment.net_earnings_exemption` ($400 at current settings). +Otherwise actual gross draw D and adjusted earnings N_D are both zero. +This independent floor preserves existing SE computations; it is a specific +modeling choice rather than an integrated new SECA statute. + +Let W be existing taxable wages, S the already adjusted and capped existing +`social_security_taxable_self_employment_income`, and C the preserved cap. +For the default cap basis: + +```text +room = max(C - W - S, 0) +draw_oasdi_base = min(N_D, room) +added_OASDI = draw_oasdi_base * SECA_OASDI_rate +``` + +Wages have priority, then existing SE earnings, then the draw. The independent +draw outputs leave the existing SE base intact. Added tax enters the existing +SECA liability variables, so payroll and OASDI accounting include it once. +HI inclusion adds `N_D * SECA_HI_rate` without the OASDI ceiling. The deduction +uses `gov.irs.ald.self_employment_tax.percent_deductible`, distinct from the +employer-share parameter used to derive q, and retains nondependent aggregation. +No second copy of the source income is added to gross income or AGI. + +Outputs include `owners_draw_active_income`, `owners_draw_planned_gross`, +`owners_draw_gross`, `owners_draw_net_earnings_factor`, +`owners_draw_net_earnings`, `owners_draw_oasdi_room`, +`owners_draw_oasdi_base`, `owners_draw_oasdi_tax`, and `owners_draw_hi_tax`. + +For the current-law cap basis, the taxed draw is nonnegative and no larger +than remaining room, adjusted earnings, or actual gross draw. Adding the +draw cannot push otherwise capped covered earnings beyond C. Increasing the +phase-in share cannot reduce draw tax when other inputs and settings stay +fixed. Already-SE income is removed before computing the draw, and disabled +years retain baseline tax and deduction behavior. The `1` cap option +intentionally permits earnings above C. + +## Benefit credit and active NIIT interaction + +`owners_draw_credited_earnings` exposes N_D when benefit credit is enabled, +and zero otherwise. The flag has no tax effect. The reform passes these +annual earnings to the existing `ss_aime` earnings-history helper and adds +them before applying each year's wage-base cap. A supplied positive +`ss_aime_input` retains its baseline precedence. This reform does not append +an amount to final AIME or alter reported benefits. This bridge inherits +main's AIME lookback and age rules and its separate +`gov.ssa.social_security.wage_base` path, last authored for 2026; it does not +synchronize that path with the current tax cap. Mini-PIA integration and a +complete annual-history ledger remain separate future work. + +`owners_draw_niit_exclusion` is the matching gross-profit exclusion for a +S.770 active-business NIIT option. With OASDI only it is +`draw_oasdi_base / q`: only profits whose adjusted earnings actually face +SECA are excluded. With HI enabled it is the entire actual gross draw D, +because N_D faces uncapped HI. Profits beyond OASDI room remain in the active +base when HI is off. The undrawn share and any floor-rejected planned draw +also remain. Existing already-SE gross K_active must separately be excluded +regardless of the OASDI cap because existing SE earnings face uncapped HI. +When both reforms are active, `s770_active_business_income` consumes this +calculated exclusion, including an explicit net-factor override. When owners +draw is disabled, before its start year, or uninstalled, the S.770 manual +draw hooks retain their original behavior. + +The automatic joint route requires `material_participation_only: true`. +Passive-inclusive draws remain supported by the standalone owners' draw +reform, but the joint active-business calculation raises an explicit error +for that setting: the aggregate draw does not identify which taxed profits +belong in the active residual or in NII. Supporting that combination requires +a validated source allocation, including NII's separate +`s770_nii_fica_seca_income` hook. Subtracting the entire passive-inclusive draw +from active profits would misallocate the remaining bases between tax rates. + +## Data limitations + +The intended field contract is separate S-corporation and partnership totals, +with the combined section 469 passive subset subtracted once and the +already-SE partnership subset removed without double counting. + +Current production exports force `s_corp_income` to zero and put combined +nonpassive S-corporation and partnership donor income into `partnership_income`. +Calibration carries passive income mass into that combined column, while +`passive_partnership_s_corp_income` is absent and defaults to zero. +`partnership_self_employment_net_earnings` is present. An absent passive +column does not establish that passive income is zero. This can overstate the +active residual and makes population results provisional. + +[Microcosm #1251](https://github.com/PolicyEngine/microcosm/pull/1251) repairs +the total/passive contract, and +[#1261](https://github.com/PolicyEngine/microcosm/pull/1261) separates entity +totals. Nonprovisional population scoring requires a pinned artifact containing +these repairs and a receipt validating the stored columns and their meanings. +No population impact is measured by this PR; the hub will measure it. + +## Comparable OACT provisions + +OACT's current [coverage provision F12](https://www.ssa.gov/OACT/solvency/provisions/coverage.html) +says, “make all distributions to all pass-through business owners up to the +earnings cap subject to the SECA tax”. Its current table starts in 2029 and +requires material participation. This reform's example 2027–2036 phase-in +and default OASDI-only treatment are distinct choices. + +OACT's current [payroll-tax provisions](https://www.ssa.gov/OACT/solvency/provisions/payrolltax.html) +separate rate increases, taxation above the taxable maximum, and partial +taxation above it. The older E5 label belongs to the +[legacy contribution-and-benefit-base table](https://www.ssa.gov/OACT/solvency/provisions/wagebase.html), +which uses the 2008 Trustees assumptions. E5 says, “Raise the taxable maximum +amount (the contribution and benefit base) to include 90 percent of total +OASDI covered earnings.” Its phased higher cap also earns benefit credit. +It is not a current 2026 E5 category. Cap changes and benefit-credit choices +are distinct from expanding the owners' draw base. diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml new file mode 100644 index 00000000000..7bb3f6f9e37 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml @@ -0,0 +1,7 @@ +description: Whether adjusted owners' draw earnings are available for annual covered-earnings benefit computation; this flag has no tax effect. +values: + 0000-01-01: true +metadata: + unit: bool + label: Owners' draw benefit credit + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml new file mode 100644 index 00000000000..39e9d8730d9 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml @@ -0,0 +1,10 @@ +description: Whether added owners' draw SECA liability qualifies for the deduction at the existing section 164(f) deductible share. +values: + 0000-01-01: true +metadata: + unit: bool + label: Owners' draw SECA deduction + economy: false + reference: + - title: 26 U.S.C. 164(f) + href: https://www.law.cornell.edu/uscode/text/26/164#f diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml new file mode 100644 index 00000000000..a3078eadb25 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml @@ -0,0 +1,10 @@ +description: Whether the contributed owners' draw reform subjects eligible pass-through profits to SECA. +values: + 0000-01-01: false +metadata: + unit: bool + label: Owners' draw reform in effect + economy: false + reference: + - title: SSA OACT pass-through business distributions provision F12 + href: https://www.ssa.gov/OACT/solvency/provisions/coverage.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml new file mode 100644 index 00000000000..422b13425d9 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml @@ -0,0 +1,7 @@ +description: Whether adjusted owners' draw earnings also face uncapped SECA HI tax. +values: + 0000-01-01: false +metadata: + unit: bool + label: Owners' draw HI inclusion + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml new file mode 100644 index 00000000000..178bb205af1 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml @@ -0,0 +1,10 @@ +description: Whether owners' draw eligibility is restricted to nonpassive income, used as the material-participation proxy. +values: + 0000-01-01: true +metadata: + unit: bool + label: Owners' draw material-participation restriction + economy: false + reference: + - title: SSA OACT pass-through business distributions provision F12 + href: https://www.ssa.gov/OACT/solvency/provisions/coverage.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml new file mode 100644 index 00000000000..a8429263300 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml @@ -0,0 +1,10 @@ +description: An owners' draw net-earnings factor override in (0, 1]; zero derives one minus the employer share times the sum of the SECA OASDI and HI rates. +values: + 0000-01-01: 0 +metadata: + unit: /1 + label: Owners' draw net-earnings factor override + economy: false + reference: + - title: 26 U.S.C. 1402(a)(12) + href: https://www.law.cornell.edu/uscode/text/26/1402#a_12 diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml new file mode 100644 index 00000000000..49ad2be64ee --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml @@ -0,0 +1,7 @@ +description: An annual owners' draw share override in [0, 1]; -1 uses the linear schedule from start_year and phase_in_years. +values: + 0000-01-01: -1 +metadata: + unit: /1 + label: Owners' draw phase-in share override + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml new file mode 100644 index 00000000000..01012f1851e --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml @@ -0,0 +1,7 @@ +description: The positive number of years over which the owners' draw share rises linearly to one, including the start year. +values: + 0000-01-01: 10 +metadata: + unit: year + label: Owners' draw phase-in years + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml new file mode 100644 index 00000000000..5a913520cad --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml @@ -0,0 +1,7 @@ +description: "The owners' draw OASDI ceiling: 0 uses the current-law cap preserved before reforms, and 1 imposes no ceiling." +values: + 0000-01-01: 0 +metadata: + unit: int + label: Owners' draw OASDI cap basis + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml new file mode 100644 index 00000000000..f0721096f14 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml @@ -0,0 +1,7 @@ +description: The first calendar year of the contributed owners' draw phase-in when enabled. +values: + 0000-01-01: 2027 +metadata: + unit: year + label: Owners' draw start year + economy: false diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 422e304b734..34d6b08777d 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -12,6 +12,7 @@ from .cbo.payroll import ( create_increase_taxable_earnings_for_social_security_reform, ) +from .ssa.owners_draw import create_owners_draw_reform from .congress.wyden_smith import create_ctc_expansion_reform from .federal import create_abolish_federal_income_tax_reform from .federal import create_abolish_payroll_tax_reform @@ -368,6 +369,7 @@ def create_structural_reforms_from_parameters(parameters, period): + owners_draw = create_owners_draw_reform(parameters, period) afa_reform = create_american_family_act_with_baby_bonus_reform(parameters, period) winship_reform = create_eitc_winship_reform(parameters, period) dc_kccatc_reform = create_dc_kccatc_reform(parameters, period) @@ -605,6 +607,7 @@ def create_structural_reforms_from_parameters(parameters, period): s770_section_8 = create_s770_section_8_reform(parameters, period) reforms = [ + owners_draw, s770_section_8, afa_reform, winship_reform, diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py new file mode 100644 index 00000000000..604f324a271 --- /dev/null +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -0,0 +1,333 @@ +"""Optional SECA treatment of owners' signed pass-through residual profits. + +The parallel draw base leaves the existing SE base available for cap ordering +and adds liability to the ordinary SECA tax leaves exactly once. See +docs/contributed_reforms/owners_draw.md for the input and classification contract. +""" + +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_social_security_tax import ( + self_employment_social_security_tax as baseline_oasdi_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_medicare_tax import ( + self_employment_medicare_tax as baseline_hi_tax, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( + self_employment_tax_ald_person as baseline_se_deduction, +) +from policyengine_us.variables.gov.ssa.social_security.ss_aime import ( + ss_aime as baseline_aime, + _compute_aime, +) + + +def _enabled(period, parameters): + p = parameters(period).gov.contrib.ssa.owners_draw + return p.in_effect and period.start.year >= p.start_year + + +def _share(period, parameters): + p = parameters(period).gov.contrib.ssa.owners_draw + if not _enabled(period, parameters): + return 0 + if p.phase_in_years < 1 or int(p.phase_in_years) != p.phase_in_years: + raise ValueError("Owners' draw phase_in_years must be a positive integer") + if p.phase_in_share == -1: + return min(1, (period.start.year - p.start_year + 1) / p.phase_in_years) + if not np.isfinite(p.phase_in_share) or not 0 <= p.phase_in_share <= 1: + raise ValueError("Owners' draw phase_in_share must be -1 or in [0, 1]") + return p.phase_in_share + + +def create_owners_draw(): + class owners_draw_subsets_are_disjoint(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Assert passive and already-SE pass-through subsets are disjoint" + documentation = ( + "Caller-validated assertion for the supplied signed entity totals. " + "Otherwise supply owners_draw_passive_se_overlap when both subsets " + "are nonzero." + ) + + class owners_draw_passive_se_overlap(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + default_value = np.nan + label = "Signed overlap of passive and already-SE partnership income" + documentation = ( + "Intersection already included in both passive pass-through income " + "and partnership SE net earnings, not an additional income source." + ) + + class owners_draw_active_income(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Signed pass-through residual eligible for owners' draw" + reference = "https://www.ssa.gov/OACT/solvency/provisions/coverage.html" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + total = add(person, period, ["s_corp_income", "partnership_income"]) + already_se = person("partnership_self_employment_net_earnings", period) + if not parameters( + period + ).gov.contrib.ssa.owners_draw.material_participation_only: + return total - already_se + passive = person("passive_partnership_s_corp_income", period) + disjoint = person("owners_draw_subsets_are_disjoint", period) + overlap = person("owners_draw_passive_se_overlap", period) + unknown_overlap = ~np.isfinite(overlap) + ambiguous = (passive != 0) & (already_se != 0) & ~disjoint & unknown_overlap + if np.any(ambiguous): + raise ValueError( + "Owners' draw needs a validated disjoint-subset assertion " + "or explicit passive/SE overlap when both subsets are nonzero" + ) + if np.any(disjoint & ~unknown_overlap & (overlap != 0)): + raise ValueError( + "Owners' draw disjoint assertion contradicts supplied overlap" + ) + # Net signed entity profits/losses before flooring. The residual + # does not offset wages or the preexisting SE earnings base. + return total - passive - already_se + where(unknown_overlap, 0, overlap) + + class owners_draw_planned_gross(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Planned gross owners' draw before the separate reporting floor" + + def formula(person, period, parameters): + return _share(period, parameters) * max_( + 0, person("owners_draw_active_income", period) + ) + + class owners_draw_net_earnings_factor(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Net earnings factor applied to owners' draw" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + p = parameters(period).gov + factor = p.contrib.ssa.owners_draw.net_earnings_factor + if factor == 0: + factor = 1 - p.irs.ald.misc.employer_share * ( + p.irs.self_employment.rate.social_security + + p.irs.self_employment.rate.medicare + ) + if not np.isfinite(factor) or not 0 < factor <= 1: + raise ValueError("Owners' draw net earnings factor must be in (0, 1]") + return factor + + class owners_draw_gross(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Actual gross owners' draw accepted by the reporting floor" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + planned = person("owners_draw_planned_gross", period) + adjusted = planned * person("owners_draw_net_earnings_factor", period) + floor = parameters(period).gov.irs.self_employment.net_earnings_exemption + return where(adjusted >= floor, planned, 0) + + class owners_draw_net_earnings(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Adjusted owners' draw before the OASDI room cap" + + def formula(person, period, parameters): + return person("owners_draw_gross", period) * person( + "owners_draw_net_earnings_factor", period + ) + + class owners_draw_oasdi_room(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "OASDI room after wages and existing adjusted SE earnings" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + p = parameters(period).gov.contrib.ssa.owners_draw + if p.room_cap_basis == 1: + return np.inf + if p.room_cap_basis != 0: + raise ValueError( + "Owners' draw room_cap_basis must be 0 (current law) or 1 (no ceiling)" + ) + wages = person("taxable_earnings_for_social_security", period) + existing_se = person( + "social_security_taxable_self_employment_income", period + ) + # The existing base is already adjusted and capped; #10376 also + # floors its room when another reform taxes above-cap wages. + return max_(0, p.current_law_cap - wages - existing_se) + + class owners_draw_oasdi_base(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Owners' draw subject to SECA OASDI" + + def formula(person, period, parameters): + return min_( + person("owners_draw_net_earnings", period), + person("owners_draw_oasdi_room", period), + ) + + class owners_draw_oasdi_tax(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Incremental SECA OASDI tax on owners' draw" + + def formula(person, period, parameters): + rate = parameters(period).gov.irs.self_employment.rate.social_security + return rate * person("owners_draw_oasdi_base", period) + + class owners_draw_hi_tax(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Optional incremental SECA HI tax on owners' draw" + + def formula(person, period, parameters): + if not parameters(period).gov.contrib.ssa.owners_draw.include_hi: + return 0 + rate = parameters(period).gov.irs.self_employment.rate.medicare + return rate * person("owners_draw_net_earnings", period) + + class owners_draw_niit_exclusion(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Gross owners' profits subject to SECA for active NIIT exclusion" + documentation = ( + "Subtract from a broadened active NIIT residual: OASDI base divided " + "by the netting factor, or the whole actual gross draw with HI on. " + "Existing gross active partnership SE earnings are excluded separately." + ) + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + if parameters(period).gov.contrib.ssa.owners_draw.include_hi: + return person("owners_draw_gross", period) + return person("owners_draw_oasdi_base", period) / person( + "owners_draw_net_earnings_factor", period + ) + + class owners_draw_credited_earnings(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Adjusted owners' draw available to an annual benefit ledger" + documentation = ( + "Add to annual covered earnings before the ordinary benefit cap. " + "This flag has no effect on tax liability or reported benefits." + ) + + def formula(person, period, parameters): + if not parameters(period).gov.contrib.ssa.owners_draw.benefit_credit: + return 0 + return person("owners_draw_net_earnings", period) + + class self_employment_social_security_tax(baseline_oasdi_tax): + def formula(person, period, parameters): + return baseline_oasdi_tax.formula(person, period, parameters) + person( + "owners_draw_oasdi_tax", period + ) + + class self_employment_medicare_tax(baseline_hi_tax): + def formula(person, period, parameters): + return baseline_hi_tax.formula(person, period, parameters) + person( + "owners_draw_hi_tax", period + ) + + class self_employment_tax_ald_person(baseline_se_deduction): + def formula(person, period, parameters): + if not _enabled(period, parameters): + return baseline_se_deduction.formula(person, period, parameters) + p = parameters(period).gov + deductible_tax = person("self_employment_tax", period) + if not p.contrib.ssa.owners_draw.half_seca_deduction: + deductible_tax = max_( + 0, + deductible_tax + - add( + person, period, ["owners_draw_oasdi_tax", "owners_draw_hi_tax"] + ), + ) + return deductible_tax * p.irs.ald.self_employment_tax.percent_deductible + + class ss_aime(baseline_aime): + def formula(person, period, parameters): + supplied = person("ss_aime_input", period) + computed = _compute_aime( + person, + period, + parameters, + additional_earnings_variable="owners_draw_credited_earnings", + ) + return where(supplied > 0, supplied, computed) + + class reform(Reform): + def apply(self): + for variable in ( + owners_draw_subsets_are_disjoint, + owners_draw_passive_se_overlap, + owners_draw_active_income, + owners_draw_planned_gross, + owners_draw_net_earnings_factor, + owners_draw_gross, + owners_draw_net_earnings, + owners_draw_oasdi_room, + owners_draw_oasdi_base, + owners_draw_oasdi_tax, + owners_draw_hi_tax, + owners_draw_niit_exclusion, + owners_draw_credited_earnings, + ): + self.update_variable(variable) + self.update_variable(self_employment_social_security_tax) + self.update_variable(self_employment_medicare_tax) + self.update_variable(self_employment_tax_ald_person) + self.update_variable(ss_aime) + + return reform + + +def create_owners_draw_reform(parameters, period, bypass=False): + if bypass or any( + value.value + for value in parameters.gov.contrib.ssa.owners_draw.in_effect.values_list + ): + return create_owners_draw() + return None + + +owners_draw = create_owners_draw_reform(None, None, bypass=True) diff --git a/policyengine_us/system.py b/policyengine_us/system.py index ebbcde84794..c97ca49fa4f 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -144,6 +144,19 @@ def __init__( self.parameters, first_instant=f"{FIRST_MODELED_YEAR}-01-01" ) + # Preserve the processed current-law path before user reforms can + # remove/change the payroll cap. Owners' draw can keep this ceiling + # independently or explicitly select no ceiling. + draw_cap = self.parameters.gov.irs.payroll.social_security.cap.clone() + draw_cap.name = "gov.contrib.ssa.owners_draw.current_law_cap" + draw_cap.metadata.update( + label="Current-law OASDI cap preserved for owners' draw", economy=False + ) + draw_cap.metadata.pop("uprating", None) + self.parameters.gov.contrib.ssa.owners_draw.add_child( + "current_law_cap", draw_cap + ) + if reform: # Applied after the parameter processing pipeline so that values # a reform inserts at future dates cannot act as defined values diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml new file mode 100644 index 00000000000..0131bc10ab8 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml @@ -0,0 +1,367 @@ +# Synthetic caps are fixed in both the ordinary tax parameter and the captured +# current-law room parameter, so these examples do not depend on projections. +# Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. +# Policy cases are grouped to retain at most five cached reform combinations. +- name: Full draw for an S-corporation owner without wages + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_active_income: 150_000 + owners_draw_planned_gross: 150_000 + owners_draw_gross: 150_000 + owners_draw_net_earnings: 138_525 + owners_draw_oasdi_room: 200_000 + owners_draw_oasdi_base: 138_525 + # 138,525 * 12.4% = 17,177.10; HI defaults off. + owners_draw_oasdi_tax: 17_177.10 + owners_draw_hi_tax: 0 + self_employment_social_security_tax: 17_177.10 + self_employment_medicare_tax: 0 + self_employment_tax_ald_person: 8_588.55 + owners_draw_credited_earnings: 138_525 + owners_draw_niit_exclusion: 150_000 + # The source remains in gross income exactly once. + irs_gross_income: 150_000 + + +- name: Wages leave only 100000 of room for 200000 of S-corporation profits + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: 100_000 + s_corp_income: 200_000 + output: + owners_draw_gross: 200_000 + owners_draw_net_earnings: 184_700 + owners_draw_oasdi_room: 100_000 + owners_draw_oasdi_base: 100_000 + owners_draw_oasdi_tax: 12_400 + owners_draw_hi_tax: 0 + self_employment_tax_ald_person: 6_200 + # M7: only profits whose net earnings face OASDI leave active NIIT. + # 100,000 / 0.9235 = 108,283.7033, rather than the whole draw. + owners_draw_niit_exclusion: 108_283.7033 + owners_draw_credited_earnings: 184_700 + + +- name: Nonpassive limited-partner profits enter the draw while general-partner SE profits do not + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + partnership_income: [100_000, 100_000] + partnership_self_employment_net_earnings: [0, 100_000] + output: + owners_draw_active_income: [100_000, 0] + owners_draw_gross: [100_000, 0] + owners_draw_net_earnings: [92_350, 0] + taxable_self_employment_income: [0, 92_350] + social_security_taxable_self_employment_income: [0, 92_350] + owners_draw_oasdi_base: [92_350, 0] + owners_draw_oasdi_tax: [11_451.40, 0] + # Both have the same OASDI liability; the general partner has it already. + self_employment_social_security_tax: [11_451.40, 11_451.40] + + +- name: The third default phase-in year reclassifies 30 percent + period: 2029 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_gross: 45_000 + owners_draw_net_earnings: 41_557.50 + owners_draw_oasdi_base: 41_557.50 + owners_draw_oasdi_tax: 5_153.13 + owners_draw_niit_exclusion: 45_000 + + +- name: An above-cap worker has a gross draw but no incremental OASDI or default NIIT exclusion + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: 250_000 + s_corp_income: 150_000 + output: + owners_draw_gross: 150_000 + owners_draw_net_earnings: 138_525 + owners_draw_oasdi_room: 0 + owners_draw_oasdi_base: 0 + owners_draw_oasdi_tax: 0 + owners_draw_niit_exclusion: 0 + owners_draw_credited_earnings: 138_525 + + +- name: Signed entity profits and subsets net before the draw floor + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + # T-Q-K: 100-20-30=50; 100-(-20)-30=90; 50-20=30; + # -50+20=-30; and (-20-10)-(-10)=-20, all in thousands. + s_corp_income: [50_000, 50_000, 50_000, -50_000, -20_000] + partnership_income: [50_000, 50_000, -20_000, 20_000, -10_000] + passive_partnership_s_corp_income: [20_000, -20_000, 0, 0, 0] + partnership_self_employment_net_earnings: [30_000, 30_000, 0, 0, -10_000] + owners_draw_subsets_are_disjoint: [true, true, true, true, true] + self_employment_income: [0, 0, 0, 50_000, 50_000] + output: + owners_draw_active_income: [50_000, 90_000, 30_000, -30_000, -20_000] + owners_draw_gross: [50_000, 90_000, 30_000, 0, 0] + owners_draw_net_earnings: [46_175, 83_115, 27_705, 0, 0] + # E1 residual losses do not offset preexisting SE income. + taxable_self_employment_income: [27_705, 27_705, 0, 46_175, 36_940] + + +- name: A supplied passive and already-SE intersection is subtracted only once + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + partnership_income: 100_000 + passive_partnership_s_corp_income: 30_000 + partnership_self_employment_net_earnings: 40_000 + owners_draw_passive_se_overlap: 10_000 + output: + # 100,000 - 30,000 - (40,000 - 10,000) = 40,000. + owners_draw_active_income: 40_000 + owners_draw_gross: 40_000 + owners_draw_net_earnings: 36_940 + + +- name: Material participation defaults to excluding the combined passive subset once + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: [60_000, 0] + partnership_income: [40_000, 100_000] + passive_partnership_s_corp_income: [25_000, 100_000] + output: + owners_draw_active_income: [75_000, 0] + owners_draw_net_earnings: [69_262.50, 0] + + +- name: Disabling the material-participation filter includes passive profits and still removes existing SE + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.material_participation_only: false + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 60_000 + partnership_income: 40_000 + passive_partnership_s_corp_income: 25_000 + partnership_self_employment_net_earnings: 10_000 + output: + owners_draw_active_income: 90_000 + owners_draw_net_earnings: 83_115 + + +- name: The independent draw floor rejects adjusted earnings below 400 + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: [433, 434] + output: + # 433 * .9235 = 399.8755; 434 * .9235 = 400.799. + owners_draw_planned_gross: [433, 434] + owners_draw_gross: [0, 434] + owners_draw_net_earnings: [0, 400.799] + owners_draw_oasdi_tax: [0, 49.699076] + owners_draw_niit_exclusion: [0, 434] + + +- name: The reporting floor includes equality and is independent of existing SE earnings + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.net_earnings_factor: 1 + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: [399, 400, 401, 800, 399] + self_employment_income: [0, 0, 0, 400, 1_000] + output: + owners_draw_gross: [0, 400, 401, 800, 0] + owners_draw_net_earnings: [0, 400, 401, 800, 0] + taxable_self_employment_income: [0, 0, 0, 0, 923.50] + owners_draw_oasdi_tax: [0, 49.60, 49.724, 99.20, 0] + + +- name: Existing SE uses its adjusted capped base before allocating draw room + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: [100_000, 100_000, 250_000] + self_employment_income: [50_000, 200_000, 50_000] + s_corp_income: [100_000, 100_000, 100_000] + output: + taxable_self_employment_income: [46_175, 184_700, 46_175] + social_security_taxable_self_employment_income: [46_175, 100_000, 0] + owners_draw_oasdi_room: [53_825, 0, 0] + owners_draw_oasdi_base: [53_825, 0, 0] + owners_draw_oasdi_tax: [6_674.30, 0, 0] + # 12.4% of (46,175 + 53,825) = 12,400, not a second net adjustment. + self_employment_social_security_tax: [12_400, 12_400, 0] + + +- name: No-ceiling draw option taxes all net earnings even after wages exhaust the ordinary cap + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.ssa.owners_draw.room_cap_basis: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: 300_000 + s_corp_income: 400_000 + output: + owners_draw_gross: 400_000 + owners_draw_net_earnings: 369_400 + owners_draw_oasdi_base: 369_400 + owners_draw_oasdi_tax: 45_805.60 + owners_draw_niit_exclusion: 400_000 + + +- name: The reform is inactive before the configured start year + period: 2026 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_gross: 0 + owners_draw_net_earnings: 0 + owners_draw_oasdi_tax: 0 + + +- name: The default linear phase-in reaches one in 2036 and stays there + period: 2037 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_gross: 100_000 + owners_draw_net_earnings: 92_350 + owners_draw_oasdi_tax: 11_451.40 + + +- name: Nondependent tax-unit aggregation leaves the dependent's draw deduction off the parents' return + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + people: + parent: + age: 45 + s_corp_income: 10_000 + child: + age: 16 + is_tax_unit_dependent: true + s_corp_income: 20_000 + tax_units: + unit: + members: [parent, child] + output: + self_employment_tax_ald_person: [572.57, 1_145.14] + self_employment_tax_ald: 572.57 + + +- name: Net draw earnings enter the ordinary AIME history and respect supplied AIME + period: 2036 + absolute_error_margin: 0.001 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + age: [62, 62] + s_corp_income: [150_000, 150_000] + ss_aime_input: [0, 500] + output: + owners_draw_credited_earnings: [138_525, 138_525] + # One year's adjusted draw / (35 years * 12 months). + ss_aime: [329.8214286, 500] diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml new file mode 100644 index 00000000000..43bbd1dab26 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml @@ -0,0 +1,193 @@ +# Where overridden, synthetic caps are fixed in both the ordinary tax parameter +# and the captured current-law room parameter. +# Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. +# Policy cases are grouped to retain at most five cached reform combinations. +- name: Disabling benefit credit leaves draw taxes unchanged + period: 2036 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.benefit_credit: false + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_net_earnings: 138_525 + owners_draw_oasdi_base: 138_525 + owners_draw_oasdi_tax: 17_177.10 + owners_draw_credited_earnings: 0 + + +- name: An explicit zero phase-in share produces no draw + period: 2027 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 0 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_gross: 0 + owners_draw_net_earnings: 0 + owners_draw_oasdi_tax: 0 + + +- name: A configurable linear phase-in starts at one quarter in its new start year + period: 2030 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_years: 4 + gov.contrib.ssa.owners_draw.start_year: 2030 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_gross: 25_000 + owners_draw_net_earnings: 23_087.50 + owners_draw_oasdi_tax: 2_862.85 + + +- name: Disabling benefit credit removes the draw from AIME and leaves taxes unchanged + period: 2036 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.benefit_credit: false + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + age: 62 + s_corp_income: 150_000 + output: + owners_draw_credited_earnings: 0 + ss_aime: 0 + owners_draw_oasdi_tax: 17_177.10 + + +- name: Default AIME lookback skips inactive draw years before the reporting floor exists + period: 2027 + absolute_error_margin: 0.001 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.in_effect: true + age: 62 + s_corp_income: 100_000 + # Explicit zero labor history isolates the draw from historical labor- + # response parameter coverage, while retaining the default 45-year lookback. + employment_income: &zero_labor_history + 1982: 0 + 1983: 0 + 1984: 0 + 1985: 0 + 1986: 0 + 1987: 0 + 1988: 0 + 1989: 0 + 1990: 0 + 1991: 0 + 1992: 0 + 1993: 0 + 1994: 0 + 1995: 0 + 1996: 0 + 1997: 0 + 1998: 0 + 1999: 0 + 2000: 0 + 2001: 0 + 2002: 0 + 2003: 0 + 2004: 0 + 2005: 0 + 2006: 0 + 2007: 0 + 2008: 0 + 2009: 0 + 2010: 0 + 2011: 0 + 2012: 0 + 2013: 0 + 2014: 0 + 2015: 0 + 2016: 0 + 2017: 0 + 2018: 0 + 2019: 0 + 2020: 0 + 2021: 0 + 2022: 0 + 2023: 0 + 2024: 0 + 2025: 0 + 2026: 0 + 2027: 0 + total_self_employment_income: *zero_labor_history + output: + owners_draw_gross: + 2012: 0 + 2027: 10_000 + owners_draw_net_earnings: 9_235 + owners_draw_credited_earnings: 9_235 + owners_draw_oasdi_tax: 1_145.14 + # Default 10% phase-in: 100,000 * .1 * .9235 = 9,235. + # Only the current year has earnings, after age-60 indexing: 9,235 / 420. + ss_aime: 21.98809524 + + +- name: Joint active NIIT excludes only the actual OASDI taxed owners draw + period: 2027 + # One float32 rounding step near the active base is $0.03125. + absolute_error_margin: 0.05 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + people: + room: + age: 45 + payroll_tax_gross_wages: 100_000 + s_corp_income: 400_000 + no_room: + age: 45 + payroll_tax_gross_wages: 250_000 + s_corp_income: 400_000 + tax_units: + room: + members: [room] + filing_status: SINGLE + niit_magi: 1_000_000 + net_investment_income: 0 + no_room: + members: [no_room] + filing_status: SINGLE + niit_magi: 1_000_000 + net_investment_income: 0 + households: + household: + members: [room, no_room] + state_code: TX + output: + owners_draw_gross: [400_000, 400_000] + owners_draw_net_earnings: [369_400, 369_400] + owners_draw_oasdi_base: [100_000, 0] + # Benefit credit uses adjusted draw earnings independently of OASDI room. + owners_draw_credited_earnings: [369_400, 369_400] + # Default q=.9235: 100,000 / .9235 = 108,283.7033 of gross profits. + owners_draw_niit_exclusion: [108_283.7033, 0] + s770_active_business_income: [291_716.2967, 400_000] + s770_active_taxable_base: [291_716.2967, 400_000] + s770_active_business_income_tax: [47_258.0401, 64_800] diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml new file mode 100644 index 00000000000..2232cc390d4 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml @@ -0,0 +1,133 @@ +# Synthetic caps are fixed in both the ordinary tax parameter and the captured +# current-law room parameter, so these examples do not depend on projections. +# Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. +# Joint policy cases explicitly install both structural reforms. +- name: The standard netting factor follows changed OASDI and HI rates + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.ald.self_employment_tax.percent_deductible: 0.25 + gov.irs.payroll.social_security.cap: 200_000 + gov.irs.self_employment.rate.medicare: 0.04 + gov.irs.self_employment.rate.social_security: 0.20 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + # q = 1 - .5 * (.20 + .04) = .88, even with incremental HI off. + owners_draw_net_earnings_factor: 0.88 + owners_draw_net_earnings: 88_000 + owners_draw_oasdi_tax: 17_600 + owners_draw_hi_tax: 0 + # The deduction share is separate from the netting employer share. + self_employment_tax_ald_person: 4_400 + + +- name: The netting employer share follows reforms independently of the deduction share + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.ald.misc.employer_share: 0.25 + gov.irs.payroll.social_security.cap: 200_000 + gov.irs.self_employment.rate.medicare: 0.04 + gov.irs.self_employment.rate.social_security: 0.20 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_net_earnings_factor: 0.94 + owners_draw_net_earnings: 94_000 + owners_draw_oasdi_tax: 18_800 + self_employment_tax_ald_person: 9_400 + + +- name: An explicit netting factor overrides the rate-derived default + period: 2027 + absolute_error_margin: 0.03 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.net_earnings_factor: 0.8 + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.irs.self_employment.rate.medicare: 0.04 + gov.irs.self_employment.rate.social_security: 0.20 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: [0, 100_000] + s_corp_income: [100_000, 400_000] + filing_status: [SINGLE, SINGLE] + niit_magi: [1_000_000, 1_000_000] + net_investment_income: [0, 0] + output: + owners_draw_net_earnings_factor: 0.8 + owners_draw_net_earnings: [80_000, 320_000] + owners_draw_oasdi_base: [80_000, 100_000] + owners_draw_oasdi_tax: [16_000, 20_000] + # The explicit .8 factor, rather than the rate-derived .88, controls M7. + owners_draw_niit_exclusion: [100_000, 125_000] + s770_active_business_income: [0, 275_000] + s770_active_taxable_base: [0, 275_000] + s770_active_business_income_tax: [0, 44_550] + +- name: HI taxes all adjusted draw earnings and makes M7 exclude the full actual gross draw + period: 2027 + absolute_error_margin: 0.03 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.include_hi: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: [100_000, 250_000, 100_000] + s_corp_income: [200_000, 150_000, 400_000] + filing_status: [SINGLE, SINGLE, SINGLE] + niit_magi: [1_000_000, 1_000_000, 1_000_000] + net_investment_income: [0, 0, 0] + output: + owners_draw_gross: [200_000, 150_000, 400_000] + owners_draw_net_earnings: [184_700, 138_525, 369_400] + owners_draw_oasdi_base: [100_000, 0, 100_000] + owners_draw_oasdi_tax: [12_400, 0, 12_400] + owners_draw_hi_tax: [5_356.30, 4_017.225, 10_712.60] + self_employment_medicare_tax: [5_356.30, 4_017.225, 10_712.60] + self_employment_tax_ald_person: [8_878.15, 2_008.6125, 11_556.30] + owners_draw_niit_exclusion: [200_000, 150_000, 400_000] + s770_active_business_income: [0, 0, 0] + s770_active_taxable_base: [0, 0, 0] + s770_active_business_income_tax: [0, 0, 0] + +- name: Disabling the added deduction preserves the deduction for preexisting SE taxes + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.half_seca_deduction: false + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.include_hi: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 10_000 + s_corp_income: 100_000 + output: + taxable_self_employment_income: 9_235 + owners_draw_oasdi_tax: 11_451.40 + owners_draw_hi_tax: 2_678.15 + # Only 9,235 * (.124 + .029) / 2 = 706.4775 is deductible. + self_employment_tax_ald_person: 706.4775 diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py new file mode 100644 index 00000000000..a20f8c1e555 --- /dev/null +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -0,0 +1,1439 @@ +"""Structural activation, isolation, and vector invariants for owners' draw. + +Dollar examples live in owners_draw.yaml. These tests capture the installed +formula classes for fast deterministic and shrinking generated vectors. Public +Simulation tests batch households under one private policy per test to exercise +constructor activation, baseline equality, cap preservation and AIME history. +""" + +import gc +from hashlib import sha256 +from itertools import chain +import pickle +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import period +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.reforms.ssa.owners_draw import ( + create_owners_draw, + create_owners_draw_reform, +) +from policyengine_us.system import system +from policyengine_us.variables.gov.contrib.congress.s770.s770_active_business_income import ( + s770_active_business_income, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( + self_employment_tax_ald_person, +) +from policyengine_us.variables.gov.irs.tax.payroll.social_security.taxable_earnings_for_social_security import ( + taxable_earnings_for_social_security, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_medicare_tax import ( + self_employment_medicare_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_social_security_tax import ( + self_employment_social_security_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_tax import ( + self_employment_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.social_security_taxable_self_employment_income import ( + social_security_taxable_self_employment_income, +) +from policyengine_us.variables.gov.irs.tax.self_employment.taxable_self_employment_income import ( + taxable_self_employment_income, +) + + +BASELINE_VARIABLES = { + variable.__name__: variable + for variable in ( + taxable_earnings_for_social_security, + taxable_self_employment_income, + social_security_taxable_self_employment_income, + self_employment_social_security_tax, + self_employment_medicare_tax, + self_employment_tax, + self_employment_tax_ald_person, + ) +} +PERIOD = period("2027") +CAP = 200_000 + + +@pytest.fixture(scope="module") +def reform_variables(): + installed = [] + create_owners_draw().apply(SimpleNamespace(update_variable=installed.append)) + return BASELINE_VARIABLES | { + variable.__name__: variable + for variable in installed + if variable.__name__ != "ss_aime" + } + + +def _parameters( + *, + oasdi_rate=0.124, + hi_rate=0.029, + employer_share=0.5, + cap=CAP, + deductible_share=0.5, + **overrides, +): + draw = SimpleNamespace( + in_effect=True, + start_year=2027, + phase_in_years=10, + phase_in_share=1, + material_participation_only=True, + net_earnings_factor=0, + include_hi=False, + half_seca_deduction=True, + benefit_credit=True, + room_cap_basis=0, + current_law_cap=cap, + ) + for name, value in overrides.items(): + setattr(draw, name, value) + root = SimpleNamespace( + gov=SimpleNamespace( + contrib=SimpleNamespace( + ssa=SimpleNamespace(owners_draw=draw), + ubi_center=SimpleNamespace( + flat_tax=SimpleNamespace(abolish_self_emp_tax=False) + ), + ), + irs=SimpleNamespace( + payroll=SimpleNamespace(social_security=SimpleNamespace(cap=cap)), + self_employment=SimpleNamespace( + rate=SimpleNamespace(social_security=oasdi_rate, medicare=hi_rate), + net_earnings_exemption=400, + ), + ald=SimpleNamespace( + misc=SimpleNamespace(employer_share=employer_share), + self_employment_tax=SimpleNamespace( + percent_deductible=deductible_share + ), + ), + ), + ) + ) + return lambda instant: root + + +def _calculate(variables, parameters, inputs, outputs=None): + values = { + "s_corp_income": 0, + "partnership_income": 0, + "passive_partnership_s_corp_income": 0, + "partnership_self_employment_net_earnings": 0, + "owners_draw_subsets_are_disjoint": True, + "owners_draw_passive_se_overlap": np.nan, + "self_employment_income": 0, + "sstb_self_employment_income": 0, + "farm_operations_income": 0, + "payroll_tax_gross_wages": 0, + **inputs, + } + + values = {name: np.asarray(value) for name, value in values.items()} + + def person(name, instant, options=None): + if name not in values: + values[name] = variables[name].formula(person, instant, parameters) + return values[name] + + # Core's add() checks entity metadata before summing Person variables. + person.entity = SimpleNamespace(is_person=True, key="person") + person.entity.get_variable = lambda name: SimpleNamespace(entity=person.entity) + + for name in outputs or variables: + if name not in ( + "owners_draw_subsets_are_disjoint", + "owners_draw_passive_se_overlap", + ): + person(name, PERIOD) + return values + + +VECTOR_INPUTS = { + "payroll_tax_gross_wages": np.array( + [0, 100_000, 200_000, 300_000, 0, 90_000, 199_999, 0], dtype=float + ), + "s_corp_income": np.array( + [150_000, 200_000, 300_000, 150_000, -50_000, 100_000, 500_000, 433], + dtype=float, + ), + "self_employment_income": np.array( + [0, 50_000, 0, 50_000, 80_000, 100_000, 1_000, 0], dtype=float + ), + "partnership_income": np.array( + [0, 30_000, 40_000, 0, 20_000, 20_000, 0, 0], dtype=float + ), + "partnership_self_employment_net_earnings": np.array( + [0, 30_000, 0, 0, 0, 20_000, 0, 0], dtype=float + ), +} + + +def test_vector_invariants_and_phase_monotonicity(reform_variables): + prior_base = np.zeros(8) + prior_tax = np.zeros(8) + for share in (0, 0.1, 0.3, 0.7, 1): + values = _calculate( + reform_variables, _parameters(phase_in_share=share), VECTOR_INPUTS + ) + base = values["owners_draw_oasdi_base"] + gross = values["owners_draw_gross"] + room = values["owners_draw_oasdi_room"] + tax = values["owners_draw_oasdi_tax"] + assert np.all(base >= 0) + assert np.all(base <= room) + assert np.all(base <= gross) + assert np.all(base >= prior_base) + assert np.all(tax >= prior_tax) + total_earnings = ( + values["taxable_earnings_for_social_security"] + + values["social_security_taxable_self_employment_income"] + + base + ) + assert np.all(total_earnings <= CAP) + # M7 cannot exclude untaxed or undrawn profits from the active base. + exclusion = values["owners_draw_niit_exclusion"] + assert np.all(exclusion >= 0) + assert np.all(exclusion <= gross + 1e-8) + np.testing.assert_allclose(exclusion * 0.9235, base) + prior_base, prior_tax = base, tax + + +def test_disabled_formulas_equal_baseline_on_the_same_vectors(reform_variables): + parameters = _parameters(in_effect=False) + baseline = _calculate(BASELINE_VARIABLES, parameters, VECTOR_INPUTS) + reformed = _calculate(reform_variables, parameters, VECTOR_INPUTS) + for name in BASELINE_VARIABLES: + np.testing.assert_array_equal(reformed[name], baseline[name], err_msg=name) + for name in reform_variables.keys() - BASELINE_VARIABLES.keys(): + if name not in ( + "owners_draw_subsets_are_disjoint", + "owners_draw_passive_se_overlap", + ): + assert np.all(np.asarray(reformed[name]) == 0), name + + +def test_already_se_partnership_income_cannot_add_a_second_draw(reform_variables): + already_se = np.array([0, 399, 1_000, 100_000, 500_000], dtype=float) + inputs = { + "partnership_income": already_se, + "partnership_self_employment_net_earnings": already_se, + } + parameters = _parameters() + baseline = _calculate(BASELINE_VARIABLES, parameters, inputs) + reformed = _calculate(reform_variables, parameters, inputs) + assert np.all(reformed["owners_draw_gross"] == 0) + assert np.all(reformed["owners_draw_oasdi_base"] == 0) + for name in BASELINE_VARIABLES: + np.testing.assert_array_equal(reformed[name], baseline[name], err_msg=name) + + +@pytest.mark.parametrize( + "inputs, message", + [ + ( + { + "owners_draw_subsets_are_disjoint": False, + "owners_draw_passive_se_overlap": np.nan, + }, + "validated disjoint", + ), + ( + { + "owners_draw_subsets_are_disjoint": True, + "owners_draw_passive_se_overlap": 1_000, + }, + "contradicts", + ), + ], +) +def test_ambiguous_or_contradictory_classification_is_rejected( + reform_variables, inputs, message +): + with pytest.raises(ValueError, match=message): + _calculate( + reform_variables, + _parameters(), + { + "partnership_income": np.array([100_000, 50_000]), + "partnership_self_employment_net_earnings": np.array([40_000, 0]), + "passive_partnership_s_corp_income": np.array([20_000, 0]), + **inputs, + }, + outputs=["owners_draw_active_income"], + ) + + +@pytest.mark.parametrize( + "overrides, output, message", + [ + ({"phase_in_years": 0}, "owners_draw_gross", "positive integer"), + ({"phase_in_years": 1.5}, "owners_draw_gross", "positive integer"), + ({"phase_in_share": 1.01}, "owners_draw_gross", "phase_in_share"), + ({"phase_in_share": -0.1}, "owners_draw_gross", "phase_in_share"), + ({"net_earnings_factor": 1.1}, "owners_draw_gross", "net earnings factor"), + ({"net_earnings_factor": -0.1}, "owners_draw_gross", "net earnings factor"), + ({"room_cap_basis": 2}, "owners_draw_oasdi_room", "room_cap_basis"), + ], +) +def test_invalid_policy_settings_fail_explicitly( + reform_variables, overrides, output, message +): + with pytest.raises(ValueError, match=message): + _calculate( + reform_variables, + _parameters(**overrides), + {"s_corp_income": 100_000}, + outputs=[output], + ) + + +def test_future_parameter_activation_captures_cap_and_preserves_baseline(): + years = (2026, 2036, 2037) + situation = { + "people": { + "owner": { + "age": {year: 45 for year in years}, + "payroll_tax_gross_wages": {year: 150_000 for year in years}, + "self_employment_income": {year: 50_000 for year in years}, + "s_corp_income": {year: 400_000 for year in years}, + }, + "past_owner": { + "age": {year: year - 1975 for year in years}, + "s_corp_income": {2026: 0, 2036: 150_000, 2037: 0}, + }, + } + } + parameter_reform = Reform.from_dict( + { + "gov.contrib.ssa.owners_draw.in_effect": { + "2036-01-01.2036-12-31": True, + "2037-01-01.2100-12-31": False, + }, + "gov.irs.payroll.social_security.cap": {"2036-01-01.2100-12-31": np.inf}, + "gov.simulation.aime_lookback_years": {"2026-01-01.2100-12-31": 1}, + }, + country_id="us", + ) + baseline = Simulation(tax_benefit_system=system, situation=situation) + reformed = Simulation( + tax_benefit_system=system, situation=situation, reform=parameter_reform + ) + + # Parameter-only activation must discover a finite active interval twelve + # years beyond the default 2024 factory start, including its later end. + assert create_owners_draw_reform(system.parameters, "2024-01-01") is None + unaffected_outputs = ( + *BASELINE_VARIABLES, + "irs_gross_income", + "adjusted_gross_income", + "niit_magi", + "net_investment_income_tax", + "income_tax", + ) + for year in (2026, 2037): + for name in unaffected_outputs: + np.testing.assert_array_equal( + reformed.calculate(name, year), + baseline.calculate(name, year), + err_msg=f"{name} in inactive {year}", + ) + assert reformed.calculate("owners_draw_gross", year)[0] == 0 + + cap = system.parameters("2036-01-01").gov.irs.payroll.social_security.cap + reformed_parameters = reformed.tax_benefit_system.parameters("2036-01-01") + assert np.isinf(reformed_parameters.gov.irs.payroll.social_security.cap) + assert reformed_parameters.gov.contrib.ssa.owners_draw.current_law_cap == cap + expected_room = max(0, cap - 150_000 - 46_175) + assert reformed.calculate("owners_draw_oasdi_room", 2036)[0] == pytest.approx( + expected_room + ) + assert reformed.calculate("owners_draw_oasdi_base", 2036)[0] == pytest.approx( + min(369_400, expected_room) + ) + + # Covered earnings retain the earning year's classification when the + # calculation/payment year is inactive. The 62-year-old's sole draw was + # earned at age 61, after the age-60 indexing year, so no wage indexing + # applies: 138,525 / (35 * 12) = 329.8214286. + assert reformed.calculate("owners_draw_credited_earnings", 2036)[ + 1 + ] == pytest.approx(138_525) + assert reformed.calculate("owners_draw_credited_earnings", 2037)[1] == 0 + assert reformed.calculate("ss_aime", 2037)[1] == pytest.approx(138_525 / 420) + + # Neither the captured current-law path nor the shared baseline was mutated + # by removing the tax ceiling in the reformed simulation. + assert system.parameters("2036-01-01").gov.irs.payroll.social_security.cap == cap + assert not system.parameters("2036-01-01").gov.contrib.ssa.owners_draw.in_effect + + +# Formula-level generators keep shrinking cheap: all owners in an example +# share one policy and are calculated together as NumPy arrays. +SETTINGS = dict( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +_RATE_POLICY = st.tuples( + st.integers(1, 250), st.integers(1, 100), st.integers(0, 1_000) +) +_SIGNED_OWNER_BATCH = st.lists( + st.tuples( + st.integers(0, 1_000_000), # wages + st.integers(-500_000, 500_000), # other existing SE income + st.integers(-500_000, 500_000), # active income outside SE + st.integers(-500_000, 500_000), # passive-only income + st.integers(-500_000, 500_000), # SE-only partnership income + st.integers(-500_000, 500_000), # passive/SE intersection + st.integers(-500_000, 500_000), # allocation to S corporations + ), + min_size=1, + max_size=12, +) + + +def _policy_rates(rates): + oasdi, hi, employer = rates + return dict( + oasdi_rate=oasdi / 1_000, + hi_rate=hi / 1_000, + employer_share=employer / 1_000, + ) + + +def _signed_owner_inputs(rows): + wages, other_se, active, passive_only, se_only, overlap, s_corp = np.asarray( + rows, dtype=float + ).T + # Four signed atoms supply an independent inclusion/exclusion oracle: + # T = A + Q_only + K_only + Q_intersect_K, Q = Q_only + overlap, + # K = K_only + overlap, so T - Q - K + overlap = A. + total = active + passive_only + se_only + overlap + return { + "payroll_tax_gross_wages": wages, + "self_employment_income": other_se, + "s_corp_income": s_corp, + "partnership_income": total - s_corp, + "passive_partnership_s_corp_income": passive_only + overlap, + "partnership_self_employment_net_earnings": se_only + overlap, + "owners_draw_subsets_are_disjoint": False, + "owners_draw_passive_se_overlap": overlap, + }, active + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), + preserved_cap=st.integers(1_000, 1_000_000), + share=st.integers(0, 1_000), + override_factor=st.sampled_from([0, 0.5, 0.8, 1]), +) +@example( + rows=[ + (0, 0, 500, 0, 0, 0, 500), # adjusted draw exactly $400 + (0, 0, 1_000, 0, 0, 0, 1_000), # exactly the cap room + (1_000, 0, 2_000, 0, 0, 0, 2_000), # no room + (2_000, 0, 2_000, 0, 0, 0, 2_000), # above the preserved cap + ], + rates=(124, 29, 500), + cap=1_000, + preserved_cap=800, + share=1_000, + override_factor=0.8, +) +def test_generated_draw_bounds_and_adjustment( + reform_variables, rows, rates, cap, preserved_cap, share, override_factor +): + inputs, _ = _signed_owner_inputs(rows) + policy = _policy_rates(rates) + parameters = _parameters( + **policy, + cap=cap, + current_law_cap=preserved_cap, + phase_in_share=share / 1_000, + net_earnings_factor=override_factor, + ) + baseline = _calculate(BASELINE_VARIABLES, parameters, inputs) + values = _calculate(reform_variables, parameters, inputs) + q = override_factor or 1 - policy["employer_share"] * ( + policy["oasdi_rate"] + policy["hi_rate"] + ) + expected_room = np.maximum( + 0, + preserved_cap + - baseline["taxable_earnings_for_social_security"] + - baseline["social_security_taxable_self_employment_income"], + ) + gross = values["owners_draw_gross"] + net = values["owners_draw_net_earnings"] + base = values["owners_draw_oasdi_base"] + assert np.all(gross >= 0) + np.testing.assert_allclose(net, gross * q) + np.testing.assert_allclose(values["owners_draw_oasdi_room"], expected_room) + assert np.all(base >= 0) + assert np.all(base <= expected_room) + assert np.all(base <= gross) + np.testing.assert_allclose(base, np.minimum(net, expected_room)) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), + shares=st.tuples(st.integers(0, 1_000), st.integers(0, 1_000)), +) +@example( + rows=[(0, 0, 100_000, 0, 0, 0, 100_000)], + rates=(124, 29, 500), + cap=200_000, + shares=(200, 300), # an annual phase-in step +) +@example( + rows=[(0, 0, 800, 0, 0, 0, 800)], + rates=(124, 29, 500), + cap=200_000, + shares=(0, 1_000), +) +def test_generated_phase_in_tax_is_monotone(reform_variables, rows, rates, cap, shares): + inputs, _ = _signed_owner_inputs(rows) + lower, upper = sorted(shares) + taxes = [ + _calculate( + reform_variables, + _parameters(**_policy_rates(rates), cap=cap, phase_in_share=share / 1_000), + inputs, + )["owners_draw_oasdi_tax"] + for share in (lower, upper) + ] + assert np.all(taxes[0] <= taxes[1]) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + share=st.integers(0, 1_000), +) +@example( + rows=[ + (0, 0, 0, 0, 50_000, 0, 0), # all income already SE + (0, 0, 0, -20_000, 30_000, -10_000, -20_000), # signed net zero + (0, 10_000, 5_000, -5_000, 10_000, -2_000, 15_000), + ], + rates=(124, 29, 500), + share=1_000, +) +def test_generated_signed_residual_never_double_counts_se( + reform_variables, rows, rates, share +): + inputs, active_not_se = _signed_owner_inputs(rows) + parameters = _parameters(**_policy_rates(rates), phase_in_share=share / 1_000) + baseline = _calculate(BASELINE_VARIABLES, parameters, inputs) + values = _calculate(reform_variables, parameters, inputs) + np.testing.assert_array_equal(values["owners_draw_active_income"], active_not_se) + planned = np.maximum(0, active_not_se) * share / 1_000 + np.testing.assert_allclose(values["owners_draw_planned_gross"], planned) + assert np.all(values["owners_draw_gross"] <= planned + 1e-8) + for name in ( + "payroll_tax_gross_wages", + "self_employment_income", + "partnership_self_employment_net_earnings", + "taxable_earnings_for_social_security", + "taxable_self_employment_income", + "social_security_taxable_self_employment_income", + ): + np.testing.assert_array_equal(values[name], baseline[name], err_msg=name) + + +@settings(**SETTINGS) +@given( + rows=st.lists( + st.tuples( + st.one_of( + st.sampled_from([0, 399, 399.99, 400, 400.01, 401]), + st.integers(0, 10_000), + ), + st.integers(-100_000, 100_000), + ), + min_size=1, + max_size=12, + ), + factor=st.sampled_from([0.5, 1]), +) +@example(rows=[(399.99, 100_000), (400, 0), (400.01, 100_000)], factor=0.5) +@example(rows=[(399, 0), (400, 399), (401, -100_000)], factor=1) +def test_generated_reporting_floor_is_separate_from_existing_se( + reform_variables, rows, factor +): + adjusted, existing_se = np.asarray(rows, dtype=float).T + planned = adjusted / factor + values = _calculate( + reform_variables, + _parameters(net_earnings_factor=factor), + {"s_corp_income": planned, "self_employment_income": existing_se}, + ) + expected_gross = np.where(adjusted >= 400, planned, 0) + np.testing.assert_array_equal(values["owners_draw_gross"], expected_gross) + np.testing.assert_array_equal( + values["owners_draw_net_earnings"], expected_gross * factor + ) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), +) +@example( + rows=[ + (200_000, 0, 500_000, 0, 0, 0, 500_000), # no OASDI room + (0, 0, 500_000, 0, 0, 0, 500_000), # draw exceeds the cap + ], + rates=(124, 29, 500), + cap=100_000, +) +def test_generated_hi_defaults_off_and_is_uncapped_when_enabled( + reform_variables, rows, rates, cap +): + inputs, _ = _signed_owner_inputs(rows) + policy = dict(**_policy_rates(rates), cap=cap) + baseline = _calculate(BASELINE_VARIABLES, _parameters(**policy), inputs) + default = _calculate(reform_variables, _parameters(**policy), inputs) + assert np.all(np.asarray(default["owners_draw_hi_tax"]) == 0) + np.testing.assert_array_equal( + default["self_employment_medicare_tax"], + baseline["self_employment_medicare_tax"], + ) + enabled = _calculate( + reform_variables, _parameters(**policy, include_hi=True), inputs + ) + added_hi = policy["hi_rate"] * enabled["owners_draw_net_earnings"] + np.testing.assert_allclose(enabled["owners_draw_hi_tax"], added_hi) + np.testing.assert_allclose( + enabled["self_employment_medicare_tax"], + baseline["self_employment_medicare_tax"] + added_hi, + ) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + deductible=st.integers(0, 1_000), + include_hi=st.booleans(), +) +@example( + rows=[(0, 50_000, 100_000, 0, 0, 0, 100_000)], + rates=(124, 29, 500), + deductible=250, + include_hi=True, +) +@example( + rows=[(0, 0, 100_000, 0, 0, 0, 100_000)], + rates=(124, 29, 500), + deductible=1_000, + include_hi=False, +) +def test_generated_deduction_tracks_existing_deductible_share( + reform_variables, rows, rates, deductible, include_hi +): + inputs, _ = _signed_owner_inputs(rows) + policy = dict( + **_policy_rates(rates), + deductible_share=deductible / 1_000, + include_hi=include_hi, + ) + baseline = _calculate(BASELINE_VARIABLES, _parameters(**policy), inputs) + enabled = _calculate(reform_variables, _parameters(**policy), inputs) + added_seca = enabled["owners_draw_oasdi_tax"] + enabled["owners_draw_hi_tax"] + np.testing.assert_allclose( + enabled["self_employment_tax"] - baseline["self_employment_tax"], added_seca + ) + np.testing.assert_allclose( + enabled["self_employment_tax_ald_person"], + baseline["self_employment_tax_ald_person"] + + policy["deductible_share"] * added_seca, + ) + disabled = _calculate( + reform_variables, + _parameters(**policy, half_seca_deduction=False), + inputs, + ) + np.testing.assert_allclose( + disabled["self_employment_tax_ald_person"], + baseline["self_employment_tax_ald_person"], + ) + + +@pytest.fixture +def private_draw_system(): + """Keep one independent registry/parameter tree for a complete property. + + Hypothesis reuses a function fixture across examples intentionally: policy + stays fixed, while every example gets new Simulation holders. + A mutable owner releases even pytest's fixture reference after the test. + """ + gc.collect() + owner = SimpleNamespace(system=None) + try: + yield owner + finally: + if owner.system is not None: + owner.system.simulation = None + owner.system = None + for name in ( + "baseline_variables", + "baseline_data_modified", + "parameter_root", + "parameter_fingerprint", + ): + owner.__dict__.pop(name, None) + gc.collect() + + +SIMULATION_SETTINGS = { + **SETTINGS, + "max_examples": 2, + "suppress_health_check": [ + *SETTINGS["suppress_health_check"], + HealthCheck.function_scoped_fixture, + ], +} +SIMULATION_YEAR = 2027 +SIMULATION_WINDOW = "2027-01-01.2100-12-31" +SIMULATION_DRAW = "gov.contrib.ssa.owners_draw." +SIMULATION_CAP = "gov.irs.payroll.social_security.cap" +simulation_amount = st.integers(0, 600_000) +simulation_person = st.tuples( + st.integers(20, 85), + simulation_amount, + st.integers(-40_000, 250_000), + st.integers(-100_000, 600_000), + st.integers(-100_000, 300_000), + st.integers(0, 80_000), + st.integers(0, 80_000), +) + + +def _simulation_situation(batch, year=SIMULATION_YEAR, supplied=None): + """Build separate single-person tax units in one vectorized simulation.""" + result = { + entity: {} + for entity in ( + "people", + "tax_units", + "families", + "spm_units", + "marital_units", + "households", + ) + } + input_names = ( + "age", + "employment_income", + "self_employment_income", + "s_corp_income", + "partnership_income", + "partnership_self_employment_net_earnings", + "passive_partnership_s_corp_income", + ) + years = (year,) if isinstance(year, int) else year + for i, inputs in enumerate(batch): + name = f"owner_{i}" + result["people"][name] = { + key: {year: value for year in years} + for key, value in zip(input_names, inputs) + } + if supplied is not None: + result["people"][name]["ss_aime_input"] = { + year: supplied[i] for year in years + } + for entity in result.keys() - {"people"}: + result[entity][f"unit_{i}"] = {"members": [name]} + result["households"][f"unit_{i}"]["state_code"] = {year: "TX" for year in years} + return result + + +def _private_simulation(owner, situation, reform=None): + # The public US constructor clones a supplied system when given a reform. + # Adopt that one private clone, then share its unchanged policy across new + # holders for every generated batch. Repeated constructors with a reform + # would copy the entire country tree on every example. + if owner.system is None: + simulation = Simulation( + tax_benefit_system=system, situation=situation, reform=reform + ) + owner.system = simulation.tax_benefit_system + return simulation + return Simulation(tax_benefit_system=owner.system, situation=situation) + + +def _zero_earlier_earnings(simulation, year): + # Supply a complete zero history through the public API after construction. + # This keeps the default lookback and isolates draw history from historical + # labor-response parameter coverage in the ordinary earnings formulas. + zeros = np.zeros(simulation.populations["person"].count) + for historic_year in range(year - 45, year): + simulation.set_input("employment_income", historic_year, zeros) + simulation.set_input("total_self_employment_income", historic_year, zeros) + + +def _release_simulation_reference(simulation): + """Release holder ownership while retaining the test's readonly policy cache.""" + policy = simulation.tax_benefit_system + if getattr(policy, "simulation", None) is simulation: + policy.simulation = None + + +def _parameter_policy_fingerprint(root): + """Fingerprint policy histories and scale structure, excluding instant caches.""" + digest = sha256() + # Core's descendants include every scale bracket and its component leaves. + # A regular node may also have a child named "brackets"; inspect scale + # fields only on their actual Core types. Never serialize nodes or caches. + for node in chain((root,), root.get_descendants()): + record = ( + type(node).__name__, + node.name, + tuple(node.children) if isinstance(node, ParameterNode) else (), + ( + tuple(bracket.name for bracket in node.brackets) + if isinstance(node, ParameterScale) + else () + ), + getattr(node, "metadata", {}).get("type"), + tuple( + (value.instant_str, value.value) + for value in (node.values_list if isinstance(node, Parameter) else ()) + ), + ) + digest.update(pickle.dumps(record, protocol=5)) + return digest.digest() + + +@pytest.mark.parametrize("route", ["parametric", "class"]) +@settings(**SIMULATION_SETTINGS) +@given(batch=st.lists(simulation_person, min_size=3, max_size=8)) +@example( + batch=[ + (62, 0, 0, 600_000, 0, 0, 0), + (62, 200_000, 0, 600_000, 0, 0, 0), + (62, 600_000, 250_000, 600_000, 0, 0, 0), + ], +) +def test_generated_raised_cap_paths_preserve_current_law_room( + private_draw_system, reform_variables, route, batch +): + """Property 2: current-law, parameter and class cap paths share ordering.""" + # The same invariant is checked cheaply first, so a source mutant can be + # rejected under the mutation runner's 2 GiB cap before cloning a system. + raised_cap = 400_000 + probe = _calculate( + reform_variables, + _parameters(cap=raised_cap, current_law_cap=200_000), + {"s_corp_income": 1_000_000}, + ) + assert probe["owners_draw_oasdi_base"] == 200_000 + # These subsets can be arbitrary in the off differential below. Here they + # are explicitly disjoint, so install that classification only in the + # private reformed simulation (the baseline has no such input variable). + situation = _simulation_situation(batch, (2027, 2028)) + policy = { + SIMULATION_DRAW + "in_effect": {SIMULATION_WINDOW: True}, + SIMULATION_DRAW + "phase_in_share": {SIMULATION_WINDOW: 1}, + SIMULATION_CAP: {"2027-01-01.2027-12-31": raised_cap}, + } + + class RaisedCap(Reform): + def apply(self): + activation = Reform.from_dict( + {key: value for key, value in policy.items() if key != SIMULATION_CAP}, + country_id="us", + ) + activation.apply(self) + self.parameters.gov.irs.payroll.social_security.cap.update( + start=PERIOD.start, stop=PERIOD.stop, value=raised_cap + ) + + simulation = _private_simulation( + private_draw_system, situation, policy if route == "parametric" else RaisedCap + ) + for year in (2027, 2028): + simulation.set_input( + "owners_draw_subsets_are_disjoint", year, np.ones(len(batch), bool) + ) + cap = system.parameters(f"{year}-01-01").gov.irs.payroll.social_security.cap + active_cap = raised_cap if year == 2027 else cap + p = simulation.tax_benefit_system.parameters(f"{year}-01-01") + assert p.gov.irs.payroll.social_security.cap == active_cap + assert p.gov.contrib.ssa.owners_draw.current_law_cap == cap + wages = simulation.calculate("taxable_earnings_for_social_security", year) + existing = simulation.calculate( + "social_security_taxable_self_employment_income", year + ) + room = np.maximum(0, cap - wages - existing) + draw = simulation.calculate("owners_draw_oasdi_base", year) + np.testing.assert_allclose( + simulation.calculate("owners_draw_oasdi_room", year), room, atol=0.02 + ) + adjusted = simulation.calculate("owners_draw_net_earnings", year) + np.testing.assert_allclose(draw, np.minimum(adjusted, room), atol=0.02) + assert np.all(wages + existing + draw <= active_cap + 0.02) + assert ( + system.parameters(f"{year}-01-01").gov.irs.payroll.social_security.cap + == cap + ) + + +@settings(**SIMULATION_SETTINGS) +@given(batch=st.lists(simulation_person, min_size=3, max_size=8)) +@example( + batch=[ + (62, 0, 0, 150_000, 0, 0, 0), + (45, 300_000, 50_000, -50_000, 50_000, 0, 0), + (85, 100_000, -20_000, 0, 100_000, 40_000, 20_000), + ] +) +def test_generated_disabled_reform_matches_public_baseline( + private_draw_system, reform_variables, batch +): + """Property 3: disabled formulas agree through complete public tax paths.""" + probe_inputs = {"s_corp_income": 150_000, "self_employment_income": 50_000} + parameters = _parameters(in_effect=False) + probe = _calculate(reform_variables, parameters, probe_inputs) + reference = _calculate(BASELINE_VARIABLES, parameters, probe_inputs) + for output in BASELINE_VARIABLES: + np.testing.assert_array_equal(probe[output], reference[output], err_msg=output) + situation = _simulation_situation(batch) + outputs = ( + *BASELINE_VARIABLES, + "payroll_tax_gross_wages", + "total_self_employment_income", + "irs_gross_income", + "adjusted_gross_income", + "income_tax", + "ss_aime", + "niit_magi", + "net_investment_income_tax", + ) + # This installer changes only the registry. One privately cloned policy + # can therefore retain its readonly 45-year parameter views for both real + # public simulations while their holders and arrays stay independent. + # Constructor activation/isolation remain covered by the original tests + # and both generated raised-cap constructor routes above. + if private_draw_system.system is None: + policy = system.clone() + policy.simulation = None + private_draw_system.system = policy + private_draw_system.baseline_variables = dict(policy.variables) + private_draw_system.baseline_data_modified = policy.data_modified + private_draw_system.parameter_root = policy.parameters + private_draw_system.parameter_fingerprint = _parameter_policy_fingerprint( + policy.parameters + ) + policy = private_draw_system.system + policy.variables = dict(private_draw_system.baseline_variables) + policy.data_modified = private_draw_system.baseline_data_modified + root = private_draw_system.parameter_root + assert policy.parameters is root + assert ( + _parameter_policy_fingerprint(root) == private_draw_system.parameter_fingerprint + ) + assert not policy.parameters("2027-01-01").gov.contrib.ssa.owners_draw.in_effect + assert "owners_draw_gross" not in policy.variables + try: + baseline = Simulation(tax_benefit_system=policy, situation=situation) + try: + _zero_earlier_earnings(baseline, 2027) + assert ( + baseline.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 + ) + baseline_outputs = { + output: baseline.calculate(output, 2027).copy() for output in outputs + } + finally: + _release_simulation_reference(baseline) + del baseline + gc.collect() + + create_owners_draw().apply(policy) + assert policy.parameters is root + assert ( + _parameter_policy_fingerprint(root) + == private_draw_system.parameter_fingerprint + ) + assert not policy.parameters("2027-01-01").gov.contrib.ssa.owners_draw.in_effect + assert "owners_draw_gross" in policy.variables + disabled = Simulation(tax_benefit_system=policy, situation=situation) + try: + _zero_earlier_earnings(disabled, 2027) + assert ( + disabled.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 + ) + for output in outputs: + np.testing.assert_array_equal( + disabled.calculate(output, 2027), + baseline_outputs[output], + err_msg=output, + ) + assert policy.parameters is root + assert ( + _parameter_policy_fingerprint(root) + == private_draw_system.parameter_fingerprint + ) + finally: + _release_simulation_reference(disabled) + del disabled + gc.collect() + finally: + # Restore all original variables, including removing added draw names. + # A fresh public constructor rebinds entities and holders next example. + policy.variables = dict(private_draw_system.baseline_variables) + policy.data_modified = private_draw_system.baseline_data_modified + + +@settings(**SIMULATION_SETTINGS) +@given( + amounts=st.lists( + st.tuples(simulation_amount, st.integers(0, 250_000), simulation_amount), + min_size=3, + max_size=8, + ), + year=st.sampled_from([2027, 2036]), +) +@example( + amounts=[(0, 0, 600_000), (600_000, 0, 600_000), (0, 0, 433)], + year=2027, +) +@example(amounts=[(0, 0, 0), (0, 0, 100_000), (100_000, 50_000, 100_000)], year=2036) +def test_generated_benefit_credit_cap_override_and_historical_lookback( + private_draw_system, reform_variables, amounts, year +): + """Property 9 and regression: default 45-year histories are always valid.""" + probe = _calculate( + reform_variables, + _parameters(benefit_credit=False), + {"s_corp_income": 150_000}, + ) + assert probe["owners_draw_credited_earnings"] == 0 + historical_parameters = _parameters(in_effect=False) + # Historical disabled years may lack the modern reporting-floor node. + del historical_parameters(PERIOD).gov.irs.self_employment.net_earnings_exemption + historical = _calculate( + reform_variables, + historical_parameters, + {"s_corp_income": 150_000}, + outputs=["owners_draw_gross"], + ) + assert historical["owners_draw_gross"] == 0 + batch = [(62, wages, se, draw, 0, 0, 0) for wages, se, draw in amounts] + # Both supplied and computed AIMEs occur in every generated batch. + supplied = np.array( + [ + 123.45 if i % 4 == 1 else 1_234.5 if i % 4 == 3 else 0 + for i in range(len(batch)) + ] + ) + situation = _simulation_situation(batch, year, supplied) + policy = { + SIMULATION_DRAW + "in_effect": {SIMULATION_WINDOW: True}, + SIMULATION_DRAW + "phase_in_share": {SIMULATION_WINDOW: 1}, + SIMULATION_DRAW + "benefit_credit": { + "2027-01-01.2027-12-31": True, + "2028-01-01.2028-12-31": False, + "2036-01-01.2036-12-31": True, + "2037-01-01.2037-12-31": False, + }, + } + credited = _private_simulation(private_draw_system, situation, policy) + _zero_earlier_earnings(credited, year) + p = credited.tax_benefit_system.parameters(f"{year}-01-01") + assert p.gov.simulation.aime_lookback_years == 45 + wages, se, gross = np.array(amounts, dtype=float).T + q = 1 - p.gov.irs.ald.misc.employer_share * ( + p.gov.irs.self_employment.rate.social_security + + p.gov.irs.self_employment.rate.medicare + ) + adjusted = gross * q + adjusted = np.where( + adjusted >= p.gov.irs.self_employment.net_earnings_exemption, adjusted, 0 + ) + np.testing.assert_allclose( + credited.calculate("owners_draw_credited_earnings", year), adjusted, atol=0.02 + ) + annual_cap = p.gov.ssa.social_security.wage_base + # At age 62 the current year's earnings are after the indexing year. All + # earlier supplied earnings default to zero, so the annual ledger gives a + # hand-computable AIME with no projection or wage-indexing dependency. + covered = np.minimum(wages + se + adjusted, annual_cap) + expected = np.where(supplied > 0, supplied, covered / 420) + np.testing.assert_allclose(credited.calculate("ss_aime", year), expected, atol=0.02) + assert np.all(covered <= annual_cap) + # Calling every lookback year explicitly catches a disabled-year formula + # reading the net-earnings floor before its first defined year (2013). + for historic_year in range(year - 45, year + 1): + value = credited.calculate("owners_draw_gross", historic_year) + assert np.isfinite(value).all() + if historic_year < 2027: + np.testing.assert_array_equal(value, np.zeros(len(batch))) + + # A separate batch in a configured credit-off year avoids copying the + # parameter tree to toggle a flag between examples. It is compared with + # the public baseline for that same year and exactly those earnings. + off_year = year + 1 + off_situation = _simulation_situation(batch, off_year, supplied) + no_credit = _private_simulation(private_draw_system, off_situation) + _zero_earlier_earnings(no_credit, off_year) + # Every owner is 62 in this separate scoring-year fixture, with zero + # earnings in all 45 prior years. Main's baseline AIME therefore uses + # only current wages plus gross SE earnings, capped at that year's wage + # base, divided by 35 * 12; a positive supplied AIME still takes priority. + # This independent baseline oracle avoids a third simulation. The + # disabled-reform differential above also checks main's public helper. + off_cap = no_credit.tax_benefit_system.parameters( + f"{off_year}-01-01" + ).gov.ssa.social_security.wage_base + expected_off = np.where( + supplied > 0, supplied, np.minimum(wages + se, off_cap) / 420 + ) + actual_off = no_credit.calculate("ss_aime", off_year) + np.testing.assert_array_equal(actual_off, expected_off.astype(actual_off.dtype)) + np.testing.assert_array_equal( + no_credit.calculate("owners_draw_credited_earnings", off_year), + np.zeros(len(batch)), + ) + + +@settings(**SETTINGS) +@given( + rows=st.lists( + st.tuples( + st.integers(0, 1_000_000), + st.integers(-100_000, 1_000_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + st.integers(-100_000, 100_000), + ), + min_size=1, + max_size=12, + ), + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), + share=st.integers(0, 1_000), + override_factor=st.sampled_from([0, 0.5, 0.8, 1]), + include_hi=st.booleans(), +) +@example( + rows=[(250_000, 400_000, 0, 0, 0), (100_000, 400_000, 0, 0, 0), (0, 0, 0, 0, 0)], + rates=(124, 29, 500), + cap=200_000, + share=1_000, + override_factor=0, + include_hi=False, +) +@example( + rows=[ + (0, 499.99, 0, 0, 0), + (0, 500, 0, 0, 0), + (0, 500.01, 0, 0, 0), + (100_000, 400_000, 0, 0, 0), + ], + rates=(124, 29, 500), + cap=200_000, + share=1_000, + override_factor=0.8, + include_hi=False, +) +@example( + rows=[(250_000, 400_000, 0, 0, 0), (100_000, 400_000, 0, 0, 0), (0, -100, 0, 0, 0)], + rates=(124, 29, 500), + cap=200_000, + share=1_000, + override_factor=0, + include_hi=True, +) +@example( + rows=[(100_000, 400_000, 50_000, 30_000, 20_000)], + rates=(124, 29, 500), + cap=200_000, + share=300, + override_factor=0, + include_hi=False, +) +def test_generated_s770_excludes_actual_owners_draw( + reform_variables, rows, rates, cap, share, override_factor, include_hi +): + """The active NIIT residual consumes the draw's actual M7 classification.""" + wages, active, passive, already_se, other_se = np.asarray(rows, dtype=float).T + inputs = { + "payroll_tax_gross_wages": wages, + "s_corp_income": active, + "partnership_income": passive + already_se, + "passive_partnership_s_corp_income": passive, + "partnership_self_employment_net_earnings": already_se, + "self_employment_income": other_se, + } + policy = dict( + **_policy_rates(rates), + cap=cap, + phase_in_share=share / 1_000, + net_earnings_factor=override_factor, + include_hi=include_hi, + ) + values = _calculate(reform_variables, _parameters(**policy), inputs) + member_values = { + **values, + "partnership_s_corp_income": active + passive + already_se, + "s770_passive_se_income": 0, + "s770_draw_gross": 0, + "s770_draw_oasdi_base": 0, + "s770_draw_hi_in_effect": False, + "s770_active_fica_income": 0, + "is_tax_unit_dependent": False, + } + + def person(name, instant): + return np.asarray(member_values[name]) + + registry = {"owners_draw_niit_exclusion": object()} + person.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace(variables=registry) + ) + # One tax unit per owner preserves the vector batch without a model clone. + tax_unit = SimpleNamespace(members=person, sum=lambda array: array) + result = s770_active_business_income.formula( + tax_unit, PERIOD, _parameters(**policy) + ) + np.testing.assert_allclose(result, active - values["owners_draw_niit_exclusion"]) + + # Installed-but-disabled, pre-start, and uninstalled draws retain the + # independent manual integration hooks, including their ordinary factor. + member_values["s770_draw_oasdi_base"] = 1_234.5 + q = 1 - policy["employer_share"] * (policy["oasdi_rate"] + policy["hi_rate"]) + for installed, enabled, start_year in ( + (True, False, 2027), + (True, True, 2028), + (False, True, 2027), + ): + person.simulation.tax_benefit_system.variables = registry if installed else {} + result = s770_active_business_income.formula( + tax_unit, + PERIOD, + _parameters(**policy, in_effect=enabled, start_year=start_year), + ) + np.testing.assert_allclose(result, active - 1_234.5 / q) + + +@settings(**SETTINGS) +@given( + rows=st.lists( + st.tuples( + st.integers(-1_000_000, 1_000_000), # total pass-through income + st.integers(-1_000_000, 1_000_000), # passive subset + st.integers(-1_000_000, 1_000_000), # already-SE subset + st.integers(-1_000_000, 1_000_000), # passive/SE intersection + st.integers(-1_000_000, 1_000_000), # manual actual gross draw + st.integers(-1_000_000, 1_000_000), # manual taxed OASDI draw + st.booleans(), # manual uncapped-HI indicator + st.integers(-1_000_000, 1_000_000), # active FICA exclusion + st.booleans(), # dependent exclusion + st.integers(0, 3), # tax-unit membership + ), + min_size=1, + max_size=12, + ), + rates=_RATE_POLICY, + override_factor=st.sampled_from([0, 0.5, 0.8, 1]), + include_hi=st.booleans(), +) +@example( + rows=[ + (400_000, 20_000, 30_000, 5_000, 50_000, 1_234.5, False, 6_000, False, 0), + (-10_000, -5_000, -3_000, -1_000, 2_000, 9_000, True, 1_000, False, 0), + (400_000, 0, 0, 0, 50_000, 12_345, False, 0, True, 0), + (100_000, 0, 0, 0, 8_000, 50_000, True, 2_000, False, 1), + ], + rates=(124, 29, 500), + override_factor=0.8, + include_hi=True, +) +@example( + rows=[(0, 0, 0, 0, 0, 0, False, 0, False, 0)], + rates=(200, 40, 500), + override_factor=0, + include_hi=False, +) +def test_generated_disabled_owners_draw_preserves_main_s770( + rows, rates, override_factor, include_hi +): + """Inactive draws preserve main's full signed residual and manual hooks.""" + columns = list(zip(*rows)) + ( + total, + passive, + already_se, + overlap, + manual_gross, + manual_oasdi, + manual_hi, + active_fica, + dependent, + tax_unit_index, + ) = [np.asarray(column) for column in columns] + policy = _policy_rates(rates) + q = 1 - policy["employer_share"] * (policy["oasdi_rate"] + policy["hi_rate"]) + # Independent oracle for main fe9ec1270f / #10402: net signed sources, + # subtract the manual M7 exclusion using main's rate-derived factor, then + # exclude dependents and aggregate head/spouse amounts within tax units. + # An owners-draw factor override must have no effect while it is inactive. + expected_person = np.zeros(len(rows), dtype=float) + for index, row in enumerate(rows): + t, p, se, intersection, gross, base, hi, fica, dep, _ = row + if not dep: + manual_exclusion = gross if hi else base / q + expected_person[index] = t - p - se + intersection - manual_exclusion - fica + expected = np.bincount(tax_unit_index, weights=expected_person, minlength=4) + values = { + "partnership_s_corp_income": total, + "passive_partnership_s_corp_income": passive, + "partnership_self_employment_net_earnings": already_se, + "s770_passive_se_income": overlap, + "s770_draw_gross": manual_gross, + "s770_draw_oasdi_base": manual_oasdi, + "s770_draw_hi_in_effect": manual_hi, + "s770_active_fica_income": active_fica, + "is_tax_unit_dependent": dependent, + # This is the actual exclusion for a disabled or pre-start draw. If + # the bridge accidentally consumes it, the manual hooks disappear. + "owners_draw_niit_exclusion": np.zeros(len(rows)), + } + + def person(name, instant): + return values[name] + + person.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace(variables={}) + ) + tax_unit = SimpleNamespace( + members=person, + sum=lambda array: np.bincount(tax_unit_index, weights=array, minlength=4), + ) + for installed, enabled, start_year in ( + (True, False, 2027), + (True, True, 2028), + (False, True, 2027), + ): + person.simulation.tax_benefit_system.variables = ( + {"owners_draw_niit_exclusion": object()} if installed else {} + ) + parameters = _parameters( + **policy, + in_effect=enabled, + start_year=start_year, + net_earnings_factor=override_factor, + include_hi=include_hi, + ) + if not installed: + # The main-only path must not require the draw parameter subtree. + del parameters(PERIOD).gov.contrib.ssa.owners_draw + actual = s770_active_business_income.formula(tax_unit, PERIOD, parameters) + np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-8) + + +@pytest.mark.parametrize( + "installed, enabled, start_year", + [(True, True, 2027), (True, False, 2027), (True, True, 2028), (False, True, 2027)], +) +def test_s770_rejects_unclassified_passive_inclusive_draw( + reform_variables, installed, enabled, start_year +): + """Only the new active joint route needs an active/NII source allocation.""" + inputs = { + "s_corp_income": [200_000], + "passive_partnership_s_corp_income": [100_000], + } + standalone = _calculate( + reform_variables, + _parameters(material_participation_only=False), + inputs, + outputs=["owners_draw_active_income"], + ) + # Passive-inclusive treatment remains available for the standalone draw. + np.testing.assert_array_equal(standalone["owners_draw_active_income"], [200_000]) + values = { + "partnership_s_corp_income": 200_000, + "passive_partnership_s_corp_income": 100_000, + "partnership_self_employment_net_earnings": 0, + "s770_passive_se_income": 0, + "s770_draw_gross": 50_000, + "s770_draw_oasdi_base": 0, + "s770_draw_hi_in_effect": True, + "s770_active_fica_income": 0, + "is_tax_unit_dependent": False, + "owners_draw_niit_exclusion": 100_000, + } + + def person(name, instant): + return np.asarray(values[name]) + + person.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace( + variables={"owners_draw_niit_exclusion": object()} if installed else {} + ) + ) + tax_unit = SimpleNamespace(members=person, sum=lambda array: array) + parameters = _parameters( + material_participation_only=False, + in_effect=enabled, + start_year=start_year, + ) + if installed and enabled and start_year <= PERIOD.start.year: + with pytest.raises(ValueError, match="validated active/NII allocation"): + s770_active_business_income.formula(tax_unit, PERIOD, parameters) + else: + np.testing.assert_array_equal( + s770_active_business_income.formula(tax_unit, PERIOD, parameters), + 50_000, + ) + # Main rejects a nonpositive standard factor even for a manual HI + # hook. Inactive and absent draws must preserve that error as well. + invalid_q = _parameters( + oasdi_rate=1, + hi_rate=1, + employer_share=0.5, + material_participation_only=False, + in_effect=enabled, + start_year=start_year, + ) + if not installed: + del invalid_q(PERIOD).gov.contrib.ssa.owners_draw + with pytest.raises(ValueError, match="netting factor must be positive"): + s770_active_business_income.formula(tax_unit, PERIOD, invalid_q) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py index 3e33405ff62..18263e9accf 100644 --- a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py @@ -25,19 +25,41 @@ class s770_active_business_income(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members - p = parameters(period).gov.irs - q = 1 - p.ald.misc.employer_share * ( - p.self_employment.rate.social_security + p.self_employment.rate.medicare + draw_installed = ( + "owners_draw_niit_exclusion" + in person.simulation.tax_benefit_system.variables ) - if q <= 0: - raise ValueError("S.770 owners-draw netting factor must be positive") - # M7: OASDI-only draws exclude only profits actually taxed after cap - # room. Uncapped HI draws exclude all actual gross draw, never planned D. - draw_exclusion = where( - person("s770_draw_hi_in_effect", period), - person("s770_draw_gross", period), - person("s770_draw_oasdi_base", period) / q, + draw_policy = ( + parameters(period).gov.contrib.ssa.owners_draw if draw_installed else None ) + if ( + draw_policy is not None + and draw_policy.in_effect + and period.start.year >= draw_policy.start_year + ): + if not draw_policy.material_participation_only: + raise ValueError( + "Joint owners-draw and active-business NIIT requires " + "material_participation_only=True; passive-inclusive draws " + "need a validated active/NII allocation" + ) + # Use the actual floor-, cap- and factor-aware exclusion. An + # explicit draw factor can differ from the standard SE factor. + draw_exclusion = person("owners_draw_niit_exclusion", period) + else: + p = parameters(period).gov.irs + q = 1 - p.ald.misc.employer_share * ( + p.self_employment.rate.social_security + p.self_employment.rate.medicare + ) + if q <= 0: + raise ValueError("S.770 owners-draw netting factor must be positive") + # M7: OASDI-only excludes only profits actually taxed after cap + # room. HI draws exclude all actual gross draw, never planned D. + draw_exclusion = where( + person("s770_draw_hi_in_effect", period), + person("s770_draw_gross", period), + person("s770_draw_oasdi_base", period) / q, + ) active_se = person("partnership_self_employment_net_earnings", period) - person( "s770_passive_se_income", period ) diff --git a/policyengine_us/variables/gov/ssa/social_security/ss_aime.py b/policyengine_us/variables/gov/ssa/social_security/ss_aime.py index 6578620d639..b0d1bc89c81 100644 --- a/policyengine_us/variables/gov/ssa/social_security/ss_aime.py +++ b/policyengine_us/variables/gov/ssa/social_security/ss_aime.py @@ -30,7 +30,7 @@ def formula(person, period, parameters): return where(has_input, aime_input, computed_aime) -def _compute_aime(person, period, parameters): +def _compute_aime(person, period, parameters, additional_earnings_variable=None): age = person("age", period) current_year = period.start.year @@ -55,6 +55,10 @@ def _compute_aime(person, period, parameters): employment_income = person("employment_income", year_period) self_employment_income = person("total_self_employment_income", year_period) total_earnings = employment_income + self_employment_income + if additional_earnings_variable is not None: + total_earnings = total_earnings + person( + additional_earnings_variable, year_period + ) wage_base = parameters(year_period).gov.ssa.social_security.wage_base covered_earnings = min_(total_earnings, wage_base)