diff --git a/changelog.d/aca-ptc-files-on-take-up.fixed.md b/changelog.d/aca-ptc-files-on-take-up.fixed.md new file mode 100644 index 00000000000..0b6828c61ed --- /dev/null +++ b/changelog.d/aca-ptc-files-on-take-up.fixed.md @@ -0,0 +1 @@ +Count a tax unit that takes up the ACA premium tax credit as filing a return to claim it (26 CFR 1.6011-8(a)), so the filing gate on aca_ptc and on the California, Maryland and New Mexico premium programs no longer zeroes the credit of a tax unit that takes it up but would not otherwise file; a tax unit that neither files nor takes up the credit still gets none. diff --git a/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py b/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py new file mode 100644 index 00000000000..41ca922c311 --- /dev/null +++ b/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py @@ -0,0 +1,127 @@ +"""Invariants for the filing gate on the ACA premium tax credit. + +`aca_ptc` is the credit for a tax unit that files a return on which it can +claim it (`files_return_for_aca_ptc`): one that files for any reason +(`tax_unit_is_filer`) or that takes up the credit, since a taxpayer who +receives advance payments must file and reconcile them (26 CFR 1.6011-8(a)). +Hypothesis draws tax units with any benchmark premium, household income and +required contribution, and runs each in all four combinations of filing and +take-up. Each drawn example is one simulation, calculated for both years. +For every tax unit: + +1. Taking up the credit never lowers `aca_ptc`. +2. For a tax unit that takes up the credit, `aca_ptc` and `assigned_aca_ptc` + are the same whether or not it would file for another reason. +3. A tax unit that neither files nor takes up the credit gets no `aca_ptc` + (the filing gate added for IRS filer data). +4. A tax unit that files gets the same `aca_ptc` whether or not it takes up + the credit. +5. Taking up coverage when the credit is $0 is no reason to file: a non-filer + that takes up but whose credit before the filing gate is $0 does not file + for it (`files_return_for_aca_ptc` is false), so state programs that read + it stay closed. + +Why Python: the YAML cases pin one combination each with fixed amounts; this +checks every combination of filing and take-up against drawn benchmark +premiums, incomes and contribution rates in one vectorized simulation, so a +gate that drops the take-up leg, or that makes the credit depend on filing for +a unit that takes it up, fails whatever the amounts. Cost, measured locally +(macOS, shared host): about 4.5 s of test calls, under 10 s in pytest and 26 +to 39 s wall time including the model import, peak memory footprint 1.6 GB. It runs in the +`core` group of the rest Python tests. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEARS = (2025, 2026) +# (tax_unit_is_filer, takes_up_aca_if_eligible) for the four copies of a unit. +COMBINATIONS = [(False, False), (False, True), (True, False), (True, True)] + +units = st.tuples( + st.integers(0, 30_000), # slcsp + st.integers(0, 120_000), # aca_magi + st.sampled_from([0.0, 0.02, 0.04, 0.085, 0.0996]), +) + + +def simulate(rows): + people, tax_units, households = {}, {}, {} + groups = {"marital_units": {}, "spm_units": {}, "families": {}} + index = 0 + for slcsp, magi, share in rows: + for files, takes_up in COMBINATIONS: + person, members = f"person_{index}", [f"person_{index}"] + people[person] = { + "age": {year: 40 for year in YEARS}, + "is_aca_ptc_eligible": {year: True for year in YEARS}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + "slcsp": {year: slcsp for year in YEARS}, + "aca_magi": {year: magi for year in YEARS}, + "aca_required_contribution_percentage": {year: share for year in YEARS}, + "tax_unit_is_filer": {year: files for year in YEARS}, + "takes_up_aca_if_eligible": {year: takes_up for year in YEARS}, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {year: "TX" for year in YEARS}, + } + for name in groups: + groups[name][f"{name}_{index}"] = {"members": members} + index += 1 + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + **groups, + } + ) + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists(units, min_size=4, max_size=24).map( + # A unit whose credit is positive, so properties 1-4 compare something, and + # one whose credit is zero, so property 5 does. + lambda rows: [(12_000, 30_000, 0.04), (5_500, 60_000, 0.0996), *rows] + ) +) +def test_aca_ptc_filing_gate(rows): + simulation = simulate(rows) + for year in YEARS: + ptc = np.asarray(simulation.calculate("aca_ptc", year)).reshape(-1, 4) + assigned = np.asarray(simulation.calculate("assigned_aca_ptc", year)).reshape( + -1, 4 + ) + neither, takes_up_only, files_only, both = ptc.T + assert (ptc >= 0).all() + assert (both > 0).any(), "no unit with a positive credit" + # Property 1. + assert (takes_up_only >= neither).all() and (both >= files_only).all() + # Property 2. + np.testing.assert_allclose(takes_up_only, both) + np.testing.assert_allclose(assigned[:, 1], assigned[:, 3]) + # Property 3. + assert (neither == 0).all() + # Property 4. + np.testing.assert_allclose(files_only, both) + # Property 5. + before = np.asarray( + simulation.calculate("aca_ptc_before_filing_gate", year) + ).reshape(-1, 4)[:, 1] + files = np.asarray( + simulation.calculate("files_return_for_aca_ptc", year) + ).reshape(-1, 4)[:, 1] + np.testing.assert_array_equal(files, before > 0) + assert (before == 0).any() and (before > 0).any() diff --git a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml index e16d13d56e5..2214672a9e5 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml @@ -132,7 +132,7 @@ output: aca_ptc: 200 -- name: ACA PTC is zero for non-filers (IRC 36B(a) requires filing Form 8962) +- name: ACA PTC is zero for non-filers who do not take it up (IRC 36B(a) requires filing Form 8962) absolute_error_margin: 0.01 period: 2025 input: @@ -141,9 +141,29 @@ aca_magi: 25_000 aca_required_contribution_percentage: 0.04 tax_unit_is_filer: false + # Nor does it take up the federal credit, so it files no return to claim it. + takes_up_aca_if_eligible: false output: aca_ptc: 0 +- name: A tax unit that takes up the PTC files to claim it, even if it would not file otherwise + # 26 CFR 1.6011-8(a): a taxpayer who receives advance payments "must file + # an income tax return for that taxable year ... and reconcile the advance + # credit payments"; a credit not paid in advance is claimed on Form 8962. + absolute_error_margin: 0.01 + period: 2025 + input: + is_aca_ptc_eligible: true + slcsp: 1_200 + aca_magi: 25_000 + aca_required_contribution_percentage: 0.04 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + output: + files_return_for_aca_ptc: true + aca_ptc: 200 # 1200 - 0.04 * 25000 = 200 + assigned_aca_ptc: 200 + - name: ACA PTC is paid to eligible filers (regression of filer gate) absolute_error_margin: 0.01 period: 2025 diff --git a/policyengine_us/tests/policy/baseline/gov/aca/ptc/files_return_for_aca_ptc.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/files_return_for_aca_ptc.yaml new file mode 100644 index 00000000000..70f0bdda7b3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/files_return_for_aca_ptc.yaml @@ -0,0 +1,74 @@ +# A tax unit files a return that can claim the premium tax credit if it files +# for any reason, or if it takes up the credit (26 CFR 1.6011-8(a); Form 1040 +# instructions, Chart C, item 5). + +- name: A filer that does not take up the credit + period: 2025 + input: + tax_unit_is_filer: true + takes_up_aca_if_eligible: false + output: + files_return_for_aca_ptc: true + +- name: A non-filer that takes up a positive credit + period: 2025 + input: + is_aca_ptc_eligible: true + slcsp: 1_200 + aca_magi: 25_000 + aca_required_contribution_percentage: 0.04 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + output: + aca_ptc_before_filing_gate: 200 # 1200 - 0.04 * 25000 + files_return_for_aca_ptc: true + +- name: A non-filer that takes up coverage when the credit is zero does not file for it + # $5,500 benchmark less 9.96% of $60,000 ($5,976) is below zero: there is + # no credit to claim and no advance payment to reconcile. + period: 2026 + input: + is_aca_ptc_eligible: true + slcsp: 5_500 + aca_magi: 60_000 + aca_required_contribution_percentage: 0.0996 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + output: + aca_ptc_before_filing_gate: 0 + files_return_for_aca_ptc: false + +- name: A non-filer that does not take up the credit + period: 2025 + input: + tax_unit_is_filer: false + takes_up_aca_if_eligible: false + output: + files_return_for_aca_ptc: false + +- name: Retired couple with only Social Security is not required to file but files to claim the credit + # Social Security of $30,000 and nothing else: none of it is taxable (the + # provisional income of $15,000 is under the $32,000 joint base amount), so + # gross income is zero and no return is required, and there is no earned + # income for an EITC. Taking up the credit makes it a filer for the credit. + period: 2025 + input: + people: + head: + age: 62 + social_security_retirement: 15_000 + spouse: + age: 62 + social_security_retirement: 15_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + taxable_social_security: [0, 0] + tax_unit_is_required_to_file: false + tax_unit_is_filer: false + files_return_for_aca_ptc: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml index 0bacd426a86..c2f5969cf23 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml @@ -276,6 +276,8 @@ members: [person1] aca_magi_fraction: 1.40 tax_unit_is_filer: false + # Nor does it take up the federal credit, so it files no return to claim it. + takes_up_aca_if_eligible: false households: household: members: [person1] @@ -401,3 +403,79 @@ state_code: CA output: ca_premium_subsidy_eligible: false + +- name: Case 13b, an in-band CA unit that takes up the federal credit files and is eligible. + # Case 13 with take-up true and a positive federal credit ($6,000 benchmark + # less 4.19% of $22,000 = $5,078.20): a tax unit that takes up the credit + # files a return to claim or reconcile it (26 CFR 1.6011-8(a)), so the + # filing gate passes. + period: 2026 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi_fraction: 1.40 + slcsp: 6_000 + aca_magi: 22_000 + aca_required_contribution_percentage: 0.0419 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + ca_premium_subsidy_eligible: true + +- name: Case 13c, an in-band CA non-filer that takes up coverage with a zero federal credit is ineligible. + # The federal contribution (4% of $22,000 = $880) exceeds the $500 + # benchmark, so there is no federal credit and no reason to file. Filer twin: + # Case 13d. + period: 2026 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi_fraction: 1.40 + slcsp: 500 + aca_magi: 22_000 + aca_required_contribution_percentage: 0.04 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + ca_premium_subsidy_eligible: false + +- name: Case 13d, the same unit filing for another reason is eligible. + period: 2026 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi_fraction: 1.40 + slcsp: 500 + aca_magi: 22_000 + aca_required_contribution_percentage: 0.04 + tax_unit_is_filer: true + takes_up_aca_if_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + ca_premium_subsidy_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/md/mhbe/premium_assistance/md_premium_assistance.yaml b/policyengine_us/tests/policy/baseline/gov/states/md/mhbe/premium_assistance/md_premium_assistance.yaml index 785e626bc21..dc03a0ca2b0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/md/mhbe/premium_assistance/md_premium_assistance.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/md/mhbe/premium_assistance/md_premium_assistance.yaml @@ -2,7 +2,7 @@ # Formula (COMAR 14.35.21.04B): the benchmark SLCSP premium remaining after the # federal APTC, less the Maryland target contribution (md_pct * income), floored # at 0, and 0 for non-filers: -# where(tax_unit_is_filer, +# where(files_return_for_aca_ptc, # max_(0, max_(0, slcsp - aca_ptc) - md_pct * max_(aca_magi, 0)), # 0) # gated on md_premium_assistance_eligible (in effect, MD, APTC-eligible, <=400% FPL). @@ -302,8 +302,11 @@ # ---- Edge cases ---- -- name: Case 12, zero MAGI -> 0 subsidy (gap = 0 * pct). - # Eligible (0 <= 400% FPL) but aca_magi 0 zeroes the contribution gap. +- name: Case 12, zero MAGI -> the whole premium balance after APTC, 2_500. + # COMAR .04B: max(0, (6_000 - 3_500) - md_pct * 0) = 2_500. (This case + # expected 0 under the older (federal - md) * income reading; on main it + # passed only because the unit, with no gross income, computed as a + # non-filer.) period: 2026 absolute_error_margin: 0.01 input: @@ -319,16 +322,21 @@ aca_required_contribution_percentage: 0.0419 slcsp: 6_000 aca_ptc: 3_500 + tax_unit_is_filer: true households: household: members: [person1] state_code: MD output: - md_premium_assistance: 0 + md_premium_assistance: 2_500 -- name: Case 13, federal applicable % below MD target % -> negative gap floored to 0. - # At 250% FPL md target = 6.22%; federal supplied at 5.00% < md target -> - # gap = max(0, 39_125 * (0.05 - 0.0622)) = max(0, negative) = 0. +- name: Case 13, a federal applicable % below the MD target does not enter the formula -> 66.43. + # COMAR .04B uses the premium balance after APTC, not the federal + # percentage: at 250% FPL md target = 6.22%, so + # max(0, (6_000 - 3_500) - 0.0622 * 39_125) = 2_500 - 2_433.575 = 66.425. + # (This case expected 0 under the older (federal - md) * income reading; on + # main it passed only because the unit, with no gross income, computed as a + # non-filer.) period: 2026 absolute_error_margin: 0.01 input: @@ -344,12 +352,13 @@ aca_required_contribution_percentage: 0.05 slcsp: 6_000 aca_ptc: 3_500 + tax_unit_is_filer: true households: household: members: [person1] state_code: MD output: - md_premium_assistance: 0 + md_premium_assistance: 66.425 - name: Case 14, aca_ptc exactly equals slcsp -> premium cap 0 -> subsidy 0. # Uncapped gap positive, but slcsp - aca_ptc = 6_000 - 6_000 = 0 caps to 0. @@ -723,3 +732,84 @@ state_code: VA output: md_premium_assistance: [868.57, 0] # MD unit derived > 0, VA unit masked to 0 by defined_for + +- name: Case 1b, a unit that takes up the federal credit but would not otherwise file -> 2500. + # Case 1's inputs with tax_unit_is_filer false and take-up true: it files to + # claim or reconcile the federal credit (26 CFR 1.6011-8(a)), so the filing + # gate passes and the subsidy is max(0, 6_000 - 3_500) - 0 = 2_500. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 23_475 + aca_magi_fraction: 1.50 + aca_required_contribution_percentage: 0.0419 + slcsp: 6_000 + aca_ptc: 3_500 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + households: + household: + members: [person1] + state_code: MD + output: + md_premium_assistance: 2_500 + +- name: Case 1c, a non-filer that takes up coverage with a zero federal credit -> 0. + # The federal contribution (9.96% of $60,000 = $5,976) exceeds the $5,500 + # benchmark, so there is no federal credit and no reason to file + # (COMAR 14.35.21.03A(1) requires a tax filer's household). Filer twin: 1d. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 60_000 + aca_magi_fraction: 3.83 + aca_required_contribution_percentage: 0.0996 + slcsp: 5_500 + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + households: + household: + members: [person1] + state_code: MD + output: + md_premium_assistance: 0 + +- name: Case 1d, the same unit filing for another reason -> 89.50. + # md_pct at 383% FPL = 0.0798 + 0.83 * (0.0923 - 0.0798) = 0.090175; + # max(0, max(0, 5_500 - 0) - 0.090175 * 60_000) = 5_500 - 5_410.50 = 89.50. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 60_000 + aca_magi_fraction: 3.83 + aca_required_contribution_percentage: 0.0996 + slcsp: 5_500 + tax_unit_is_filer: true + takes_up_aca_if_eligible: true + households: + household: + members: [person1] + state_code: MD + output: + md_premium_assistance: 89.5 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance.yaml index 7852514f67b..9b3e0c164a7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance.yaml @@ -738,6 +738,8 @@ tax_unit: members: [person1] tax_unit_is_filer: false + # Nor does it take up the federal credit, so it files no return to claim it. + takes_up_aca_if_eligible: false aca_magi: 39_125 aca_magi_fraction: 2.50 slcsp: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.yaml index ba154b9eefa..4916b85758a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.yaml @@ -260,6 +260,8 @@ tax_unit: members: [person1] tax_unit_is_filer: false + # Nor does it take up the federal credit, so it files no return to claim it. + takes_up_aca_if_eligible: false aca_magi_fraction: 2.50 households: household: @@ -267,3 +269,78 @@ state_code: NM output: nm_premium_assistance_eligible: false + +- name: Case 13b, an NM unit at 250% FPL that takes up the federal credit files and is base-eligible. + # Case 13 with take-up true and a positive federal credit ($6,000 benchmark + # less 8.44% of $39,125 = $2,697.85), so it files to claim the credit + # (26 CFR 1.6011-8(a)). + period: 2026 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + aca_magi_fraction: 2.50 + slcsp: 6_000 + aca_magi: 39_125 + aca_required_contribution_percentage: 0.0844 + households: + household: + members: [person1] + state_code: NM + output: + nm_premium_assistance_eligible: true + +- name: Case 13c, a non-filer that takes up coverage with a zero federal credit is base-ineligible. + # At 383% FPL the federal contribution (9.96% of $60,000 = $5,976) exceeds the + # $5,500 benchmark, so the federal credit is $0 and taking up coverage gives + # no reason to file. Filer twin: Case 13d. + period: 2026 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + aca_magi_fraction: 3.83 + slcsp: 5_500 + aca_magi: 60_000 + aca_required_contribution_percentage: 0.0996 + households: + household: + members: [person1] + state_code: NM + output: + nm_premium_assistance_eligible: false + +- name: Case 13d, the same unit filing for another reason is base-eligible. + period: 2026 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + tax_unit_is_filer: true + takes_up_aca_if_eligible: true + aca_magi_fraction: 3.83 + slcsp: 5_500 + aca_magi: 60_000 + aca_required_contribution_percentage: 0.0996 + households: + household: + members: [person1] + state_code: NM + output: + nm_premium_assistance_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.yaml index 9dc6fda92fb..8f51d63d7df 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.yaml @@ -237,3 +237,28 @@ state_code: NM output: nm_premium_assistance_mih_eligible: false + +- name: Case 10b, taking up ACA does not make an MIH unit above 400% FPL a filer. + # Above 400% FPL there is no federal premium tax credit to take up, so the + # federal take-up input says nothing about filing; only tax_unit_is_filer + # counts (the Addendum also says the state payment is not reconciled like + # APTC). Same inputs as Case 10 with take-up true: still MIH-ineligible. + period: 2026 + input: + people: + person1: + age: 40 + pays_aca_premium: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + aca_magi_fraction: 4.20 + households: + household: + members: [person1] + state_code: NM + output: + nm_premium_assistance_mih_eligible: false diff --git a/policyengine_us/variables/gov/aca/ptc/aca_ptc.py b/policyengine_us/variables/gov/aca/ptc/aca_ptc.py index 76c9fbe2464..5e044bca7b4 100644 --- a/policyengine_us/variables/gov/aca/ptc/aca_ptc.py +++ b/policyengine_us/variables/gov/aca/ptc/aca_ptc.py @@ -19,10 +19,9 @@ def formula(tax_unit, period, parameters): return 0 def formula_2018(tax_unit, period, parameters): - plan_cost = tax_unit("slcsp", period) - income = tax_unit("aca_magi", period) - applicable_figure = tax_unit("aca_required_contribution_percentage", period) - # IRC § 36B(a) conditions the PTC on filing a return (Form 8962). - # Gate on tax_unit_is_filer so non-filers receive $0. - is_filer = tax_unit("tax_unit_is_filer", period) - return max_(0, plan_cost - income * applicable_figure) * is_filer + # IRC § 36B(a) conditions the PTC on filing a return (Form 8962), so + # a tax unit that neither files nor takes up the credit receives $0. + # Taking up the credit means filing to claim or reconcile it. + credit = tax_unit("aca_ptc_before_filing_gate", period) + files = tax_unit("files_return_for_aca_ptc", period) + return credit * files diff --git a/policyengine_us/variables/gov/aca/ptc/aca_ptc_before_filing_gate.py b/policyengine_us/variables/gov/aca/ptc/aca_ptc_before_filing_gate.py new file mode 100644 index 00000000000..9621dde4694 --- /dev/null +++ b/policyengine_us/variables/gov/aca/ptc/aca_ptc_before_filing_gate.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class aca_ptc_before_filing_gate(Variable): + value_type = float + entity = TaxUnit + label = "ACA premium tax credit before the filing condition" + unit = USD + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/36B#b" + documentation = ( + "The premium tax credit the tax unit could claim on a return: the " + "benchmark premium less the required contribution. aca_ptc is this " + "amount for a tax unit that files a return claiming it." + ) + defined_for = "is_aca_ptc_eligible" + + def formula(tax_unit, period, parameters): + return 0 + + def formula_2018(tax_unit, period, parameters): + plan_cost = tax_unit("slcsp", period) + income = tax_unit("aca_magi", period) + applicable_figure = tax_unit("aca_required_contribution_percentage", period) + return max_(0, plan_cost - income * applicable_figure) diff --git a/policyengine_us/variables/gov/aca/ptc/files_return_for_aca_ptc.py b/policyengine_us/variables/gov/aca/ptc/files_return_for_aca_ptc.py new file mode 100644 index 00000000000..f813f108ccf --- /dev/null +++ b/policyengine_us/variables/gov/aca/ptc/files_return_for_aca_ptc.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class files_return_for_aca_ptc(Variable): + value_type = bool + entity = TaxUnit + label = "Files a federal return that claims the ACA premium tax credit" + documentation = """ + Whether the tax unit files a federal income tax return on which it can + claim the premium tax credit. A tax unit that files for any reason + (tax_unit_is_filer) can, and so does one that takes up a credit it is + due: it either receives advance payments, after which it "must file an + income tax return for that taxable year ... and reconcile the advance + credit payments" (26 CFR 1.6011-8(a)), or claims the credit on Form 8962 + with its return. Taking up coverage when the credit is $0 gives no such + reason to file. A tax unit that neither files nor takes up a credit gets + none, which keeps aca_ptc to filers as IRS filing data count it. + """ + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/36B#a", + "https://www.law.cornell.edu/cfr/text/26/1.6011-8#a", + # Chart C, item 5: a return is required if advance payments of the + # premium tax credit were made. + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=11", + ) + + def formula(tax_unit, period, parameters): + files = tax_unit("tax_unit_is_filer", period) + takes_up = tax_unit("takes_up_aca_if_eligible", period) + credit = tax_unit("aca_ptc_before_filing_gate", period) + return files | (takes_up & (credit > 0)) diff --git a/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.py b/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.py index e9fd47b9eaf..eb31d0a2ee2 100644 --- a/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.py +++ b/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.py @@ -44,5 +44,5 @@ def formula(tax_unit, period, parameters): # immigration exception that the applicable-return-filer definition # excludes. Dependents claimed by another filer do not form separate # claiming units in the model, so the dependent exclusion is structural. - is_filer = tax_unit("tax_unit_is_filer", period) + is_filer = tax_unit("files_return_for_aca_ptc", period) return in_effect & aptc_eligible & income_eligible & is_filer diff --git a/policyengine_us/variables/gov/states/md/mhbe/premium_assistance/md_premium_assistance.py b/policyengine_us/variables/gov/states/md/mhbe/premium_assistance/md_premium_assistance.py index 28b2835f2c9..0395799eb38 100644 --- a/policyengine_us/variables/gov/states/md/mhbe/premium_assistance/md_premium_assistance.py +++ b/policyengine_us/variables/gov/states/md/mhbe/premium_assistance/md_premium_assistance.py @@ -29,7 +29,8 @@ class md_premium_assistance(Variable): # A non-filer has aca_ptc = 0, which would inflate the post-APTC premium # balance and over-grant the state subsidy. COMAR 14.35.21.03A(1) # conditions the subsidy on APTC eligibility, which requires filing, so - # the subsidy is gated on tax_unit_is_filer below. + # the subsidy is gated below on filing a return that claims the federal + # credit (files_return_for_aca_ptc). # # The display name "Premium Assistance" follows the MHBE consumer-facing # site ("Maryland Premium Assistance"); the statute and COMAR title the @@ -53,6 +54,7 @@ def formula(tax_unit, period, parameters): # Premium balance remaining after the federal APTC (COMAR .04B). premium_after_aptc = max_(0, slcsp - aca_ptc) subsidy = max_(0, premium_after_aptc - md_percentage * income) - # Gate on filing: non-filers are not APTC-eligible (COMAR .03A(1)). - is_filer = tax_unit("tax_unit_is_filer", period) + # Gate on filing: non-filers are not APTC-eligible (COMAR .03A(1)). A + # tax unit that takes up the federal credit files to claim it. + is_filer = tax_unit("files_return_for_aca_ptc", period) return where(is_filer, subsidy, 0) diff --git a/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.py b/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.py index 787f8b57464..0ca623a510c 100644 --- a/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.py +++ b/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.py @@ -24,8 +24,9 @@ def formula(tax_unit, period, parameters): # exclusion, and the 100-400% FPL required-contribution income test. aptc_eligible = add(tax_unit, period, ["is_aca_ptc_eligible"]) > 0 # is_aca_ptc_eligible does not embed the federal PTC filing requirement - # (IRC 36B(a); aca_ptc does), so gate it explicitly here. - is_filer = tax_unit("tax_unit_is_filer", period) + # (IRC 36B(a); aca_ptc does), so gate it explicitly here, as aca_ptc + # does. + is_filer = tax_unit("files_return_for_aca_ptc", period) magi_frac = tax_unit("aca_magi_fraction", period) income_eligible = magi_frac <= p.fpl_limit return in_effect & aptc_eligible & is_filer & income_eligible diff --git a/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.py b/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.py index 78a6a44ff95..718534d46ea 100644 --- a/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.py +++ b/policyengine_us/variables/gov/states/nm/hca/premium_assistance/nm_premium_assistance_mih_eligible.py @@ -33,7 +33,11 @@ def formula(tax_unit, period, parameters): filing_status = tax_unit("filing_status", period) not_separate = filing_status != filing_status.possible_values.SEPARATE # is_aca_ptc_eligible does not embed the federal PTC filing requirement - # (IRC 36B(a); aca_ptc does), so gate it explicitly here. + # (IRC 36B(a); aca_ptc does), so gate it explicitly here. Unlike the + # base component, this one serves income above the federal PTC limit, + # where no federal credit exists to take up, so ACA take-up cannot + # stand for filing (files_return_for_aca_ptc); only filing for another + # reason counts. is_filer = tax_unit("tax_unit_is_filer", period) magi_frac = tax_unit("aca_magi_fraction", period) income_eligible = magi_frac > p.fpl_limit