diff --git a/changelog.d/reform-s770-section8-investment-business-income-tax.added.md b/changelog.d/reform-s770-section8-investment-business-income-tax.added.md new file mode 100644 index 00000000000..09d245985e8 --- /dev/null +++ b/changelog.d/reform-s770-section8-investment-business-income-tax.added.md @@ -0,0 +1 @@ +Add independently enabled S.770 section 8 NII add-on and active pass-through income tax options, signed unified-base allocation, and configurable OASDI revenue transfers. diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/deny_nol.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/deny_nol.yaml new file mode 100644 index 00000000000..bd0226b7068 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/deny_nol.yaml @@ -0,0 +1,12 @@ +description: When the active option is enabled, add back the validated NOL deduction supplied + in s770_niit_nol_deduction to broadened NII before allocating the unified base. There + is no automatic federal section 172 deduction variable. +values: + '0000-01-01': true +metadata: + label: S.770 broadened NII denies NOL deduction + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: bool diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/in_effect.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/in_effect.yaml new file mode 100644 index 00000000000..d401534a47f --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/in_effect.yaml @@ -0,0 +1,11 @@ +description: The signed additional active-business base applies if this is true, independently + of the NII add-on. +values: + '0000-01-01': false +metadata: + label: S.770 active business option in effect + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: bool diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/oasdi_share.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/oasdi_share.yaml new file mode 100644 index 00000000000..8ec6b13ab7a --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/oasdi_share.yaml @@ -0,0 +1,11 @@ +description: In custom and all_addon transfer modes, this share of the active business + tax goes to OASDI. +values: + '0000-01-01': 0.62 +metadata: + label: S.770 active business OASDI share + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: /1 diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/rate.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/rate.yaml new file mode 100644 index 00000000000..11b1f8fcd7b --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/rate.yaml @@ -0,0 +1,10 @@ +description: The active business option taxes its allocated base at this total rate. +values: + '0000-01-01': 0.162 +metadata: + label: S.770 active business tax rate + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: /1 diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/start_year.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/start_year.yaml new file mode 100644 index 00000000000..224fa59bbc7 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/active_business/start_year.yaml @@ -0,0 +1,11 @@ +description: The active-business option starts in this tax year. Section 8(c) specifies + taxable years beginning after enactment; 2026 is an illustrative enactment assumption. +values: + '0000-01-01': 2026 +metadata: + label: S.770 active business start year + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: year diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/in_effect.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/in_effect.yaml new file mode 100644 index 00000000000..0a9f7607ee3 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/in_effect.yaml @@ -0,0 +1,11 @@ +description: The NII add-on applies if this is true, independently of the active-income + option. +values: + '0000-01-01': false +metadata: + label: S.770 NII add-on in effect + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: bool diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/oasdi_share.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/oasdi_share.yaml new file mode 100644 index 00000000000..4fc607b34b1 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/oasdi_share.yaml @@ -0,0 +1,12 @@ +description: In custom transfer mode, this share of the NII add-on goes to OASDI. The bill + specifies 62 percent of all resulting section 1411 tax; this component share alone does + not reproduce that transfer. +values: + '0000-01-01': 0.62 +metadata: + label: S.770 NII add-on OASDI share + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: /1 diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/rate.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/rate.yaml new file mode 100644 index 00000000000..c521030b3c4 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/rate.yaml @@ -0,0 +1,11 @@ +description: The NII option adds this rate to existing NIIT. The bill example is 16.2 percent + minus 3.8 percent. +values: + '0000-01-01': 0.124 +metadata: + label: S.770 NII add-on rate + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: /1 diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/start_year.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/start_year.yaml new file mode 100644 index 00000000000..9a338777bbe --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/nii_addon/start_year.yaml @@ -0,0 +1,11 @@ +description: The NII add-on starts in this tax year. Section 8(c) specifies taxable years + beginning after enactment, not a fixed year; 2026 is an illustrative enactment assumption. +values: + '0000-01-01': 2026 +metadata: + label: S.770 NII add-on start year + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: year diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/bill_share.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/bill_share.yaml new file mode 100644 index 00000000000..0a39aee95c0 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/bill_share.yaml @@ -0,0 +1,11 @@ +description: In bill transfer mode, this share of all resulting section 1411 tax goes to + the merged Social Security fund. +values: + '0000-01-01': 0.62 +metadata: + label: S.770 bill OASDI transfer share + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: /1 diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/mode.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/mode.yaml new file mode 100644 index 00000000000..f11d9f5529d --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/mode.yaml @@ -0,0 +1,14 @@ +description: 'OASDI transfer convention: 0=custom, 1=combined_rate_share, 2=all_addon, 3=bill. Custom uses each incremental part share; combined_rate_share + allocates the OASDI rate over each combined rate; all_addon sends every NII add-on point + to OASDI and uses the active share; bill sends the bill share of the entire resulting + section 1411 tax, including existing NIIT. This selects transfers, not full statutory + category coverage.' +values: + '0000-01-01': 0 +metadata: + label: S.770 OASDI transfer mode + period: year + unit: int + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/oasdi_rate.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/oasdi_rate.yaml new file mode 100644 index 00000000000..79c6581dd32 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/oasdi_rate.yaml @@ -0,0 +1,14 @@ +description: In combined_rate_share mode, allocate this rate over the enabled combined + NII rate and active tax rate. At bill example rates the OACT convention is 12.4 divided + by 16.2; it differs from the bill share of 62 percent. +values: + '0000-01-01': 0.124 +metadata: + label: S.770 OASDI rate for combined-rate allocation + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + - title: OACT 2025 coverage provisions, F8 + href: https://www.ssa.gov/OACT/solvency/provisions_tr2025/coverage.html + unit: /1 diff --git a/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/start_year.yaml b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/start_year.yaml new file mode 100644 index 00000000000..97eb1a726c3 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/congress/s770/section_8/transfer/start_year.yaml @@ -0,0 +1,12 @@ +description: OASDI transfers start in this modeled annual year. Section 9(a) appropriates + NIIT revenue from the first fiscal year beginning after enactment; this annual tax model + does not prorate fiscal-year receipts. +values: + '0000-01-01': 2026 +metadata: + label: S.770 OASDI transfer start year + period: year + reference: + - title: S.770 (119th Congress), sections 8 and 9 + href: https://www.congress.gov/bill/119th-congress/senate-bill/770/text + unit: year diff --git a/policyengine_us/reforms/congress/s770/README.md b/policyengine_us/reforms/congress/s770/README.md new file mode 100644 index 00000000000..9cf73485fb1 --- /dev/null +++ b/policyengine_us/reforms/congress/s770/README.md @@ -0,0 +1,168 @@ +# S.770 section 8 split tax options + +These independently enabled options model an NII rate add-on and an additional +active pass-through base. Both default off. Parameters are under +`gov.contrib.congress.s770.section_8`; each part has its own `in_effect`, +`start_year`, rate and custom OASDI share. The example rates are the proposed +bill's 12.4 percentage-point add-on and 16.2 percent active rate. No package +settings are encoded. + +## Sources and dates + +[S.770, 119th Congress, introduced text, sections 8–9](https://www.congress.gov/bill/119th-congress/senate-bill/770/text) +was retrieved October 23, 2026. Section 8(a)(1) replaces 3.8 percent with 16.2 +percent. Section 8(b) broadens the investment-income definition, denies the +section 172 NOL deduction, and excludes income actually taxed under SECA or +FICA. Section 8(c) applies to “taxable years beginning after the date of enactment +of this Act”. There is no statutory fixed start year. The configurable 2026 +defaults illustrate assumed enactment timing and have no effect while disabled. + +Section 9(a) assigns 62 percent of all section 1411 tax to a merged Social +Security fund. Revenue appropriation starts with the first fiscal year beginning +after enactment; assets transfer on January 1 of the first calendar year beginning +after enactment. +`transfer.start_year` is a separate annual modeling choice. This liability model +does not estimate fiscal-year collection lags or prorate receipts. + +## Signed base and tax accounting + +Use the existing `niit_magi`, including its modeled estate and section 911 +adjustments, and existing filing-status thresholds. Let `X=max(MAGI−threshold,0)`, +`N` be signed adjusted old-definition NII, and `A` the signed additional active +base (zero if that option is off): + +``` +L = min(max(N + A, 0), X) +n = min(max(N, 0), L) +a = L - n +tax = (existing_rate + enabled_addon_rate) * n + enabled_active_rate * a +``` + +Losses net before clipping. Both allocated shares are nonnegative; NII consumes +the limit first even when its add-on rate is zero. When active inclusion is on, +the existing 3.8 percent NIIT also uses `n`. The reform replaces +`net_investment_income_tax` with the entire resulting liability. The two named +component taxes are already included there and in federal income tax; adding +them again would double count. + +With both options enabled, identical sources, exclusions, NOL treatment, MAGI, +thresholds and timing, and `existing_rate + addon_rate = active_rate = .162`, +this equals `.162 * L` +for all signed inputs. This is a conditional liability identity, not a claim +that the available fields implement every statutory category. For `N=−10,000`, +`A=20,000`, `X=50,000`, the tax is $1,620. For `N=20,000`, `A=−10,000`, the +tax is also $1,620. Active losses can therefore reduce liability relative to +the add-on alone ($3,240 in the latter example). Activation is monotone with +nonnegative active income, nonnegative rates and fixed signed NII/source rules, +including exclusions and NOL treatment. Changing source exclusions can lower +liability even when the active residual is nonnegative: removing $10,000 of +already-SE passive income from NII with no active residual reduces existing NIIT +from $380 to zero. Arbitrary signed active losses also defeat monotonicity. + +## Pass-through classification and SECA/FICA interaction + +The person-level total is `partnership_s_corp_income`, whose formula sums +`s_corp_income + partnership_income`. Subtract the passive subset and gross +already-SE active subset, then net signed results across head/spouse members. +Dependents' income belongs to their own return. This assumes the passive and +already-SE subsets are disjoint unless their validated intersection is supplied +in `s770_passive_se_income`. An intersection is removed from NII too. Signed +subsets are not independently floored. Cross-entity netting within the available +person totals is an approximation. Nonpassive status is only a proxy for material +participation, not a determination of each limited partner's legal status. + +The already-SE exclusion uses `partnership_self_employment_net_earnings`, not a +capped OASDI base: existing SE earnings can still pay uncapped HI above that cap. +Separately paid wages are outside distributive entity income. If supplied totals +embed FICA-taxed amounts, use the validated `s770_active_fica_income` exclusion; +use `s770_nii_fica_seca_income` for other covered amounts already in NII, excluding +the separately supplied passive/SE intersection. These categories and exclusions +must be disjoint and supported by source data; the model cannot infer overlap. + +For a future owners-draw reform, the `s770_draw_*` inputs are classification hooks +and do not impose SECA themselves. The supplier must connect actual taxed amounts: + +* OASDI-only: exclude `s770_draw_oasdi_base / q`, where the draw base is after + wages, existing SE earnings and applicable cap room. Above-room profits remain + in `A` because no SECA applies to them. +* Uncapped draw HI: exclude the full actual `s770_draw_gross`. + +Here `q=1−employer_share*(SE_OASDI_rate+SE_HI_rate)`, currently .9235, using +existing parameters. The undrawn phase-in share and floor-rejected planned draw +remain in `A`. The supplying reform must apply its phase-in and reporting floor +and provide the actual floor-accepted draw and taxed base; an undrawn or +floor-rejected draw supplies zero for both hooks, including when HI is selected. +These hooks do not calculate the draw, phase-in or reporting floor themselves. +No independent owners-draw policy is enabled by these hooks. + +## Literal-bill category audit and NOL support + +| Statutory category | Available treatment and limitation | +| --- | --- | +| Interest, dividends, annuities, royalties and rents, including ordinary-course business sources after §8(b)(1)(A) | Existing NII sources are reused. Separate ordinary-course/SECA/FICA classification, annuity and royalty completeness are not established. | +| Trade/business income under amended §1411(c)(1)(A)(ii) | Active pass-through residual is added. All other business categories and material-participation determinations are not inferred. | +| Net property-disposition gains, including business property after §8(b)(1)(C) | Existing capital-gain sources are reused. Full business-property gain and associated exclusion classification is unsupported. | +| §172 deduction denial under §8(b)(2) | No federal §172 NOL deduction input/formula exists in the baseline NII source list. Indiana NOL variables are state-specific. | +| Items actually taxed by §1401 or §3101 | Already-SE fields and explicit validated overlap/FICA/draw hooks provide exclusions, not a complete statutory source classifier. | + +`s770_niit_nol_deduction` is an explicit TaxUnit input for a validated deduction +**already subtracted from supplied NII**. When active inclusion and `deny_nol` +are on, it is added back to `N` before unified allocation, changing broadened NII +and the existing NIIT component too. Do not supply an AGI-only deduction or treat +ordinary business losses as an NOL deduction. Baseline formulas deduct no federal +NOL from NII, so the default input is zero. Complete literal-bill coverage needs +a validated federal deduction/data route and the missing category classification; +neither a zero input nor matching rates/transfers establishes that coverage. + +## OASDI transfers + +Transfers are separate from liability. `transfer.mode` selects: + +| Mode | Existing NIIT | NII add-on | Active tax | +| --- | --- | --- | --- | +| `0` = `custom` (default) | 0 | `nii_addon.oasdi_share` | `active_business.oasdi_share` | +| `1` = `combined_rate_share` | OASDI rate / combined NII rate | Same combined share | OASDI rate / active rate | +| `2` = `all_addon` | 0 | 100% | `active_business.oasdi_share` | +| `3` = `bill` | `transfer.bill_share` | Same bill share | Same bill share | + +The mode parameter uses integer codes because core does not support string +parameter scalars. + +The [OACT 2025 F8 description](https://www.ssa.gov/OACT/solvency/provisions_tr2025/coverage.html) +allocates the active tax attributable to 12.4 of its 16.2 percentage points to +OASDI. That convention is approximately 76.5432 percent, distinct from the +bill's 62 percent. Here it is also available for the combined NII rate as a +custom transfer convention. A combined-rate share requires an OASDI rate no +larger than each positive tax rate to which it applies; a zero tax has zero +transfer. When the NII add-on is off or its rate is zero, this custom mode +transfers no existing NIIT, while the active transfer remains independent. +`bill` selects the bill's **transfer rule**, not unsupported category +coverage. Custom part shares default to .62; custom mode does not transfer the +existing 3.8 percent component. Bill mode does, through the separately reported +`s770_existing_niit_revenue_oasdi`. All transfers are zero when both options are +off or before the transfer start year. + +`s770_nii_addon_revenue_oasdi`, `s770_active_business_income_revenue_oasdi`, and +`s770_existing_niit_revenue_oasdi` sum to `s770_revenue_oasdi`. Each is its selected +mode's share times the corresponding tax. Aggregate each TaxUnit once alongside +`tob_revenue_oasdi`. Liability equivalence does not establish allocation equivalence. + +## Scoring-data limitation + +The certified pre-repair artifact forces `s_corp_income` to zero, puts combined +income carrying passive mass in `partnership_income`, and omits +`passive_partnership_s_corp_income` (which therefore defaults to zero). The active +formula runs on those fields, but (b) overstates active income while (a) +understates NII because passive pass-through income is missing. Microcosm #1251 +repairs total/passive fields; #1261 splits the entities. Scoring remains +provisional until the selected artifact actually contains both repairs and its +stored fields are checked. No population effects are asserted here. + +## Test structure + +The YAML examples are grouped by reform configuration across four variable-named +files. Each file stays within the existing five-combination memory budget; +the Congress batcher can therefore release cached reform models between files. +Keep that grouping when adding cases rather than combining the files. Python +properties exercise the vector helpers, with one shared household simulation +checking dated registration, source aggregation and revenue-variable integration. diff --git a/policyengine_us/reforms/congress/s770/__init__.py b/policyengine_us/reforms/congress/s770/__init__.py new file mode 100644 index 00000000000..ae73789202e --- /dev/null +++ b/policyengine_us/reforms/congress/s770/__init__.py @@ -0,0 +1 @@ +from .section_8 import create_s770_section_8_reform diff --git a/policyengine_us/reforms/congress/s770/section_8.py b/policyengine_us/reforms/congress/s770/section_8.py new file mode 100644 index 00000000000..1334c1ed74c --- /dev/null +++ b/policyengine_us/reforms/congress/s770/section_8.py @@ -0,0 +1,126 @@ +"""Independent S.770 section 8 options; see README.md for scope and accounting.""" + +import numpy as np + +from policyengine_us.model_api import * + + +def enabled(part, period): + return part.in_effect and period.start.year >= part.start_year + + +def active_residual(total, passive, already_se, draw_exclusion=0): + """Signed profits, assuming the passive and already-SE subsets are disjoint.""" + return total - passive - already_se - draw_exclusion + + +def allocate_bases(nii, active, excess): + """Net signed sources before clipping, then allocate the limit to NII first.""" + limited = np.minimum(np.maximum(nii + active, 0), np.maximum(excess, 0)) + nii_base = np.minimum(np.maximum(nii, 0), limited) + return nii_base, limited - nii_base + + +def transfer_shares( + mode, + existing_rate, + addon_rate, + active_rate, + nii_share, + active_share, + bill_share, + oasdi_rate, +): + """Shares of existing NIIT, the add-on, and active tax, respectively.""" + # Core parameters accept numbers, booleans and lists, not string scalars. + mode = {0: "custom", 1: "combined_rate_share", 2: "all_addon", 3: "bill"}.get( + mode, mode + ) + if mode == "custom": + return 0, nii_share, active_share + if mode == "bill": + return bill_share, bill_share, bill_share + if mode == "all_addon": + return 0, 1, active_share + if mode == "combined_rate_share": + combined = existing_rate + addon_rate + # With no NII add-on, this custom mode transfers no old-law NII + # receipts. The independently enabled active option still transfers. + nii = oasdi_rate / combined if addon_rate and combined else 0 + active = oasdi_rate / active_rate if active_rate else 0 + if not (0 <= nii <= 1 and 0 <= active <= 1): + raise ValueError("OASDI rate must not exceed an enabled combined tax rate") + return nii, nii, active + raise ValueError(f"Unknown S.770 transfer mode: {mode}") + + +def revenue_shares(parameters, period): + p = parameters(period).gov.contrib.congress.s770.section_8 + if period.start.year < p.transfer.start_year: + return 0, 0, 0 + nii_on = enabled(p.nii_addon, period) + active_on = enabled(p.active_business, period) + if not (nii_on or active_on): + return 0, 0, 0 + existing_rate = parameters(period).gov.irs.investment.net_investment_income_tax.rate + return transfer_shares( + p.transfer.mode, + existing_rate, + p.nii_addon.rate if nii_on else 0, + p.active_business.rate if active_on else 0, + p.nii_addon.oasdi_share, + p.active_business.oasdi_share, + p.transfer.bill_share, + p.transfer.oasdi_rate, + ) + + +def create_s770_section_8(): + class net_investment_income_tax(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "Net investment income tax including enabled S.770 section 8 options" + unit = USD + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + documentation = ( + "Entire resulting section 1411 liability, including the existing rate. " + "When the active option is enabled, signed active losses also change " + "the existing NIIT base. The named S.770 component taxes are already " + "included here and must not be added to income tax a second time." + ) + + def formula(tax_unit, period, parameters): + return add( + tax_unit, + period, + [ + "s770_existing_niit_tax", + "s770_nii_addon_tax", + "s770_active_business_income_tax", + ], + ) + + class reform(Reform): + def apply(self): + self.update_variable(net_investment_income_tax) + + return reform + + +def create_s770_section_8_reform(parameters, period, bypass=False): + if bypass: + return create_s770_section_8() + p = parameters.gov.contrib.congress.s770.section_8 + # Install once if enabled anywhere in the parameter history. Formula gates + # retain both independent start years, including requests in another order. + if any( + value.value + for part in (p.nii_addon, p.active_business) + for value in part.in_effect.values_list + ): + return create_s770_section_8() + return None + + +section_8 = create_s770_section_8_reform(None, None, bypass=True) diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 61fcd3050de..422e304b734 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -1,3 +1,4 @@ +from .congress.s770 import create_s770_section_8_reform from .congress.delauro import create_american_family_act_with_baby_bonus_reform from .states.al.hb527 import create_al_hb527_overtime_deduction_reform from .dc_kccatc import create_dc_kccatc_reform @@ -601,7 +602,10 @@ def create_structural_reforms_from_parameters(parameters, period): parameters, period ) + s770_section_8 = create_s770_section_8_reform(parameters, period) + reforms = [ + s770_section_8, afa_reform, winship_reform, dc_kccatc_reform, diff --git a/policyengine_us/tests/policy/contrib/congress/s770/s770_active_business_income.yaml b/policyengine_us/tests/policy/contrib/congress/s770/s770_active_business_income.yaml new file mode 100644 index 00000000000..bfd010914d3 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/congress/s770/s770_active_business_income.yaml @@ -0,0 +1,613 @@ +- name: Active S corporation shareholder with 500000 of income + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + s_corp_income: 500_000 + niit_magi: 500_000 + filing_status: SINGLE + output: + s770_active_business_income: 500_000 + s770_nii_taxable_base: 0 + s770_active_taxable_base: 300_000 + s770_nii_addon_tax: 0 + # 16.2% x min(500,000, 500,000 - 200,000). + s770_active_business_income_tax: 48_600 + net_investment_income_tax: 48_600 + s770_active_business_income_revenue_oasdi: 30_132 + s770_revenue_oasdi: 30_132 + +- name: Limited partner excludes the passive subset from the active residual + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + partnership_income: 300_000 + passive_partnership_s_corp_income: 100_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + partnership_s_corp_income: 300_000 + net_investment_income: 100_000 + s770_nii_base: 100_000 + s770_active_business_income: 200_000 + # NII consumes 100,000 of the shared 200,000 MAGI excess first. + s770_nii_taxable_base: 100_000 + s770_active_taxable_base: 100_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 16_200 + net_investment_income_tax: 20_000 + +- name: General partner earnings already subject to SECA have no new active tax + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + partnership_income: 300_000 + partnership_self_employment_net_earnings: 300_000 + niit_magi: 500_000 + filing_status: SINGLE + output: + s770_active_business_income: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 0 + +- name: Wages subject to FICA do not enter either new tax base + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + employment_income: 500_000 + filing_status: SINGLE + output: + s770_nii_base: 0 + s770_active_business_income: 0 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 0 + +- name: Below threshold neither part imposes tax + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 50_000 + s_corp_income: 100_000 + niit_magi: 199_999 + filing_status: SINGLE + output: + s770_nii_taxable_base: 0 + s770_active_taxable_base: 0 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 0 + s770_revenue_oasdi: 0 + +- name: Both switches off preserve current NIIT on the same household + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 3_800 + s770_revenue_oasdi: 0 + +- name: NII addon alone on the same household + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 100_000 + s770_active_taxable_base: 0 + s770_nii_addon_tax: 12_400 + s770_active_business_income_tax: 0 + net_investment_income_tax: 16_200 + s770_nii_addon_revenue_oasdi: 7_688 + s770_existing_niit_revenue_oasdi: 0 + s770_revenue_oasdi: 7_688 + +- name: Active option alone on the same household + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 100_000 + s770_active_taxable_base: 100_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 16_200 + net_investment_income_tax: 20_000 + s770_active_business_income_revenue_oasdi: 10_044 + s770_revenue_oasdi: 10_044 + +- name: Both options on the same household share one MAGI excess + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 100_000 + s770_active_taxable_base: 100_000 + s770_nii_addon_tax: 12_400 + s770_active_business_income_tax: 16_200 + # .162 x min(100,000 + 200,000, 400,000 - 200,000). + net_investment_income_tax: 32_400 + s770_nii_addon_revenue_oasdi: 7_688 + s770_active_business_income_revenue_oasdi: 10_044 + s770_existing_niit_revenue_oasdi: 0 + s770_revenue_oasdi: 17_732 + +- name: Signed NII loss offsets active income before either bucket is clipped + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + estate_income: -10_000 + s_corp_income: 20_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: -10_000 + s770_active_business_income: 20_000 + s770_nii_taxable_base: 0 + s770_active_taxable_base: 10_000 + s770_nii_addon_tax: 0 + # Review counterexample: .162 x (-10,000 + 20,000), not .162 x 20,000. + s770_active_business_income_tax: 1_620 + net_investment_income_tax: 1_620 + +- name: Signed active loss also reduces the existing NIIT component + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 20_000 + s_corp_income: -10_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: 20_000 + s770_active_business_income: -10_000 + s770_nii_taxable_base: 10_000 + s770_active_taxable_base: 0 + s770_nii_addon_tax: 1_240 + s770_active_business_income_tax: 0 + net_investment_income_tax: 1_620 + +- name: A net unified loss produces no resulting section 1411 liability + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + estate_income: -30_000 + s_corp_income: 20_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: -30_000 + s770_active_business_income: 20_000 + s770_nii_taxable_base: 0 + s770_active_taxable_base: 0 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 0 + +- name: An inactive active option does not import its signed loss + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + taxable_interest_income: 20_000 + s_corp_income: -10_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 20_000 + s770_active_taxable_base: 0 + s770_nii_addon_tax: 2_480 + s770_active_business_income_tax: 0 + net_investment_income_tax: 3_240 + +- name: NOL denial restores the deduction to broadened NII before allocation + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + # This supplied NII already deducts 30,000 under section 172. + net_investment_income: -10_000 + s770_niit_nol_deduction: 30_000 + s_corp_income: 20_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: 20_000 + s770_active_business_income: 20_000 + s770_nii_taxable_base: 20_000 + s770_active_taxable_base: 20_000 + s770_nii_addon_tax: 2_480 + s770_active_business_income_tax: 3_240 + net_investment_income_tax: 6_480 + +- name: NII addon alone does not activate the active option NOL denial + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + net_investment_income: -10_000 + s770_niit_nol_deduction: 30_000 + s_corp_income: 20_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: -10_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 0 + +- name: Both options respect the illustrative default start year + period: 2025 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 3_800 + s770_revenue_oasdi: 0 + +- name: Head and spouse aggregate but dependent income stays outside both bases + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + people: + head: + age: 45 + is_tax_unit_head: true + taxable_interest_income: 10_000 + s_corp_income: 100_000 + spouse: + age: 45 + is_tax_unit_spouse: true + taxable_interest_income: 20_000 + partnership_income: 200_000 + dependent: + age: 15 + is_tax_unit_dependent: true + taxable_interest_income: 50_000 + s_corp_income: 500_000 + tax_units: + family: + members: [head, spouse, dependent] + filing_status: JOINT + niit_magi: 350_000 + households: + household: + members: [head, spouse, dependent] + state_code: TX + output: + s770_nii_base: 30_000 + s770_active_business_income: 300_000 + s770_nii_taxable_base: 30_000 + s770_active_taxable_base: 70_000 + s770_nii_addon_tax: 3_720 + s770_active_business_income_tax: 11_340 + net_investment_income_tax: 16_200 + +- name: OASDI only draw above cap room excludes only profits actually taxed + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + s_corp_income: 200_000 + s770_draw_gross: 150_000 + # q=.9235; 9,235 of covered net earnings represents 10,000 gross profit. + s770_draw_oasdi_base: 9_235 + s770_draw_hi_in_effect: false + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_active_business_income: 190_000 + s770_active_taxable_base: 190_000 + s770_active_business_income_tax: 30_780 + net_investment_income_tax: 30_780 + +- name: HI draw above cap room excludes the full gross draw from the active base + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + s_corp_income: 200_000 + s770_draw_gross: 150_000 + s770_draw_oasdi_base: 9_235 + s770_draw_hi_in_effect: true + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_active_business_income: 50_000 + s770_active_taxable_base: 50_000 + s770_active_business_income_tax: 8_100 + net_investment_income_tax: 8_100 + +- name: OASDI only draw with no remaining cap room leaves all profits active + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + s_corp_income: 200_000 + s770_draw_gross: 150_000 + s770_draw_oasdi_base: 0 + s770_draw_hi_in_effect: false + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_active_business_income: 200_000 + s770_active_taxable_base: 200_000 + s770_active_business_income_tax: 32_400 + net_investment_income_tax: 32_400 + +- name: Draw gross exclusion uses the netting factor from changed SECA rates + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.irs.self_employment.rate.social_security: .14 + gov.irs.self_employment.rate.medicare: .06 + s_corp_income: 200_000 + s770_draw_gross: 150_000 + # q = 1 - .5 x (.14 + .06) = .9, so 9,000 / q = 10,000 gross. + s770_draw_oasdi_base: 9_000 + s770_draw_hi_in_effect: false + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_active_business_income: 190_000 + s770_active_taxable_base: 190_000 + s770_active_business_income_tax: 30_780 + net_investment_income_tax: 30_780 + +- name: Undrawn and floor rejected draws supply zero actual classification hooks + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + people: + undrawn: + age: 45 + s_corp_income: 200_000 + # The supplying draw reform has a zero phase-in share. + s770_draw_gross: 0 + s770_draw_oasdi_base: 0 + s770_draw_hi_in_effect: false + floor_rejected: + age: 45 + s_corp_income: 200_000 + # The supplying reform rejected a planned $200 draw below its floor. + # It must supply actual accepted draw/base of zero even with HI on. + # These hooks do not calculate the phase-in or reporting floor. + s770_draw_gross: 0 + s770_draw_oasdi_base: 0 + s770_draw_hi_in_effect: true + tax_units: + undrawn: + members: [undrawn] + filing_status: SINGLE + niit_magi: 400_000 + floor_rejected: + members: [floor_rejected] + filing_status: SINGLE + niit_magi: 400_000 + households: + household: + members: [undrawn, floor_rejected] + state_code: TX + output: + s770_active_business_income: [200_000, 200_000] + s770_active_taxable_base: [200_000, 200_000] + s770_active_business_income_tax: [32_400, 32_400] + net_investment_income_tax: [32_400, 32_400] + +- name: Before active inclusion already SE passive income remains in existing NII + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + partnership_income: 10_000 + passive_partnership_s_corp_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + s770_passive_se_income: 10_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: 10_000 + s770_active_business_income: 0 + s770_nii_taxable_base: 10_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 380 + +- name: Activating the SECA exclusion can lower tax with no signed active loss + period: 2026 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.active_business.in_effect: true + partnership_income: 10_000 + passive_partnership_s_corp_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + s770_passive_se_income: 10_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + # Same household as above: N falls by 10,000, while A stays zero. + # Monotonic activation requires fixed NII/source exclusions as well as A>=0. + s770_nii_base: 0 + s770_active_business_income: 0 + s770_nii_taxable_base: 0 + s770_active_taxable_base: 0 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 0 + net_investment_income_tax: 0 + +- name: Explicit passive SE overlap is removed once from each affected category + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + partnership_income: 300_000 + passive_partnership_s_corp_income: 100_000 + partnership_self_employment_net_earnings: 150_000 + s770_passive_se_income: 40_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + # N = 100,000 - 40,000; A = 300,000 - 100,000 - (150,000 - 40,000). + s770_nii_base: 60_000 + s770_active_business_income: 90_000 + s770_nii_taxable_base: 60_000 + s770_active_taxable_base: 90_000 + s770_nii_addon_tax: 7_440 + s770_active_business_income_tax: 14_580 + net_investment_income_tax: 24_300 + +- name: Existing filing status thresholds govern both options + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + people: + single: + age: 40 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + joint: + age: 40 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + separate: + age: 40 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + head: + age: 40 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + survivor: + age: 40 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + tax_units: + single: + members: [single] + filing_status: SINGLE + niit_magi: 230_000 + joint: + members: [joint] + filing_status: JOINT + niit_magi: 280_000 + separate: + members: [separate] + filing_status: SEPARATE + niit_magi: 155_000 + head: + members: [head] + filing_status: HEAD_OF_HOUSEHOLD + niit_magi: 230_000 + survivor: + members: [survivor] + filing_status: SURVIVING_SPOUSE + niit_magi: 280_000 + households: + household: + members: [single, joint, separate, head, survivor] + state_code: TX + output: + # Each filing status has 30,000 of MAGI excess: n=20,000, a=10,000. + s770_nii_taxable_base: [20_000, 20_000, 20_000, 20_000, 20_000] + s770_active_taxable_base: [10_000, 10_000, 10_000, 10_000, 10_000] + s770_nii_addon_tax: [2_480, 2_480, 2_480, 2_480, 2_480] + s770_active_business_income_tax: [1_620, 1_620, 1_620, 1_620, 1_620] + net_investment_income_tax: [4_860, 4_860, 4_860, 4_860, 4_860] + +- name: Exact threshold is untaxed and one dollar above it has one dollar of base + period: 2026 + absolute_error_margin: 0.001 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + people: + exact: + age: 40 + s_corp_income: 100_000 + above: + age: 40 + s_corp_income: 100_000 + tax_units: + exact: + members: [exact] + filing_status: SINGLE + niit_magi: 200_000 + above: + members: [above] + filing_status: SINGLE + niit_magi: 200_001 + households: + household: + members: [exact, above] + state_code: TX + output: + s770_active_taxable_base: [0, 1] + s770_nii_addon_tax: [0, 0] + s770_active_business_income_tax: [0, .162] + net_investment_income_tax: [0, .162] diff --git a/policyengine_us/tests/policy/contrib/congress/s770/s770_active_business_income_revenue_oasdi.yaml b/policyengine_us/tests/policy/contrib/congress/s770/s770_active_business_income_revenue_oasdi.yaml new file mode 100644 index 00000000000..a7b58828857 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/congress/s770/s770_active_business_income_revenue_oasdi.yaml @@ -0,0 +1,68 @@ +- name: Combined rate transfer works with the active option independently enabled + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: false + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 1 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 100_000 + s770_active_taxable_base: 100_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 16_200 + net_investment_income_tax: 20_000 + # No NII add-on means no transfer of the existing 3.8 percent tax. + s770_existing_niit_revenue_oasdi: 0 + s770_nii_addon_revenue_oasdi: 0 + s770_active_business_income_revenue_oasdi: 12_400 + s770_revenue_oasdi: 12_400 + +- name: Combined rate transfer with zero addon preserves NII threshold consumption + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.nii_addon.rate: 0 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 1 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + niit_magi: 230_000 + filing_status: SINGLE + output: + # NII still takes 20,000 of the shared 30,000 MAGI excess first. + s770_nii_taxable_base: 20_000 + s770_active_taxable_base: 10_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 1_620 + net_investment_income_tax: 2_380 + s770_existing_niit_revenue_oasdi: 0 + s770_nii_addon_revenue_oasdi: 0 + s770_active_business_income_revenue_oasdi: 1_240 + s770_revenue_oasdi: 1_240 + +- name: Bill transfer has its own start year + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 3 + gov.contrib.congress.s770.section_8.transfer.start_year: 2027 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + net_investment_income_tax: 32_400 + s770_existing_niit_revenue_oasdi: 0 + s770_nii_addon_revenue_oasdi: 0 + s770_active_business_income_revenue_oasdi: 0 + s770_revenue_oasdi: 0 diff --git a/policyengine_us/tests/policy/contrib/congress/s770/s770_nii_taxable_base.yaml b/policyengine_us/tests/policy/contrib/congress/s770/s770_nii_taxable_base.yaml new file mode 100644 index 00000000000..0f67af85071 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/congress/s770/s770_nii_taxable_base.yaml @@ -0,0 +1,93 @@ +- name: Zero addon rate still reserves NII threshold room + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.nii_addon.rate: 0 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 20_000 + s_corp_income: 20_000 + niit_magi: 230_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 20_000 + s770_active_taxable_base: 10_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 1_620 + # .038 x 20,000 + .162 x 10,000. + net_investment_income_tax: 2_380 + +- name: Zero active rate does not change the NII allocation + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.active_business.rate: 0 + taxable_interest_income: 20_000 + s_corp_income: 20_000 + niit_magi: 230_000 + filing_status: SINGLE + output: + s770_nii_taxable_base: 20_000 + s770_active_taxable_base: 10_000 + s770_nii_addon_tax: 2_480 + s770_active_business_income_tax: 0 + net_investment_income_tax: 3_240 + +- name: Custom NOL permission preserves the supplied deduction + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.active_business.deny_nol: false + net_investment_income: -10_000 + s770_niit_nol_deduction: 30_000 + s_corp_income: 20_000 + niit_magi: 250_000 + filing_status: SINGLE + output: + s770_nii_base: -10_000 + s770_nii_taxable_base: 0 + s770_active_taxable_base: 10_000 + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 1_620 + net_investment_income_tax: 1_620 + +- name: The active option can start later than the NII addon + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.active_business.start_year: 2027 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_addon_tax: 12_400 + s770_active_business_income_tax: 0 + net_investment_income_tax: 16_200 + +- name: The NII addon can start later than the active option + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.nii_addon.start_year: 2027 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_nii_addon_tax: 0 + s770_active_business_income_tax: 16_200 + net_investment_income_tax: 20_000 diff --git a/policyengine_us/tests/policy/contrib/congress/s770/s770_revenue_oasdi.yaml b/policyengine_us/tests/policy/contrib/congress/s770/s770_revenue_oasdi.yaml new file mode 100644 index 00000000000..62961759a59 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/congress/s770/s770_revenue_oasdi.yaml @@ -0,0 +1,74 @@ +- name: Bill transfer mode sends 62 percent of all resulting NIIT to OASDI + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 3 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + net_investment_income_tax: 32_400 + s770_existing_niit_revenue_oasdi: 2_356 + s770_nii_addon_revenue_oasdi: 7_688 + s770_active_business_income_revenue_oasdi: 10_044 + s770_revenue_oasdi: 20_088 + +- name: Combined rate allocation sends 12.4 of 16.2 percentage points to OASDI + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 1 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + net_investment_income_tax: 32_400 + s770_existing_niit_revenue_oasdi: 3_800 * .124 / .162 + s770_nii_addon_revenue_oasdi: 12_400 * .124 / .162 + s770_active_business_income_revenue_oasdi: 12_400 + s770_revenue_oasdi: 24_800 + +- name: All addon mode transfers all new NII points and the chosen active share + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.transfer.mode: 2 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_existing_niit_revenue_oasdi: 0 + s770_nii_addon_revenue_oasdi: 12_400 + s770_active_business_income_revenue_oasdi: 10_044 + s770_revenue_oasdi: 22_444 + +- name: Custom transfer shares remain independently configurable + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.congress.s770.section_8.nii_addon.in_effect: true + gov.contrib.congress.s770.section_8.nii_addon.oasdi_share: .5 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.contrib.congress.s770.section_8.active_business.oasdi_share: .25 + taxable_interest_income: 100_000 + s_corp_income: 200_000 + niit_magi: 400_000 + filing_status: SINGLE + output: + s770_existing_niit_revenue_oasdi: 0 + s770_nii_addon_revenue_oasdi: 6_200 + s770_active_business_income_revenue_oasdi: 4_050 + s770_revenue_oasdi: 10_250 diff --git a/policyengine_us/tests/reforms/test_s770_section_8.py b/policyengine_us/tests/reforms/test_s770_section_8.py new file mode 100644 index 00000000000..30cae9c1220 --- /dev/null +++ b/policyengine_us/tests/reforms/test_s770_section_8.py @@ -0,0 +1,428 @@ +"""Signed allocation properties and multi-year S.770 reform integration. + +The inexpensive Hypothesis cases exercise vector helpers without rebuilding +the model. One shared, read-only simulation then checks registration, changing +switches/start years, entity aggregation and revenue accounting across years; +YAML cannot express those dated parameter changes. No population data are used. +Direct-NII YAML cases cover the NOL-denial hook separately from these source +aggregation checks. + +With both options enabled, unified-liability equivalence assumes identical signed +sources, exclusions, NOL treatment, MAGI, thresholds and timing, with existing +rate + add-on rate = active rate = 16.2%. It does not imply equal OASDI transfers or full statutory +category coverage. Adding a signed active loss can lower total tax, so the +monotonicity property requires a nonnegative active residual and fixed signed +NII/source rules, including exclusions and NOL treatment, across switches. +Activating an exclusion can lower existing NIIT even with a nonnegative active +residual; YAML pins that separate source-classification counterexample. +""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +from policyengine_core.reforms import Reform +from policyengine_us import Simulation +from policyengine_us.reforms.congress.s770.section_8 import ( + active_residual, + allocate_bases, + enabled, + transfer_shares, +) + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +EXISTING_RATE = 0.038 +ADDON_RATE = 0.124 +ACTIVE_RATE = 0.162 +SHARE = 0.62 +YEARS = tuple(range(2025, 2035)) +MODES = ("custom", "bill", "combined_rate_share", "all_addon") +SETTINGS = hypothesis.settings(max_examples=100, deadline=None) +SIGNED = st.integers(-1_000_000, 1_000_000) +NONNEGATIVE = st.integers(0, 1_000_000) +BASES = st.lists(st.tuples(SIGNED, SIGNED, NONNEGATIVE), min_size=1, max_size=30) + + +@SETTINGS +@hypothesis.given(BASES) +def test_signed_unified_liability_equivalence(rows): + nii, active, excess = np.asarray(rows, dtype=float).T + n, a = allocate_bases(nii, active, excess) + unified = np.minimum(np.maximum(nii + active, 0), excess) + total = EXISTING_RATE * n + ADDON_RATE * n + ACTIVE_RATE * a + np.testing.assert_allclose(total, ACTIVE_RATE * unified, atol=1e-8) + np.testing.assert_allclose(n + a, unified) + assert np.all(total >= 0) + assert np.all(n >= 0) + assert np.all(a >= 0) + assert np.all(n <= np.maximum(nii, 0)) + # Signed sources are netted first; the resulting allocated shares are + # both nonnegative under E2's allocation, even when a source is negative. + np.testing.assert_allclose(a[n < np.maximum(nii, 0)], 0) + + +def test_review_counterexamples_and_zero_addon_threshold_allocation(): + n, a = allocate_bases( + np.array([-10_000, 20_000, 20_000]), + np.array([20_000, -10_000, 20_000]), + np.array([50_000, 50_000, 30_000]), + ) + np.testing.assert_array_equal(n, [0, 10_000, 20_000]) + np.testing.assert_array_equal(a, [10_000, 0, 10_000]) + np.testing.assert_allclose(ACTIVE_RATE * (n + a), [1_620, 1_620, 4_860]) + assert EXISTING_RATE * n[2] + ACTIVE_RATE * a[2] == pytest.approx(2_380) + # An active loss reduces the existing 3.8% component as well as the + # add-on; unconditional monotonicity would contradict signed netting. + assert ACTIVE_RATE * (n[1] + a[1]) < ACTIVE_RATE * 20_000 + + +@SETTINGS +@hypothesis.given(nii=SIGNED, active=NONNEGATIVE, excess=NONNEGATIVE) +def test_turning_on_parts_is_monotone_with_fixed_nii_and_nonnegative_active_income( + nii, active, excess +): + # The signed NII input is fixed across switches. Applying new SECA/FICA + # source exclusions when the active option starts is outside this property. + n, a = allocate_bases(nii, active, excess) + n_without_active, _ = allocate_bases(nii, 0, excess) + baseline = EXISTING_RATE * n_without_active + addon_only = (EXISTING_RATE + ADDON_RATE) * n_without_active + active_only = EXISTING_RATE * n + ACTIVE_RATE * a + both = (EXISTING_RATE + ADDON_RATE) * n + ACTIVE_RATE * a + assert addon_only >= baseline + assert active_only >= baseline + assert both >= addon_only + assert both >= active_only + + +@SETTINGS +@hypothesis.given( + active=NONNEGATIVE, + passive=NONNEGATIVE, + already_se=NONNEGATIVE, + draw_exclusion=NONNEGATIVE, +) +def test_no_profit_is_in_both_seca_and_active_residual( + active, passive, already_se, draw_exclusion +): + # Disjoint source subsets are drawn independently. The two SECA-covered + # subsets leave exactly the undrawn, nonpassive active dollars behind. + total = active + passive + already_se + draw_exclusion + residual = active_residual(total, passive, already_se, draw_exclusion) + assert residual == active + assert residual + already_se + draw_exclusion == total - passive + + +@SETTINGS +@hypothesis.given(total=SIGNED, passive=SIGNED, already_se=SIGNED) +def test_active_residual_preserves_signed_losses(total, passive, already_se): + assert active_residual(total, passive, already_se) + passive + already_se == total + + +@SETTINGS +@hypothesis.given( + mode=st.sampled_from(MODES), + nii_share=st.integers(0, 10_000), + active_share=st.integers(0, 10_000), + bill_share=st.integers(0, 10_000), + nii_tax=NONNEGATIVE, + addon_tax=NONNEGATIVE, + active_tax=NONNEGATIVE, +) +def test_transfer_modes_account_for_each_component( + mode, nii_share, active_share, bill_share, nii_tax, addon_tax, active_tax +): + nii_share, active_share, bill_share = ( + value / 10_000 for value in (nii_share, active_share, bill_share) + ) + shares = transfer_shares( + mode, + EXISTING_RATE, + ADDON_RATE, + ACTIVE_RATE, + nii_share, + active_share, + bill_share, + ADDON_RATE, + ) + expected = { + "custom": (0, nii_share, active_share), + "bill": (bill_share,) * 3, + "combined_rate_share": (ADDON_RATE / ACTIVE_RATE,) * 3, + "all_addon": (0, 1, active_share), + }[mode] + np.testing.assert_allclose(shares, expected) + taxes = np.array([nii_tax, addon_tax, active_tax]) + np.testing.assert_allclose(np.asarray(shares) * taxes, np.asarray(expected) * taxes) + if mode == "bill": + assert np.dot(shares, taxes) == pytest.approx(bill_share * taxes.sum()) + + +def test_combined_rate_transfer_handles_zero_rates(): + assert transfer_shares("combined_rate_share", 0, 0, 0, SHARE, SHARE, SHARE, 0) == ( + 0, + 0, + 0, + ) + + +@SETTINGS +@hypothesis.given( + in_effect=st.booleans(), + start_year=st.integers(2026, 2040), + year=st.integers(2020, 2045), +) +def test_activation_requires_switch_and_start_year(in_effect, start_year, year): + part = SimpleNamespace(in_effect=in_effect, start_year=start_year) + period = SimpleNamespace(start=SimpleNamespace(year=year)) + assert bool(enabled(part, period)) == (in_effect and year >= start_year) + + +class DatedSection8Options(Reform): + """Exercise the public parameter route, including structural registration.""" + + def apply(self): + def modify(parameters): + p = parameters.gov.contrib.congress.s770.section_8 + for year in YEARS: + values = { + "nii_addon.in_effect": year not in (2027, 2033), + "active_business.in_effect": year not in (2026, 2033), + "nii_addon.start_year": 2026, + "active_business.start_year": 2026, + "nii_addon.rate": 0 if year == 2032 else ADDON_RATE, + "active_business.rate": ACTIVE_RATE, + "nii_addon.oasdi_share": SHARE, + "active_business.oasdi_share": SHARE, + "transfer.mode": { + 2029: 3, + 2030: 1, + 2031: 2, + }.get(year, 0), + "transfer.start_year": 2035 if year == 2034 else 2026, + "transfer.bill_share": SHARE, + "transfer.oasdi_rate": ADDON_RATE, + } + for path, value in values.items(): + node = p + for key in path.split("."): + node = getattr(node, key) + node.update(period=str(year), value=value) + return parameters + + self.modify_parameters(modify) + + +# Each row is a separate tax unit. Derived MAGI is deliberately used instead +# of entering niit_magi, and dependent income must not join the filer's bases. +ROWS = [ + dict(rental=-10_000, total=20_000), + dict(rental=20_000, total=-10_000), + dict(rental=20_000, total=20_000, agi=230_000), + dict(total=30_000, passive=10_000, already_se=10_000), + dict(total=30_000, passive=10_000, already_se=15_000, overlap=5_000), + dict(rental=20_000, total=30_000, fica=10_000, nii_fica=5_000), + dict(total=100_000, draw=80_000, draw_base=9_235), + dict(total=100_000, draw=80_000, draw_base=9_235, hi=True), + dict(rental=20_000, total=20_000, agi=230_000, dependent=True), + dict( + rental=15_000, total=20_000, agi=180_000, estate_adjustment=20_000, feie=10_000 + ), + dict(rental=5_000, total=5_000, spouse=True), +] + + +def _situation(): + people, tax_units, households = {}, {}, {} + + def annual(value): + return {str(year): value for year in YEARS} + + for i, row in enumerate(ROWS): + head = f"head_{i}" + people[head] = { + "age": annual(45), + "is_tax_unit_head": annual(True), + "rental_income": annual(row.get("rental", 0)), + "s_corp_income": annual(row.get("total", 0) / 2), + "partnership_income": annual(row.get("total", 0) / 2), + "passive_partnership_s_corp_income": annual(row.get("passive", 0)), + "partnership_self_employment_net_earnings": annual( + row.get("already_se", 0) + ), + "s770_passive_se_income": annual(row.get("overlap", 0)), + "s770_active_fica_income": annual(row.get("fica", 0)), + "s770_draw_gross": annual(row.get("draw", 0)), + "s770_draw_oasdi_base": annual(row.get("draw_base", 0)), + "s770_draw_hi_in_effect": annual(row.get("hi", False)), + "estate_income_niit_magi_adjustment": annual( + row.get("estate_adjustment", 0) + ), + } + members = [head] + if row.get("dependent"): + child = f"child_{i}" + members.append(child) + people[child] = { + "age": annual(12), + "is_tax_unit_dependent": annual(True), + "rental_income": annual(1_000_000), + "s_corp_income": annual(1_000_000), + } + if row.get("spouse"): + spouse = f"spouse_{i}" + members.append(spouse) + people[spouse] = { + "age": annual(45), + "is_tax_unit_spouse": annual(True), + "rental_income": annual(7_000), + "s_corp_income": annual(11_000), + } + tax_units[f"tax_unit_{i}"] = { + "members": members, + "filing_status": annual("JOINT" if row.get("spouse") else "SINGLE"), + "adjusted_gross_income": annual(row.get("agi", 250_000)), + "niit_magi_section_911_addition": annual(row.get("feie", 0)), + "s770_nii_fica_seca_income": annual(row.get("nii_fica", 0)), + } + households[f"household_{i}"] = {"members": members, "state_code": annual("TX")} + return {"people": people, "tax_units": tax_units, "households": households} + + +@pytest.fixture(scope="module") +def dated_simulation(): + # One full construction for all integration assertions, without mutation + # after calculations begin. This fixture belongs to Rest's remaining group. + return Simulation( + situation=_situation(), reform=DatedSection8Options, start_instant="2025-01-01" + ) + + +@pytest.mark.parametrize("year", YEARS) +def test_dated_registration_components_and_revenue_accounts(dated_simulation, year): + sim = dated_simulation + nii_on = year >= 2026 and year not in (2027, 2033) + active_on = year >= 2026 and year not in (2026, 2033) + addon_rate = 0 if year == 2032 else ADDON_RATE + expected_nii, expected_active, expected_excess = [], [], [] + for row in ROWS: + nii = row.get("rental", 0) + row.get("passive", 0) + active = row.get("total", 0) - row.get("passive", 0) + active -= row.get("already_se", 0) - row.get("overlap", 0) + active -= row.get("fica", 0) + active -= ( + row.get("draw", 0) + if row.get("hi", False) + else row.get("draw_base", 0) / 0.9235 + ) + if row.get("spouse"): + nii += 7_000 + active += 11_000 + if active_on: + nii -= row.get("overlap", 0) + row.get("nii_fica", 0) + expected_nii.append(nii) + expected_active.append(active) + threshold = 250_000 if row.get("spouse") else 200_000 + magi = ( + row.get("agi", 250_000) + + row.get("estate_adjustment", 0) + + row.get("feie", 0) + ) + expected_excess.append(max(magi - threshold, 0)) + expected_nii = np.asarray(expected_nii) + expected_active = np.asarray(expected_active) + unified = np.minimum( + np.maximum(expected_nii + (expected_active if active_on else 0), 0), + expected_excess, + ) + n = np.minimum(np.maximum(expected_nii, 0), unified) + a = unified - n + existing_tax = EXISTING_RATE * n + addon_tax = addon_rate * n if nii_on else np.zeros_like(n) + active_tax = ACTIVE_RATE * a if active_on else np.zeros_like(a) + np.testing.assert_allclose( + sim.calculate("s770_nii_base", year), expected_nii, atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("s770_active_business_income", year), expected_active, atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("s770_nii_taxable_base", year), n, atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("s770_active_taxable_base", year), a, atol=0.01 + ) + for variable, expected in ( + ("s770_nii_addon_tax", addon_tax), + ("s770_active_business_income_tax", active_tax), + ("net_investment_income_tax", existing_tax + addon_tax + active_tax), + ): + actual = sim.calculate(variable, year) + np.testing.assert_allclose(actual, expected, atol=0.01) + assert np.all(actual >= 0) + if year == 2029: + shares = (SHARE,) * 3 + elif year == 2030: + shares = (ADDON_RATE / ACTIVE_RATE,) * 3 + elif year == 2031: + shares = (0, 1, SHARE) + else: + shares = (0, SHARE, SHARE) + if year in (2025, 2033, 2034): + shares = (0, 0, 0) + revenues = [] + for variable, share, tax in zip( + ( + "s770_existing_niit_revenue_oasdi", + "s770_nii_addon_revenue_oasdi", + "s770_active_business_income_revenue_oasdi", + ), + shares, + (existing_tax, addon_tax, active_tax), + ): + revenue = sim.calculate(variable, year) + np.testing.assert_allclose(revenue, share * tax, atol=0.01) + revenues.append(revenue) + np.testing.assert_allclose( + sim.calculate("s770_revenue_oasdi", year), sum(revenues), atol=0.01 + ) + + +class NoDrawNettingFactorIn2026(Reform): + """In 2026 only, q = 1 - 100% x (100% + 0%) = 0 leaves no SE netting factor.""" + + def apply(self): + def modify(parameters): + irs = parameters.gov.irs + for node, value in ( + (irs.ald.misc.employer_share, 1), + (irs.self_employment.rate.social_security, 1), + (irs.self_employment.rate.medicare, 0), + ): + node.update(period="2026", value=value) + return parameters + + self.modify_parameters(modify) + + +def test_owners_draw_netting_factor_must_be_positive(): + # An OASDI-only draw is excluded as its taxed base divided by q. + head = { + variable: {"2025": value, "2026": value} + for variable, value in ( + ("age", 45), + ("s_corp_income", 100_000), + ("s770_draw_oasdi_base", 9_235), + ) + } + situation = {"people": {"head": head}} + sim = Simulation(situation=situation, reform=NoDrawNettingFactorIn2026) + # Current law: q = 1 - 50% x (12.4% + 2.9%) = 0.9235; 9,235 / q = 10,000. + np.testing.assert_allclose( + sim.calculate("s770_active_business_income", 2025), [90_000], atol=0.01 + ) + with pytest.raises(ValueError, match="netting factor must be positive"): + sim.calculate("s770_active_business_income", 2026) diff --git a/policyengine_us/tests/unit/test_ss_calculator_differential.py b/policyengine_us/tests/unit/test_ss_calculator_differential.py index 4b4ad08e875..3228ede8e5b 100644 --- a/policyengine_us/tests/unit/test_ss_calculator_differential.py +++ b/policyengine_us/tests/unit/test_ss_calculator_differential.py @@ -26,7 +26,7 @@ # Benefit levers and COLA history also serve the reported-benefit route, # which remains part of the reference when the calculator is removed. "gov.contrib.ssa.pia", - "gov.contrib.congress.s770", + "gov.contrib.congress.s770.section_2", "gov.ssa.social_security.pia.bend_points", "gov.ssa.social_security.cola", ) 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 new file mode 100644 index 00000000000..3e33405ff62 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py @@ -0,0 +1,50 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import active_residual + + +class s770_active_business_income(Variable): + value_type = float + entity = TaxUnit + label = "S.770 signed active business income outside FICA and SECA" + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + documentation = ( + "Signed head/spouse pass-through residual: partnership_s_corp_income " + "(s_corp_income + partnership_income), less its passive subset, " + "already-SE active subset, and amounts actually subject to FICA/SECA " + "under the owners-draw integration hooks. Passive and already-SE " + "subsets must be disjoint unless s770_passive_se_income supplies their " + "intersection. Nonpassive status is only a material-participation " + "proxy; entity-level netting and legal limited-partner classifications " + "are not identified. Certified unrepaired data have zero S-corp income, " + "combined partnership income carrying passive mass, and no passive " + "subset: this overstates the active base and understates NII until " + "Microcosm #1251/#1261 repairs appear in the scoring artifact." + ) + + 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 + ) + 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, + ) + active_se = person("partnership_self_employment_net_earnings", period) - person( + "s770_passive_se_income", period + ) + residual = active_residual( + person("partnership_s_corp_income", period), + person("passive_partnership_s_corp_income", period), + active_se, + draw_exclusion + person("s770_active_fica_income", period), + ) + return tax_unit.sum(~person("is_tax_unit_dependent", period) * residual) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income_revenue_oasdi.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income_revenue_oasdi.py new file mode 100644 index 00000000000..65772de73ed --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income_revenue_oasdi.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_active_business_income_revenue_oasdi(Variable): + value_type = float + entity = TaxUnit + label = "OASDI revenue from S.770 active business tax" + documentation = "OASDI source accounting alongside tob_revenue_oasdi. Custom mode applies the component share; combined_rate_share applies the OACT combined-rate share; all_addon sends all NII add-on points; bill applies 62 percent to all resulting NIIT including the separately reported existing NIIT revenue. Transfer timing is independent of tax timing." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + return revenue_shares(parameters, period)[2] * tax_unit( + "s770_active_business_income_tax", period + ) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income_tax.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income_tax.py new file mode 100644 index 00000000000..3fc6d0bfc8b --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income_tax.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_active_business_income_tax(Variable): + value_type = float + entity = TaxUnit + label = "S.770 tax on additional active business income" + documentation = "This component is included in net_investment_income_tax when the structural reform is enabled. Missing passive pass-through data understate NII and overstate active income until the Microcosm entity/passive field repairs are present. See the reform README for statutory category and NOL limitations." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.contrib.congress.s770.section_8.active_business + if not enabled(p, period): + return 0 + return p.rate * tax_unit("s770_active_taxable_base", period) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_fica_income.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_fica_income.py new file mode 100644 index 00000000000..cf3a02d7501 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_fica_income.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class s770_active_fica_income(Variable): + value_type = float + entity = Person + label = "FICA covered income included in active entity totals" + documentation = "Validated FICA covered amount already inside the nonpassive entity total, disjoint from the already-SE subset and draw exclusion. Wages paid separately are not in distributive entity income and must not be subtracted a second time." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_taxable_base.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_taxable_base.py new file mode 100644 index 00000000000..ce21db125e6 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_taxable_base.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_active_taxable_base(Variable): + value_type = float + entity = TaxUnit + label = "S.770 active allocation of the unified taxable base" + documentation = "Net signed NII and active income before clipping to the MAGI excess, then allocate to NII first independently of rates. Both allocated bases are nonnegative. The existing NIIT allocation must change with signed active losses. This component is included in net_investment_income_tax when the structural reform is enabled. Missing passive pass-through data understate NII and overstate active income until the Microcosm entity/passive field repairs are present. See the reform README for statutory category and NOL limitations." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + p = parameters(period) + options = p.gov.contrib.congress.s770.section_8 + if not enabled(options.active_business, period): + return 0 + niit = p.gov.irs.investment.net_investment_income_tax + excess = ( + tax_unit("niit_magi", period) + - niit.threshold[tax_unit("filing_status", period)] + ) + return allocate_bases( + tax_unit("s770_nii_base", period), + tax_unit("s770_active_business_income", period), + excess, + )[1] diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_gross.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_gross.py new file mode 100644 index 00000000000..0477fccf911 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_gross.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class s770_draw_gross(Variable): + value_type = float + entity = Person + label = "Actual gross owners draw for S.770 interaction" + documentation = "Integration hook for actual, floor-accepted owners draw under a future SECA reform. Supply actual gross draw, not planned or phase-in-rejected profits. This input classifies income only and does not itself impose SECA." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_hi_in_effect.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_hi_in_effect.py new file mode 100644 index 00000000000..9737350c692 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_hi_in_effect.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class s770_draw_hi_in_effect(Variable): + value_type = bool + entity = Person + label = "Actual owners draw is subject to uncapped SECA HI" + documentation = "Integration hook: true only where the actual gross draw supplied in s770_draw_gross is subject to uncapped SECA HI. Exclude the full gross draw in that case; otherwise exclude only the gross equivalent of its capped OASDI base. Undrawn and floor-rejected amounts stay in active income." + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_oasdi_base.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_oasdi_base.py new file mode 100644 index 00000000000..a7fd63573f7 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_draw_oasdi_base.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class s770_draw_oasdi_base(Variable): + value_type = float + entity = Person + label = "Owners draw net earnings actually taxed by OASDI" + documentation = "Integration hook for adjusted owners draw after wages, existing SE earnings and the applicable cap room. With draw HI off, only this amount divided by the SE netting factor is excluded from active income; above-room profits stay in the active base. This input does not itself impose SECA." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_existing_niit_revenue_oasdi.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_existing_niit_revenue_oasdi.py new file mode 100644 index 00000000000..c7ca17c6069 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_existing_niit_revenue_oasdi.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_existing_niit_revenue_oasdi(Variable): + value_type = float + entity = TaxUnit + label = "OASDI revenue from S.770 allocation of existing NIIT" + documentation = "OASDI source accounting alongside tob_revenue_oasdi. Custom mode applies the component share; combined_rate_share applies the OACT combined-rate share; all_addon sends all NII add-on points; bill applies 62 percent to all resulting NIIT including the separately reported existing NIIT revenue. Transfer timing is independent of tax timing." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + return revenue_shares(parameters, period)[0] * tax_unit( + "s770_existing_niit_tax", period + ) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_existing_niit_tax.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_existing_niit_tax.py new file mode 100644 index 00000000000..7559062f96d --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_existing_niit_tax.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_existing_niit_tax(Variable): + value_type = float + entity = TaxUnit + label = "Existing NIIT rate on the S.770 NII allocation" + documentation = "This component is included in net_investment_income_tax when the structural reform is enabled. Missing passive pass-through data understate NII and overstate active income until the Microcosm entity/passive field repairs are present. See the reform README for statutory category and NOL limitations." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + rate = parameters(period).gov.irs.investment.net_investment_income_tax.rate + return rate * tax_unit("s770_nii_taxable_base", period) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_addon_revenue_oasdi.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_addon_revenue_oasdi.py new file mode 100644 index 00000000000..842392e6315 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_addon_revenue_oasdi.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_nii_addon_revenue_oasdi(Variable): + value_type = float + entity = TaxUnit + label = "OASDI revenue from S.770 NII add-on" + documentation = "OASDI source accounting alongside tob_revenue_oasdi. Custom mode applies the component share; combined_rate_share applies the OACT combined-rate share; all_addon sends all NII add-on points; bill applies 62 percent to all resulting NIIT including the separately reported existing NIIT revenue. Transfer timing is independent of tax timing." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + return revenue_shares(parameters, period)[1] * tax_unit( + "s770_nii_addon_tax", period + ) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_addon_tax.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_addon_tax.py new file mode 100644 index 00000000000..f864c02000a --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_addon_tax.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_nii_addon_tax(Variable): + value_type = float + entity = TaxUnit + label = "S.770 additional tax on net investment income" + documentation = "This component is included in net_investment_income_tax when the structural reform is enabled. Missing passive pass-through data understate NII and overstate active income until the Microcosm entity/passive field repairs are present. See the reform README for statutory category and NOL limitations." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.contrib.congress.s770.section_8.nii_addon + if not enabled(p, period): + return 0 + return p.rate * tax_unit("s770_nii_taxable_base", period) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_base.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_base.py new file mode 100644 index 00000000000..c919fc3d487 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_base.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import enabled + + +class s770_nii_base(Variable): + value_type = float + entity = TaxUnit + label = "S.770 signed NII with selected source adjustments" + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + documentation = ( + "Existing signed NII, adjusted when the active option applies for " + "validated NOL denial and items actually subject to FICA/SECA. The " + "NOL hook changes broadened NII itself before unified allocation, " + "not a separately floored active bucket. No federal section 172 " + "deduction is automatically modeled. Literal section 8 also broadens " + "ordinary-course investment income and business-property gains; " + "their full classification is unsupported here. Missing passive " + "pass-through income in unrepaired data understates this NII source " + "while overstating the active residual; see the reform documentation." + ) + + def formula(tax_unit, period, parameters): + nii = tax_unit("net_investment_income", period) + p = parameters(period).gov.contrib.congress.s770.section_8.active_business + if not enabled(p, period): + return nii + overlap = tax_unit_non_dep_add(tax_unit, period, ["s770_passive_se_income"]) + nii = nii - overlap - tax_unit("s770_nii_fica_seca_income", period) + if p.deny_nol: + nii = nii + tax_unit("s770_niit_nol_deduction", period) + return nii diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_fica_seca_income.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_fica_seca_income.py new file mode 100644 index 00000000000..75f86765d19 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_fica_seca_income.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class s770_nii_fica_seca_income(Variable): + value_type = float + entity = TaxUnit + label = "Already FICA or SECA covered income in supplied NII" + documentation = "Validated amount already included in NII and taxed under section 1401 or 3101, other than the passive/already-SE intersection supplied separately. The active option removes it from broadened NII. Requires source classification; wages are not automatically subtracted from unrelated entity income." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_taxable_base.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_taxable_base.py new file mode 100644 index 00000000000..73784487653 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_nii_taxable_base.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * +from policyengine_us.reforms.congress.s770.section_8 import ( + enabled, + allocate_bases, + revenue_shares, +) + + +class s770_nii_taxable_base(Variable): + value_type = float + entity = TaxUnit + label = "S.770 NII allocation of the unified taxable base" + documentation = "Net signed NII and active income before clipping to the MAGI excess, then allocate to NII first independently of rates. Both allocated bases are nonnegative. The existing NIIT allocation must change with signed active losses. This component is included in net_investment_income_tax when the structural reform is enabled. Missing passive pass-through data understate NII and overstate active income until the Microcosm entity/passive field repairs are present. See the reform README for statutory category and NOL limitations." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + + def formula(tax_unit, period, parameters): + p = parameters(period) + niit = p.gov.irs.investment.net_investment_income_tax + options = p.gov.contrib.congress.s770.section_8 + excess = ( + tax_unit("niit_magi", period) + - niit.threshold[tax_unit("filing_status", period)] + ) + active = ( + tax_unit("s770_active_business_income", period) + if enabled(options.active_business, period) + else 0 + ) + return allocate_bases(tax_unit("s770_nii_base", period), active, excess)[0] diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_niit_nol_deduction.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_niit_nol_deduction.py new file mode 100644 index 00000000000..0a67969cfcb --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_niit_nol_deduction.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class s770_niit_nol_deduction(Variable): + value_type = float + entity = TaxUnit + label = "NOL deduction already included in supplied NII" + documentation = "Validated section 172 deduction already subtracted from net_investment_income. The active option adds it back to broadened NII if deny_nol is true. Supply only an amount actually deducted from the NII input, not an AGI deduction or a signed business loss. Baseline source formulas contain no federal NOL deduction; default zero does not assert complete NOL coverage." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_passive_se_income.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_passive_se_income.py new file mode 100644 index 00000000000..9f638b79ae2 --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_passive_se_income.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class s770_passive_se_income(Variable): + value_type = float + entity = Person + label = "Intersection of passive and already SE covered entity income" + documentation = "Intersection already included in both passive_partnership_s_corp_income and partnership_self_employment_net_earnings. Default zero assumes disjoint subsets; supply the validated intersection when they overlap. Active inclusion removes it from NII and subtracts only the active portion of already-SE income from the active residual." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_revenue_oasdi.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_revenue_oasdi.py new file mode 100644 index 00000000000..6812581be1f --- /dev/null +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_revenue_oasdi.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class s770_revenue_oasdi(Variable): + value_type = float + entity = TaxUnit + label = "OASDI revenue from S.770 section 8 options" + documentation = "Sum each TaxUnit once, alongside tob_revenue_oasdi; includes the existing NIIT transfer only where the selected mode transfers it. Liability and trust-fund allocation equivalence are separate claims." + unit = USD + definition_period = YEAR + reference = "https://www.congress.gov/bill/119th-congress/senate-bill/770/text" + adds = [ + "s770_existing_niit_revenue_oasdi", + "s770_nii_addon_revenue_oasdi", + "s770_active_business_income_revenue_oasdi", + ]