From da4d7e7069ca7de44cc6d0aab87ca79a114904da Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:31:04 -0400 Subject: [PATCH 1/3] Count ACA premium tax credit take-up as filing a return to claim it aca_ptc was zero for any tax unit that tax_unit_is_filer did not count as filing, even one that takes up the credit. A taxpayer who receives advance payments must file and reconcile them (26 CFR 1.6011-8(a)), and a credit not paid in advance is claimed on Form 8962. files_return_for_aca_ptc is tax_unit_is_filer or takes_up_aca_if_eligible; aca_ptc and the California, Maryland and New Mexico premium programs that copy its filing gate use it. A tax unit that neither files nor takes up the credit still gets none. Two Maryland cases expected 0 from an older formula and passed only because their units computed as non-filers; they now expect the COMAR .04B amounts. Co-Authored-By: Claude Opus 5.5 --- changelog.d/aca-ptc-files-on-take-up.fixed.md | 1 + .../test_aca_ptc_filing_gate_invariants.py | 103 ++++++++++++++++++ .../policy/baseline/gov/aca/ptc/aca_ptc.yaml | 22 +++- .../gov/aca/ptc/files_return_for_aca_ptc.yaml | 54 +++++++++ .../ca_premium_subsidy_eligible.yaml | 2 + .../md_premium_assistance.yaml | 23 ++-- .../nm_premium_assistance.yaml | 2 + .../nm_premium_assistance_eligible.yaml | 2 + .../nm_premium_assistance_mih_eligible.yaml | 2 + .../variables/gov/aca/ptc/aca_ptc.py | 9 +- .../gov/aca/ptc/files_return_for_aca_ptc.py | 30 +++++ .../ca_premium_subsidy_eligible.py | 2 +- .../md_premium_assistance.py | 5 +- .../nm_premium_assistance_eligible.py | 5 +- .../nm_premium_assistance_mih_eligible.py | 5 +- 15 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 changelog.d/aca-ptc-files-on-take-up.fixed.md create mode 100644 policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/aca/ptc/files_return_for_aca_ptc.yaml create mode 100644 policyengine_us/variables/gov/aca/ptc/files_return_for_aca_ptc.py 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..3855706c880 --- /dev/null +++ b/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py @@ -0,0 +1,103 @@ +"""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, as one simulation per year. 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. +""" + +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. + lambda rows: [(12_000, 30_000, 0.04), *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) 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..3a5c94fe7fb 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 + # A tax unit that takes up the credit files 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..cdd9b7e6ffc --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/files_return_for_aca_ptc.yaml @@ -0,0 +1,54 @@ +# 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 the credit + period: 2025 + input: + tax_unit_is_filer: false + takes_up_aca_if_eligible: true + output: + files_return_for_aca_ptc: true + +- 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..c4a4a9ce507 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 + # A tax unit that takes up the credit files to claim it. + takes_up_aca_if_eligible: false households: household: members: [person1] 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..89061850b8a 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 @@ -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. 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..282997e45f4 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 + # A tax unit that takes up the credit files 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..85730c04c32 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 + # A tax unit that takes up the credit files to claim it. + takes_up_aca_if_eligible: false aca_magi_fraction: 2.50 households: household: 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..edcf6442145 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 @@ -230,6 +230,8 @@ members: [person1] filing_status: SINGLE tax_unit_is_filer: false + # A tax unit that takes up the credit files to claim it. + takes_up_aca_if_eligible: false aca_magi_fraction: 4.20 households: household: diff --git a/policyengine_us/variables/gov/aca/ptc/aca_ptc.py b/policyengine_us/variables/gov/aca/ptc/aca_ptc.py index 76c9fbe2464..ef6b8fd41f0 100644 --- a/policyengine_us/variables/gov/aca/ptc/aca_ptc.py +++ b/policyengine_us/variables/gov/aca/ptc/aca_ptc.py @@ -22,7 +22,8 @@ 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. + files = tax_unit("files_return_for_aca_ptc", period) + return max_(0, plan_cost - income * applicable_figure) * files 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..488c9639215 --- /dev/null +++ b/policyengine_us/variables/gov/aca/ptc/files_return_for_aca_ptc.py @@ -0,0 +1,30 @@ +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 the credit: 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. A tax unit that neither files nor takes up the 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) + return files | takes_up 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..67179b19766 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 @@ -53,6 +53,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..7f6b9a58643 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,8 +33,9 @@ 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. - 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 & pays_premium & not_separate & is_filer & income_eligible From d3680f184c9d59203798f825c6339ed4c07d728b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:44:35 -0400 Subject: [PATCH 2/3] Address review round 1: keep New Mexico's middle-income gate on filing, add positive state cases - nm_premium_assistance_mih_eligible serves income above the federal PTC limit, where there is no federal credit to take up, so it keeps tax_unit_is_filer; Case 10b pins that taking up ACA does not make such a unit a filer. - Add a non-filer that takes up the federal credit to the CA, MD and NM base tests, each passing the filing gate. - Document why the invariants test is Python and what it costs. Co-Authored-By: Claude Opus 5.5 --- .../test_aca_ptc_filing_gate_invariants.py | 9 ++++++ .../policy/baseline/gov/aca/ptc/aca_ptc.yaml | 2 +- .../ca_premium_subsidy_eligible.yaml | 25 ++++++++++++++++- .../md_premium_assistance.yaml | 28 +++++++++++++++++++ .../nm_premium_assistance.yaml | 2 +- .../nm_premium_assistance_eligible.yaml | 23 ++++++++++++++- .../nm_premium_assistance_mih_eligible.yaml | 27 ++++++++++++++++-- .../nm_premium_assistance_mih_eligible.py | 9 ++++-- 8 files changed, 116 insertions(+), 9 deletions(-) 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 index 3855706c880..5e2f57c30f4 100644 --- a/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py +++ b/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py @@ -15,6 +15,15 @@ (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. + +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): 4.4 s of test calls, 7.5 s in pytest and 26 s wall time +including the model import, peak memory footprint 1.5 GB. It runs in the +`core` group of the rest Python tests. """ import numpy as np 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 3a5c94fe7fb..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 @@ -141,7 +141,7 @@ aca_magi: 25_000 aca_required_contribution_percentage: 0.04 tax_unit_is_filer: false - # A tax unit that takes up the credit files to claim it. + # 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 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 c4a4a9ce507..e48867c248d 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,7 +276,7 @@ members: [person1] aca_magi_fraction: 1.40 tax_unit_is_filer: false - # A tax unit that takes up the credit files to claim it. + # Nor does it take up the federal credit, so it files no return to claim it. takes_up_aca_if_eligible: false households: household: @@ -403,3 +403,26 @@ 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. + # Same inputs as Case 13 with take-up true: a tax unit that takes up the + # federal premium tax 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 + tax_unit_is_filer: false + 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 89061850b8a..c6af850af26 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 @@ -732,3 +732,31 @@ 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 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 282997e45f4..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,7 +738,7 @@ tax_unit: members: [person1] tax_unit_is_filer: false - # A tax unit that takes up the credit files to claim it. + # 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 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 85730c04c32..301e4920b57 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,7 +260,7 @@ tax_unit: members: [person1] tax_unit_is_filer: false - # A tax unit that takes up the credit files to claim it. + # 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: @@ -269,3 +269,24 @@ 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. + # Same inputs as Case 13 with take-up true (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 + 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 edcf6442145..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 @@ -230,8 +230,31 @@ members: [person1] filing_status: SINGLE tax_unit_is_filer: false - # A tax unit that takes up the credit files to claim it. - takes_up_aca_if_eligible: false + aca_magi_fraction: 4.20 + households: + household: + members: [person1] + 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: 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 7f6b9a58643..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,9 +33,12 @@ 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, as aca_ptc - # does. - is_filer = tax_unit("files_return_for_aca_ptc", period) + # (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 return in_effect & pays_premium & not_separate & is_filer & income_eligible From 7fed1d3d27174501eaba5b037fe30909fed33672 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 07:21:25 -0400 Subject: [PATCH 3/3] Address review round 2: count take-up as filing only when the federal credit is positive Taking up coverage when the federal credit is $0 gives no reason to file: there is nothing to claim or reconcile. aca_ptc_before_filing_gate carries the credit formula; aca_ptc is that amount times files_return_for_aca_ptc, which now requires a positive credit before take-up counts as filing. The federal credit is unchanged; the CA, MD and NM base gates no longer open for a non-filer with a $0 federal credit. - Zero-credit counterexamples with filer twins for the helper, CA, MD and NM. - Invariant 5: a non-filer that takes up a $0 credit does not file for it. - Correct the test docstring (one simulation per drawn example) and the MD comments that named the old gate. Co-Authored-By: Claude Opus 5.5 --- .../test_aca_ptc_filing_gate_invariants.py | 25 ++++++-- .../gov/aca/ptc/files_return_for_aca_ptc.yaml | 22 ++++++- .../ca_premium_subsidy_eligible.yaml | 59 ++++++++++++++++++- .../md_premium_assistance.yaml | 55 ++++++++++++++++- .../nm_premium_assistance_eligible.yaml | 56 +++++++++++++++++- .../variables/gov/aca/ptc/aca_ptc.py | 6 +- .../gov/aca/ptc/aca_ptc_before_filing_gate.py | 25 ++++++++ .../gov/aca/ptc/files_return_for_aca_ptc.py | 16 ++--- .../md_premium_assistance.py | 3 +- 9 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 policyengine_us/variables/gov/aca/ptc/aca_ptc_before_filing_gate.py 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 index 5e2f57c30f4..41ca922c311 100644 --- a/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py +++ b/policyengine_us/tests/core/test_aca_ptc_filing_gate_invariants.py @@ -6,7 +6,8 @@ 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, as one simulation per year. For every tax unit: +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` @@ -15,14 +16,18 @@ (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): 4.4 s of test calls, 7.5 s in pytest and 26 s wall time -including the model import, peak memory footprint 1.5 GB. It runs in the +(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. """ @@ -87,8 +92,9 @@ def simulate(rows): ) @given( st.lists(units, min_size=4, max_size=24).map( - # A unit whose credit is positive, so properties 1-4 compare something. - lambda rows: [(12_000, 30_000, 0.04), *rows] + # 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): @@ -110,3 +116,12 @@ def test_aca_ptc_filing_gate(rows): 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/files_return_for_aca_ptc.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/files_return_for_aca_ptc.yaml index cdd9b7e6ffc..70f0bdda7b3 100644 --- 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 @@ -10,14 +10,34 @@ output: files_return_for_aca_ptc: true -- name: A non-filer that takes up the credit +- 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: 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 e48867c248d..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 @@ -405,9 +405,10 @@ ca_premium_subsidy_eligible: false - name: Case 13b, an in-band CA unit that takes up the federal credit files and is eligible. - # Same inputs as Case 13 with take-up true: a tax unit that takes up the - # federal premium tax credit files a return to claim or reconcile it - # (26 CFR 1.6011-8(a)), so the filing gate passes. + # 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: @@ -418,6 +419,9 @@ 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: @@ -426,3 +430,52 @@ 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 c6af850af26..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). @@ -760,3 +760,56 @@ 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_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/hca/premium_assistance/nm_premium_assistance_eligible.yaml index 301e4920b57..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 @@ -271,7 +271,9 @@ 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. - # Same inputs as Case 13 with take-up true (26 CFR 1.6011-8(a)). + # 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: @@ -284,6 +286,58 @@ 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] diff --git a/policyengine_us/variables/gov/aca/ptc/aca_ptc.py b/policyengine_us/variables/gov/aca/ptc/aca_ptc.py index ef6b8fd41f0..5e044bca7b4 100644 --- a/policyengine_us/variables/gov/aca/ptc/aca_ptc.py +++ b/policyengine_us/variables/gov/aca/ptc/aca_ptc.py @@ -19,11 +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), 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 max_(0, plan_cost - income * applicable_figure) * files + 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 index 488c9639215..f813f108ccf 100644 --- 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 @@ -8,12 +8,13 @@ class files_return_for_aca_ptc(Variable): 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 the credit: 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. A tax unit that neither files nor takes up the credit gets none, - which keeps aca_ptc to filers as IRS filing data count it. + (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 = ( @@ -27,4 +28,5 @@ class files_return_for_aca_ptc(Variable): def formula(tax_unit, period, parameters): files = tax_unit("tax_unit_is_filer", period) takes_up = tax_unit("takes_up_aca_if_eligible", period) - return files | takes_up + credit = tax_unit("aca_ptc_before_filing_gate", period) + return files | (takes_up & (credit > 0)) 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 67179b19766..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