From 628ef23efb03a9275bfb3c41704d325669295b7b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 22:49:18 -0400 Subject: [PATCH 01/14] Count capital gains once in Arizona property tax credit income az_property_tax_credit_income added capital_gains_excluded_from_taxable_income on top of federal AGI. That variable is the part of federal taxable income taxed at the capital gains rates; when taxable income is zero it is the whole net capital gain plus qualified dividends. Federal AGI already holds those amounts, so they counted twice, mostly for the low-income seniors the credit is for. ARS 43-1072(H)(6)(b) adds "the amount of capital gains excluded from adjusted gross income". A.A.C. R15-2C-502(C)(3), ITR 12-1 item (7) and the 2021-2025 Form 140PTC instructions (Part 1 line D) count each member's net gains and losses from sales or exchanges of property, which federal AGI holds. The model has no gain excluded from federal AGI, so the entry is removed. Adds Form 140PTC line-by-line YAML tests and seeded property tests (differential against a line A-J reference, counted once, independence from the preferential-rate amount, credit monotone in gains). Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-az-ptc-capital-gains.fixed.md | 1 + .../credits/property_tax/income_sources.yaml | 32 ++- .../az_property_tax_credit_income.yaml | 206 ++++++++++++++++++ .../test_az_property_tax_credit_income.py | 190 ++++++++++++++++ .../az_property_tax_credit_income.py | 7 +- 5 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fix-az-ptc-capital-gains.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml create mode 100644 policyengine_us/tests/test_az_property_tax_credit_income.py diff --git a/changelog.d/fix-az-ptc-capital-gains.fixed.md b/changelog.d/fix-az-ptc-capital-gains.fixed.md new file mode 100644 index 00000000000..94634df9b4a --- /dev/null +++ b/changelog.d/fix-az-ptc-capital-gains.fixed.md @@ -0,0 +1 @@ +Arizona property tax credit household income counts capital gains and qualified dividends once, through federal AGI, instead of adding the federal preferential-rate amount again. diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml index 76b29218242..baa623bb1a1 100644 --- a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml @@ -1,15 +1,25 @@ description: Arizona accounts for the following income sources when computing household income for the property tax credit. values: 2021-01-01: - # Item (7): Capital gains - excluded gains not in Federal AGI - - capital_gains_excluded_from_taxable_income # Item (2): All interest income including tax-exempt - tax_exempt_interest_income # Item (9): Pension income - tax-exempt portions not in Federal AGI # (taxable pension is already in Federal AGI via az_property_tax_credit_agi) - tax_exempt_public_pension_income - tax_exempt_private_pension_income - # Federal AGI minus Social Security (per ARS 43-1072(I) exclusion) + # Federal AGI minus Social Security (per ARS 43-1072(I) exclusion). + # Item (7) capital gains and losses (Form 140PTC Part 1 line D) are counted + # here, once: federal AGI holds the year's net gain from the sale or + # exchange of property. ARS 43-1072(H)(6)(b) also adds "the amount of + # capital gains excluded from adjusted gross income"; the model has no gain + # excluded from federal AGI (such as a section 121 home sale or section 1202 + # small business stock exclusion), so nothing is added for it. Do not add + # capital_gains_excluded_from_taxable_income: it is the part of federal + # taxable income taxed at the capital gains rates (the whole net capital + # gain when taxable income is zero), which federal AGI already holds. + # Federal AGI limits a net capital loss to $3,000 per return ($1,500 if + # married filing separately); A.A.C. R15-2C-502(C)(3) limits it to $1,500 + # for each household member. - az_property_tax_credit_agi metadata: unit: list @@ -18,13 +28,27 @@ metadata: reference: - title: ARS 43-1072. Property tax credit - Income definition href: https://www.azleg.gov/ars/43/01072.htm + - title: A.A.C. R15-2C-502. Property Tax Credit + href: https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502 - title: ITR 12-1. Income Tax Ruling - Property Tax Credit Household Income - href: https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf + href: https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2 + - title: 2025 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4 + - title: 2025 Property Tax Refund (Credit) Claim Form 140PTC, Part 1 + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTC_f.pdf#page=2 + - title: 2024 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2024_140PTCi.pdf#page=4 - title: 2024 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/forms/tax-credits-forms/property-tax-refund-credit-claim-form-fillable + - title: 2023 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/2023-12/FORMS_INDIVIDUAL_2023_140PTCi.pdf#page=4 - title: 2023 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/forms/tax-credits-forms/property-tax-refund-credit-claim-form-fillable + - title: 2022 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2022_140PTCi-2D.pdf#page=4 - title: 2022 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2022_140PTC.pdf + - title: 2021 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2021_140PTCi-2D.pdf#page=4 - title: 2021 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2021_140PTC.pdf diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml new file mode 100644 index 00000000000..009e885ee85 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -0,0 +1,206 @@ +# Arizona property tax credit household income: Form 140PTC page 2, Part 1, +# Schedule of Household Income, lines A to J (ARS 43-1072(H)(6); +# A.A.C. R15-2C-502; ITR 12-1). Line D is "gain or loss from sale or exchange +# of property": each member's gains and losses combined, with a net loss +# limited to $1,500 (2021-2025 instructions, page 4). Federal AGI already holds +# those gains, so each dollar of gain must count once. +# +# Credit amounts: Form 140PTC line 11 is the Schedule 1 (lived alone) or +# Schedule 2 (lived with a spouse or others) amount at line J; line 13 is a +# renter's property taxes, modeled as 0.15 x rent; line 15 is the smaller. + +- name: Single renter with ordinary long-term gains counts them once + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: gain from the sale of property 3,000. + # Line J: total household income 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. + # Line 13: 0.15 x 5,000 = 750. Line 15: smaller of 212 and 750. + az_property_tax_credit: 212 + +- name: Single renter with qualified dividends counts them once + period: 2025 + input: + people: + head: + age: 70 + qualified_dividend_income: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line B: dividend income 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: Long-term gains above the federal deductions still count once + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 40_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal taxable income is positive here (40,000 less the standard, + # additional and senior deductions); the gain still counts once. + # Line D: gain 40,000. Line J: 40,000. + az_property_tax_credit_income: 40_000 + # Line 11a: Schedule 1, 3,751 and up -> 0. + az_property_tax_credit: 0 + +- name: Married couple combines each spouse's gains on line D + period: 2025 + input: + people: + head: + age: 70 + employment_income: 1_000 + long_term_capital_gains: 1_500 + rent: 4_000 + spouse: + age: 70 + short_term_capital_gains: 1_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line A: wages 1,000 (column 1). + # Line D: 1,500 (column 1) + 1,000 (column 2) = 2,500. + # Line J: 1,000 + 2,500 = 3,500. + az_property_tax_credit_income: 3_500 + # Line 11b: Schedule 2, 3,401 - 3,550 -> 345. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 345 and 600. + az_property_tax_credit: 345 + +- name: Short-term loss nets against long-term gain on line D + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 5_000 + short_term_capital_gains: -2_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: 5,000 - 2,000 = 3,000 net gain. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: Net loss within the 1,500 limit reduces household income + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 4,000. + # Line D: net loss (1,000), within the (1,500) limit. + # Line J: 4,000 - 1,000 = 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: Every income line together, with Social Security excluded + period: 2025 + input: + people: + head: + age: 72 + employment_income: 500 + taxable_interest_income: 200 + tax_exempt_interest_income: 300 + long_term_capital_gains: 400 + taxable_private_pension_income: 600 + tax_exempt_public_pension_income: 100 + social_security_retirement: 12_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 500. + # Line B: interest 200 + tax-exempt interest 300 = 500. + # Line D: gain 400. + # Line E: pension 600 + tax-exempt public pension 100 = 700. + # Social Security is not income for the credit. + # Line J: 500 + 500 + 400 + 700 = 2,100. + az_property_tax_credit_income: 2_100 + # Line 11a: Schedule 1, 2,051 - 2,150 -> 412. + # Line 13: 0.15 x 6,000 = 900. Line 15: smaller of 412 and 900. + az_property_tax_credit: 412 + +- name: Long-term gains count once in 2021 + period: 2021 + input: + people: + head: + age: 66 + long_term_capital_gains: 2_000 + rent: 3_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: gain 2,000. Line J: 2,000. + az_property_tax_credit_income: 2_000 + # Line 11a: 2021 Schedule 1, 1,951 - 2,050 -> 435. + # Line 13: 0.15 x 3,000 = 450. Line 15: smaller of 435 and 450. + az_property_tax_credit: 435 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py new file mode 100644 index 00000000000..fbdde7ee13c --- /dev/null +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -0,0 +1,190 @@ +"""Sampled property tests for Arizona property tax credit household income. + +ARS 43-1072(H)(6) defines income for the credit, and A.A.C. R15-2C-502 and the +Form 140PTC instructions (2021-2025, page 4) apply it line by line: line D is +each member's gains and losses from the sale or exchange of property, combined, +with a net loss limited to $1,500. Federal AGI already holds those gains, so +az_property_tax_credit_income must count each dollar of gain once. It used to +add capital_gains_excluded_from_taxable_income as well, which is the part of +federal taxable income taxed at the capital gains rates (the whole net capital +gain plus qualified dividends when taxable income is zero), so gains and +qualified dividends counted twice. + +Invariants, checked on a seeded sample of Arizona tax units whose per-member +net capital gain is at least -$1,500 (where the federal and Arizona loss rules +agree) and whose income other than Social Security is not negative: + +1. Differential: the model equals an independent line A + B + D + E sum from + Form 140PTC Part 1, with Social Security excluded. +2. Counted once: adding d to a member's long-term gains raises household + income by exactly d. +3. The income does not depend on capital_gains_excluded_from_taxable_income. +4. The credit never rises when gains rise. +""" + +import numpy as np + +from policyengine_us import Simulation + +YEAR = 2025 +N = 60 +SEED = 43_1072 +# A.A.C. R15-2C-502(C)(3): net capital losses are limited to $1,500 for each +# household member. The sample stays at or above it, where federal AGI's +# $3,000-per-return limit gives the same answer. +MEMBER_LOSS_LIMIT = 1_500 + + +def _sample_units(rng: np.random.Generator) -> list: + units = [] + for _ in range(N): + married = rng.random() < 0.4 + members = [] + for role in ["head", "spouse"] if married else ["head"]: + gains = rng.random() < 0.7 + # Some large gains, so federal taxable income is positive. + large_gain = ( + round(rng.uniform(20_000, 80_000), 2) if rng.random() < 0.15 else 0 + ) + members.append( + { + "role": role, + "age": int(rng.integers(65, 86)), + "employment_income": float( + rng.choice([0, round(rng.uniform(0, 6_000), 2)]) + ), + "taxable_interest_income": round(rng.uniform(0, 800), 2), + "tax_exempt_interest_income": round(rng.uniform(0, 500), 2), + "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), + "long_term_capital_gains": ( + round(rng.uniform(-1_000, 6_000), 2) + large_gain + if gains + else 0.0 + ), + "short_term_capital_gains": ( + round(rng.uniform(-500, 2_000), 2) if gains else 0.0 + ), + "taxable_private_pension_income": round(rng.uniform(0, 3_000), 2), + "tax_exempt_public_pension_income": round(rng.uniform(0, 1_000), 2), + "social_security_retirement": float( + rng.choice([0, round(rng.uniform(0, 30_000), 2)]) + ), + } + ) + for member in members: + net_gain = ( + member["long_term_capital_gains"] + member["short_term_capital_gains"] + ) + assert net_gain >= -MEMBER_LOSS_LIMIT + units.append( + { + "members": members, + "rent": round(rng.uniform(1_000, 12_000), 2), + "gain_increase": round(rng.uniform(1, 5_000), 2), + } + ) + return units + + +def _situation(units: list, raise_gains: bool) -> dict: + people, tax_units, households = {}, {}, {} + year = str(YEAR) + for i, unit in enumerate(units): + names = [] + for member in unit["members"]: + name = f"{member['role']}_{i}" + names.append(name) + person = { + key: {year: value} for key, value in member.items() if key != "role" + } + if raise_gains and member["role"] == "head": + person["long_term_capital_gains"] = { + year: member["long_term_capital_gains"] + unit["gain_increase"] + } + if member["role"] == "head": + person["rent"] = {year: unit["rent"]} + people[name] = person + tax_units[f"tax_unit_{i}"] = {"members": names} + households[f"household_{i}"] = { + "members": names, + "state_code": {year: "AZ"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def _form_140ptc_line_j(units: list) -> np.ndarray: + """Form 140PTC Part 1 line J from the line A to I instructions.""" + totals = [] + for unit in units: + total = 0.0 + for m in unit["members"]: + line_a = m["employment_income"] + line_b = ( + m["taxable_interest_income"] + + m["tax_exempt_interest_income"] + + m["qualified_dividend_income"] + ) + line_d = max( + m["long_term_capital_gains"] + m["short_term_capital_gains"], + -MEMBER_LOSS_LIMIT, + ) + line_e = ( + m["taxable_private_pension_income"] + + m["tax_exempt_public_pension_income"] + ) + # Social Security benefits are not income for the credit. + total += line_a + line_b + line_d + line_e + totals.append(total) + return np.array(totals) + + +def _units_with_nonnegative_income() -> list: + rng = np.random.default_rng(SEED) + units = _sample_units(rng) + # Keep each unit's income other than Social Security at or above zero, so + # the reference sum is not clipped (az_property_tax_credit_agi floors + # federal AGI less taxable Social Security at zero). + for unit, line_j in zip(units, _form_140ptc_line_j(units)): + exempt = sum( + m["tax_exempt_interest_income"] + m["tax_exempt_public_pension_income"] + for m in unit["members"] + ) + shortfall = exempt - line_j + if shortfall > 0: + unit["members"][0]["employment_income"] += round(shortfall, 2) + 1 + return units + + +UNITS = _units_with_nonnegative_income() + + +def test_household_income_matches_form_140ptc_lines(): + sim = Simulation(situation=_situation(UNITS, raise_gains=False)) + income = sim.calculate("az_property_tax_credit_income", YEAR) + np.testing.assert_allclose(income, _form_140ptc_line_j(UNITS), atol=0.01) + + +def test_gains_count_once(): + base = Simulation(situation=_situation(UNITS, raise_gains=False)) + raised = Simulation(situation=_situation(UNITS, raise_gains=True)) + increase = np.array([unit["gain_increase"] for unit in UNITS]) + income_change = raised.calculate( + "az_property_tax_credit_income", YEAR + ) - base.calculate("az_property_tax_credit_income", YEAR) + np.testing.assert_allclose(income_change, increase, atol=0.01) + credit_change = raised.calculate("az_property_tax_credit", YEAR) - base.calculate( + "az_property_tax_credit", YEAR + ) + assert (credit_change <= 0.01).all() + + +def test_income_does_not_read_preferential_rate_amount(): + sim = Simulation(situation=_situation(UNITS, raise_gains=False)) + rng = np.random.default_rng(SEED + 1) + sim.set_input( + "capital_gains_excluded_from_taxable_income", + YEAR, + rng.uniform(0, 50_000, len(UNITS)), + ) + income = sim.calculate("az_property_tax_credit_income", YEAR) + np.testing.assert_allclose(income, _form_140ptc_line_j(UNITS), atol=0.01) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py index fb47ebd3bf1..8f3163bfab1 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py @@ -4,9 +4,14 @@ class az_property_tax_credit_income(Variable): value_type = float entity = TaxUnit - label = "Income for the Arizona property tax the credit" + label = "Income for the Arizona property tax credit" unit = USD definition_period = YEAR + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(6) + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] defined_for = StateCode.AZ adds = "gov.states.az.tax.income.credits.property_tax.income_sources" From 7b5013b227fcd04dca89c1dd18256b5d8f7bf4c3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:59:42 -0400 Subject: [PATCH 02/14] Count dependents' income in Arizona property tax credit income Review r1b: federal AGI (irs_gross_income) leaves out every tax-unit dependent's income. The removed preferential-rate amount summed all members' gains and qualified dividends, so it had been counting a dependent's gains; without it a claimant whose child had $3,000 of gains got $502 instead of $412. A.A.C. R15-2C-502(A)(2) and (B) count every household member's income whether or not the member is a dependent, so az_property_tax_credit_dependent_income adds dependents' income from the same federal gross income sources, without Social Security (ARS 43-1072(I)). Also corrects the zero-taxable-income description: the preferential-rate amount is then the adjusted net capital gain. Adds dependent YAML cases and dependents to the sampled property tests. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-az-ptc-capital-gains.fixed.md | 2 +- .../credits/property_tax/income_sources.yaml | 8 +- .../az_property_tax_credit_income.yaml | 78 +++++++++++++++++++ .../test_az_property_tax_credit_income.py | 48 +++++++++--- ...az_property_tax_credit_dependent_income.py | 37 +++++++++ 5 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py diff --git a/changelog.d/fix-az-ptc-capital-gains.fixed.md b/changelog.d/fix-az-ptc-capital-gains.fixed.md index 94634df9b4a..29547a2d693 100644 --- a/changelog.d/fix-az-ptc-capital-gains.fixed.md +++ b/changelog.d/fix-az-ptc-capital-gains.fixed.md @@ -1 +1 @@ -Arizona property tax credit household income counts capital gains and qualified dividends once, through federal AGI, instead of adding the federal preferential-rate amount again. +Arizona property tax credit household income counts capital gains and qualified dividends once, through federal AGI, instead of adding the federal preferential-rate amount again, and counts dependents' income, which federal AGI leaves out. diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml index baa623bb1a1..c6e515d7d66 100644 --- a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml @@ -15,12 +15,16 @@ values: # excluded from federal AGI (such as a section 121 home sale or section 1202 # small business stock exclusion), so nothing is added for it. Do not add # capital_gains_excluded_from_taxable_income: it is the part of federal - # taxable income taxed at the capital gains rates (the whole net capital - # gain when taxable income is zero), which federal AGI already holds. + # taxable income taxed at the capital gains rates (at zero taxable income, + # the adjusted net capital gain), which federal AGI already holds. # Federal AGI limits a net capital loss to $3,000 per return ($1,500 if # married filing separately); A.A.C. R15-2C-502(C)(3) limits it to $1,500 # for each household member. - az_property_tax_credit_agi + # Federal AGI leaves out dependents' income; Arizona counts every household + # member's income whether or not the member is a dependent + # (A.A.C. R15-2C-502(A)(2), (B)). + - az_property_tax_credit_dependent_income metadata: unit: list period: year diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 009e885ee85..9a778e00879 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -204,3 +204,81 @@ # Line 11a: 2021 Schedule 1, 1,951 - 2,050 -> 435. # Line 13: 0.15 x 3,000 = 450. Line 15: smaller of 435 and 450. az_property_tax_credit: 435 + +# A.A.C. R15-2C-502(A)(2) and (B): household income combines every member's +# income, whether or not the member is a dependent. Federal AGI leaves out a +# dependent's income, so it is added separately. +- name: A dependent's long-term gains count once + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line D: column 3 (other persons) gain 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. + # Line 13: 0.15 x 5,000 = 750. Line 15: smaller of 412 and 750. + az_property_tax_credit: 412 + +- name: A dependent's qualified dividends count once + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 10 + is_tax_unit_dependent: true + qualified_dividend_income: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line B: column 3 dividends 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. Line 13: 750. + az_property_tax_credit: 412 + +- name: A dependent's wages count with the claimant's pension + period: 2025 + input: + people: + head: + age: 70 + taxable_private_pension_income: 1_000 + rent: 5_000 + grandchild: + age: 16 + is_tax_unit_dependent: true + employment_income: 2_000 + tax_units: + tax_unit: + members: [head, grandchild] + households: + household: + members: [head, grandchild] + state_code: AZ + output: + # Line A: column 3 wages 2,000. Line E: column 1 pension 1,000. + # Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. Line 13: 750. + az_property_tax_credit: 412 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index fbdde7ee13c..25a1efc2e50 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -6,16 +6,18 @@ with a net loss limited to $1,500. Federal AGI already holds those gains, so az_property_tax_credit_income must count each dollar of gain once. It used to add capital_gains_excluded_from_taxable_income as well, which is the part of -federal taxable income taxed at the capital gains rates (the whole net capital -gain plus qualified dividends when taxable income is zero), so gains and -qualified dividends counted twice. - -Invariants, checked on a seeded sample of Arizona tax units whose per-member -net capital gain is at least -$1,500 (where the federal and Arizona loss rules -agree) and whose income other than Social Security is not negative: - -1. Differential: the model equals an independent line A + B + D + E sum from - Form 140PTC Part 1, with Social Security excluded. +federal taxable income taxed at the capital gains rates (at zero taxable +income, the adjusted net capital gain), so gains and qualified dividends +counted twice. Federal AGI leaves out dependents' income, which Arizona counts +for every household member whether or not a dependent. + +Invariants, checked on a seeded sample of Arizona tax units (some with a child +dependent who has income) whose per-member net capital gain is at least -$1,500 +(where the federal and Arizona loss rules agree), whose dependents have no net +capital loss, and whose income other than Social Security is not negative: + +1. Differential: the model equals an independent line A + B + D + E sum over + every member from Form 140PTC Part 1, with Social Security excluded. 2. Counted once: adding d to a member's long-term gains raises household income by exactly d. 3. The income does not depend on capital_gains_excluded_from_taxable_income. @@ -71,6 +73,29 @@ def _sample_units(rng: np.random.Generator) -> list: ), } ) + if rng.random() < 0.35: + # A child dependent with income of their own. Dependents' net + # capital losses are not modeled, so their gains stay at or above 0. + members.append( + { + "role": "dependent", + "age": int(rng.integers(5, 18)), + "is_tax_unit_dependent": True, + "employment_income": float( + rng.choice([0, round(rng.uniform(0, 4_000), 2)]) + ), + "taxable_interest_income": round(rng.uniform(0, 300), 2), + "tax_exempt_interest_income": round(rng.uniform(0, 200), 2), + "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), + "long_term_capital_gains": round(rng.uniform(0, 4_000), 2), + "short_term_capital_gains": round(rng.uniform(0, 1_000), 2), + "taxable_private_pension_income": 0.0, + "tax_exempt_public_pension_income": 0.0, + "social_security_survivors": float( + rng.choice([0, round(rng.uniform(0, 8_000), 2)]) + ), + } + ) for member in members: net_gain = ( member["long_term_capital_gains"] + member["short_term_capital_gains"] @@ -132,7 +157,8 @@ def _form_140ptc_line_j(units: list) -> np.ndarray: m["taxable_private_pension_income"] + m["tax_exempt_public_pension_income"] ) - # Social Security benefits are not income for the credit. + # Social Security benefits are not income for the credit. Every + # member counts, dependent or not (A.A.C. R15-2C-502(A)(2), (B)). total += line_a + line_b + line_d + line_e totals.append(total) return np.array(totals) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py new file mode 100644 index 00000000000..969bb276c0a --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py @@ -0,0 +1,37 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_dependent_income(Variable): + value_type = float + entity = TaxUnit + label = "Arizona property tax credit income of dependents" + unit = USD + definition_period = YEAR + documentation = ( + "Income of the tax unit's dependents for the Arizona property tax credit. " + "Federal AGI, which az_property_tax_credit_agi starts from, holds only the " + "income of members who are not dependents; Arizona combines the income of " + "every member of the household, whether or not the member is a dependent." + ) + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(4)-(6), (I) + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] + defined_for = StateCode.AZ + + def formula(tax_unit, period, parameters): + person = tax_unit.members + # A.A.C. R15-2C-502(A)(2) and (B): household income combines the + # separately determined income of each member, "whether or not the + # person is related to, or a dependent of, the claimant". irs_gross_income + # counts the same federal sources for members who are not dependents. + sources = parameters(period).gov.irs.gross_income.sources + income = 0 + for source in sources: + # ARS 43-1072(I): Social Security benefits are not income. + if source == "taxable_social_security": + continue + income += max_(0, add(person, period, [source])) + is_dependent = person("is_tax_unit_dependent", period) + return tax_unit.sum(is_dependent * income) From 6c814d5e70fc1b782b2fb026bef51997a952a84e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 10:27:54 -0400 Subject: [PATCH 03/14] Apply Arizona's $1,500 member capital loss limit and allow a negative line J A.A.C. R15-2C-502(C)(3), ITR 12-1 item (7) and the Form 140PTC line D instructions (2021-2025) limit each household member's net capital loss to $1,500. az_property_tax_credit_agi used federal AGI's capital gains and losses instead: non-dependents' net gains less a capital loss limited to $3,000 per return. It now swaps those for each non-dependent member's line D amount (new az_property_tax_credit_capital_gains, parameter capital_loss_limit). Dependents' line D amounts move into az_property_tax_credit_dependent_income with the same limit, in place of their gains floored at zero. The line J note in the instructions lets household income go negative and uses zero for the Schedule 1 and 2 amounts. az_property_tax_credit_agi no longer floors at zero; az_property_tax_credit floors income at the schedule lookup, where a single-amount scale would otherwise return 0 below its first threshold. Co-Authored-By: Claude Opus 5.5 --- changelog.d/az-ptc-line-d-loss-limit.fixed.md | 1 + .../property_tax/capital_loss_limit.yaml | 22 ++ .../credits/property_tax/income_sources.yaml | 20 +- .../income/az_property_tax_credit_agi.yaml | 52 +++- .../credits/az_property_tax_credit.yaml | 17 ++ .../az_property_tax_credit_capital_gains.yaml | 57 ++++ .../az_property_tax_credit_income.yaml | 176 ++++++++++++ .../test_az_property_tax_credit_income.py | 269 +++++++++++++----- .../az_property_tax_credit.py | 11 +- .../az_property_tax_credit_agi.py | 37 ++- .../az_property_tax_credit_capital_gains.py | 29 ++ ...az_property_tax_credit_dependent_income.py | 11 +- .../az_property_tax_credit_income.py | 5 + 13 files changed, 616 insertions(+), 91 deletions(-) create mode 100644 changelog.d/az-ptc-line-d-loss-limit.fixed.md create mode 100644 policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py diff --git a/changelog.d/az-ptc-line-d-loss-limit.fixed.md b/changelog.d/az-ptc-line-d-loss-limit.fixed.md new file mode 100644 index 00000000000..0a254ec33c0 --- /dev/null +++ b/changelog.d/az-ptc-line-d-loss-limit.fixed.md @@ -0,0 +1 @@ +Limit each Arizona household member's net capital loss to $1,500 in property tax credit household income (Form 140PTC line D), and let household income go negative, using zero only for the credit schedules (line J). diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml new file mode 100644 index 00000000000..4c6e88e13f5 --- /dev/null +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml @@ -0,0 +1,22 @@ +description: Arizona limits each household member's net capital loss to this amount when computing household income for the property tax credit. +values: + 2021-01-01: 1_500 +metadata: + unit: currency-USD + period: year + label: Arizona property tax credit capital loss limit per household member + reference: + - title: A.A.C. R15-2C-502(C)(3). Property Tax Credit + href: https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502 + - title: ITR 12-1. Income Tax Ruling - Property Tax Credit Household Income, item (7) + href: https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2 + - title: 2025 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4 + - title: 2024 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2024_140PTCi.pdf#page=4 + - title: 2023 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/2023-12/FORMS_INDIVIDUAL_2023_140PTCi.pdf#page=4 + - title: 2022 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2022_140PTCi-2D.pdf#page=4 + - title: 2021 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2021_140PTCi-2D.pdf#page=4 diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml index c6e515d7d66..8c819559cb7 100644 --- a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml @@ -9,21 +9,23 @@ values: - tax_exempt_private_pension_income # Federal AGI minus Social Security (per ARS 43-1072(I) exclusion). # Item (7) capital gains and losses (Form 140PTC Part 1 line D) are counted - # here, once: federal AGI holds the year's net gain from the sale or - # exchange of property. ARS 43-1072(H)(6)(b) also adds "the amount of - # capital gains excluded from adjusted gross income"; the model has no gain - # excluded from federal AGI (such as a section 121 home sale or section 1202 - # small business stock exclusion), so nothing is added for it. Do not add + # here, once, for members who are not dependents: federal AGI's net capital + # gain and capital loss deduction (limited to $3,000 per return) are + # replaced by each member's combined gains and losses, with a net loss + # limited to $1,500 for each member (A.A.C. R15-2C-502(C)(3)). The total can + # be negative; az_property_tax_credit uses zero for the schedules. + # ARS 43-1072(H)(6)(b) also adds "the amount of capital gains excluded from + # adjusted gross income"; the model has no gain excluded from federal AGI + # (such as a section 121 home sale or section 1202 small business stock + # exclusion), so nothing is added for it. Do not add # capital_gains_excluded_from_taxable_income: it is the part of federal # taxable income taxed at the capital gains rates (at zero taxable income, # the adjusted net capital gain), which federal AGI already holds. - # Federal AGI limits a net capital loss to $3,000 per return ($1,500 if - # married filing separately); A.A.C. R15-2C-502(C)(3) limits it to $1,500 - # for each household member. - az_property_tax_credit_agi # Federal AGI leaves out dependents' income; Arizona counts every household # member's income whether or not the member is a dependent - # (A.A.C. R15-2C-502(A)(2), (B)). + # (A.A.C. R15-2C-502(A)(2), (B)). This includes each dependent's line D + # amount, with the same $1,500 loss limit. - az_property_tax_credit_dependent_income metadata: unit: list diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml index 5016feb4b85..2f920a71272 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml @@ -38,14 +38,16 @@ output: az_property_tax_credit_agi: 25_000 -- name: Capped at zero when Social Security exceeds other income +- name: Negative when losses leave other income below taxable Social Security period: 2024 input: state_code: AZ adjusted_gross_income: 5_000 tax_unit_taxable_social_security: 6_000 output: - az_property_tax_credit_agi: 0 + # $5,000 - $6,000 = -$1,000. Not floored: Form 140PTC line J can be + # negative, and az_property_tax_credit uses zero only for the schedules. + az_property_tax_credit_agi: -1_000 - name: Combined income types with Social Security exclusion period: 2024 @@ -56,3 +58,49 @@ output: # $22,250 - $4,250 = $18,000 az_property_tax_credit_agi: 18_000 + +# A.A.C. R15-2C-502(C)(3): each member's net capital loss is limited to +# $1,500, in place of federal AGI's $3,000 limit per return. +- name: Federal capital loss limit is replaced by the 1,500 member limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 10_000 + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: 10,000 - 3,000 = 7,000. + adjusted_gross_income: 7_000 + # Form 140PTC line A 10,000 + line D (1,500) = 8,500. + az_property_tax_credit_agi: 8_500 + +- name: A dependent's capital loss is left to dependent income + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + child: + age: 12 + is_tax_unit_dependent: true + long_term_capital_gains: -2_500 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Only the head's line A wages. The dependent's line D (1,500) is in + # az_property_tax_credit_dependent_income. + az_property_tax_credit_agi: 3_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml index 2f4afe51114..f9be8c98981 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml @@ -144,3 +144,20 @@ households: {household: {members: [head], state_code: AZ}} output: az_property_tax_credit: 78 + +# Form 140PTC line J note (2021-2025 instructions, page 4): a negative household +# income is entered as is, and household income is considered zero for the +# Schedule 1 and 2 amounts. +- name: negative household income uses the zero-income schedule amount + period: 2025 + input: + state_code: AZ + cohabitating_spouses: false + az_property_tax_credit_income: -1_000 + az_property_tax_credit_eligible: true + real_estate_taxes: 0 + rent: 4_000 + output: + # Line 11a: Schedule 1, 0 - 1,750 -> 502. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 502 and 600. + az_property_tax_credit: 502 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml new file mode 100644 index 00000000000..43feab97aea --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml @@ -0,0 +1,57 @@ +# Form 140PTC Part 1 line D, for one household member: "combine gains and +# losses from the sale or exchange of property. If the result is a gain, enter +# the gain. If the result is a loss, enter the amount of the loss up to a +# maximum of ($1,500)." (2021-2025 instructions, page 4; +# A.A.C. R15-2C-502(C)(3); ITR 12-1 item (7).) + +- name: A net gain is entered in full + period: 2025 + input: + state_code: AZ + long_term_capital_gains: 3_000 + short_term_capital_gains: -500 + output: + # 3,000 - 500 = 2,500. + az_property_tax_credit_capital_gains: 2_500 + +- name: A net loss within the limit is entered in full + period: 2025 + input: + state_code: AZ + long_term_capital_gains: -1_000 + output: + az_property_tax_credit_capital_gains: -1_000 + +- name: A net loss equal to the limit is entered in full + period: 2025 + input: + state_code: AZ + short_term_capital_gains: -1_500 + output: + az_property_tax_credit_capital_gains: -1_500 + +- name: A net loss over the limit is entered as the limit + period: 2025 + input: + state_code: AZ + long_term_capital_gains: 2_000 + short_term_capital_gains: -6_000 + output: + # 2,000 - 6,000 = (4,000), limited to (1,500). + az_property_tax_credit_capital_gains: -1_500 + +- name: The limit applies in 2021 + period: 2021 + input: + state_code: AZ + long_term_capital_gains: -3_000 + output: + az_property_tax_credit_capital_gains: -1_500 + +- name: Not computed outside Arizona + period: 2025 + input: + state_code: CA + long_term_capital_gains: -3_000 + output: + az_property_tax_credit_capital_gains: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 9a778e00879..bed6e7187df 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -282,3 +282,179 @@ az_property_tax_credit_income: 3_000 # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. Line 13: 750. az_property_tax_credit: 412 + +# Line D loss limit. A.A.C. R15-2C-502(C)(3), ITR 12-1 item (7) and the line D +# instructions limit each member's net loss to (1,500). Federal AGI instead +# limits a return's net loss to (3,000), so household income replaces it. A +# negative line J is entered as is and treated as zero for Schedules 1 and 2 +# (2021-2025 instructions, page 4, line J note). +- name: Single filer's net loss counts only up to the 1,500 limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -2_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 4,000. + # Line D: net loss (2,000), limited to (1,500). + # Line J: 4,000 - 1,500 = 2,500. + # Federal AGI would give 4,000 - 2,000 = 2,000. + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. + # Line 13: 0.15 x 5,000 = 750. Line 15: smaller of 323 and 750. + az_property_tax_credit: 323 + +- name: Each spouse's net loss counts up to that spouse's own 1,500 limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 6_000 + long_term_capital_gains: -1_500 + rent: 5_000 + spouse: + age: 68 + short_term_capital_gains: -1_500 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line A: wages 6,000 (column 1). + # Line D: (1,500) (column 1) + (1,500) (column 2) = (3,000). + # Line J: 6,000 - 3,000 = 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. + # Line 13: 750. Line 15: smaller of 412 and 750. + az_property_tax_credit: 412 + +- name: One spouse's loss over the limit does not offset the other's gain + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + long_term_capital_gains: -4_000 + rent: 5_000 + spouse: + age: 70 + long_term_capital_gains: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line A: wages 3,000 (column 1). + # Line D: (4,000) limited to (1,500) (column 1) + 2,000 (column 2) = 500. + # Line J: 3,000 + 500 = 3,500. + # Federal AGI would net the return first: 3,000 + 2,000 - 3,000 = 2,000. + az_property_tax_credit_income: 3_500 + # Line 11b: Schedule 2, 3,401 - 3,550 -> 345. + # Line 13: 750. Line 15: smaller of 345 and 750. + az_property_tax_credit: 345 + +- name: A line D loss offsets a tax-exempt pension + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_500 + tax_exempt_public_pension_income: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: net loss (1,500), at the limit. + # Line E: Arizona public pension 3,000. + # Line J: 3,000 - 1,500 = 1,500. Flooring federal AGI less Social + # Security at zero before adding the pension would give 3,000. + az_property_tax_credit_agi: -1_500 + az_property_tax_credit_income: 1_500 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. + # Line 13: 750. Line 15: smaller of 502 and 750. + az_property_tax_credit: 502 + +- name: Negative line J with a tax-exempt pension uses zero for the schedule + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_500 + tax_exempt_public_pension_income: 1_000 + rent: 4_000 + spouse: + age: 70 + short_term_capital_gains: -2_500 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line D: (1,500) (column 1) + (2,500) limited to (1,500) (column 2) + # = (3,000). + # Line E: Arizona public pension 1,000 (column 1). + # Line J: 1,000 - 3,000 = (2,000), entered as a negative amount. + az_property_tax_credit_agi: -3_000 + az_property_tax_credit_income: -2_000 + # Line 11b: household income is considered zero for the schedule. + # Schedule 2, 0 - 2,500 -> 502. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 502 and 600. + az_property_tax_credit: 502 + +- name: A dependent's net loss counts up to the dependent's own 1,500 limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + rent: 5_000 + grandchild: + age: 15 + is_tax_unit_dependent: true + long_term_capital_gains: -2_500 + tax_units: + tax_unit: + members: [head, grandchild] + households: + household: + members: [head, grandchild] + state_code: AZ + output: + # Line A: wages 3,000 (column 1). + # Line D: column 3 net loss (2,500), limited to (1,500). + # Line J: 3,000 - 1,500 = 1,500. + az_property_tax_credit_agi: 3_000 + az_property_tax_credit_dependent_income: -1_500 + az_property_tax_credit_income: 1_500 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. + # Line 13: 750. Line 15: smaller of 502 and 750. + az_property_tax_credit: 502 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index 25a1efc2e50..a2b66b5e356 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -3,25 +3,33 @@ ARS 43-1072(H)(6) defines income for the credit, and A.A.C. R15-2C-502 and the Form 140PTC instructions (2021-2025, page 4) apply it line by line: line D is each member's gains and losses from the sale or exchange of property, combined, -with a net loss limited to $1,500. Federal AGI already holds those gains, so -az_property_tax_credit_income must count each dollar of gain once. It used to -add capital_gains_excluded_from_taxable_income as well, which is the part of -federal taxable income taxed at the capital gains rates (at zero taxable -income, the adjusted net capital gain), so gains and qualified dividends +with a net loss limited to $1,500 for each member. Federal AGI already holds +those gains, so az_property_tax_credit_income must count each dollar of gain +once. It used to add capital_gains_excluded_from_taxable_income as well, which +is the part of federal taxable income taxed at the capital gains rates (at zero +taxable income, the adjusted net capital gain), so gains and qualified dividends counted twice. Federal AGI leaves out dependents' income, which Arizona counts -for every household member whether or not a dependent. +for every household member whether or not a dependent. Federal AGI also limits +a net capital loss to $3,000 per return rather than $1,500 per member, so line D +replaces its capital gains and losses. Household income (line J) can be +negative; the instructions then use zero for the Schedule 1 and 2 amounts. Invariants, checked on a seeded sample of Arizona tax units (some with a child -dependent who has income) whose per-member net capital gain is at least -$1,500 -(where the federal and Arizona loss rules agree), whose dependents have no net -capital loss, and whose income other than Social Security is not negative: +dependent who has income) whose members' net capital gains run from well below +-$1,500 to large gains, including units whose line J is negative: 1. Differential: the model equals an independent line A + B + D + E sum over - every member from Form 140PTC Part 1, with Social Security excluded. -2. Counted once: adding d to a member's long-term gains raises household - income by exactly d. -3. The income does not depend on capital_gains_excluded_from_taxable_income. -4. The credit never rises when gains rise. + every member from Form 140PTC Part 1, with Social Security excluded and + each member's line D floored at -$1,500. +2. Counted once: adding d to a member's long-term gains changes household + income by exactly the change in that member's line D, which is d when the + member's net gain stays at or above -$1,500. The credit never rises. +3. Loss limit: lowering a member's gains changes household income by the + change in that member's line D, which is 0 once the member's net gain is at + or below -$1,500. +4. Schedule floor: the credit depends on line J only through max(line J, 0), + so a negative line J gets the zero-income schedule amount. +5. The income does not depend on capital_gains_excluded_from_taxable_income. """ import numpy as np @@ -29,12 +37,38 @@ from policyengine_us import Simulation YEAR = 2025 -N = 60 +N = 80 SEED = 43_1072 # A.A.C. R15-2C-502(C)(3): net capital losses are limited to $1,500 for each -# household member. The sample stays at or above it, where federal AGI's -# $3,000-per-return limit gives the same answer. +# household member. MEMBER_LOSS_LIMIT = 1_500 +# Form 140PTC line 13 for renters: rent times the property tax factor, +# modeled as gov.states.az.tax.income.credits.property_tax.rent_property_tax_rate. +RENT_PROPERTY_TAX_RATE = 0.15 + + +def _capital_gains(rng: np.random.Generator, can_be_large: bool) -> tuple: + """Long- and short-term gains for one member.""" + draw = rng.random() + if draw < 0.3: + return 0.0, 0.0 + if draw < 0.55: + # A net loss beyond the $1,500 member limit, some beyond federal AGI's + # $3,000 per-return limit too. + return ( + round(rng.uniform(-9_000, -1_600), 2), + round(rng.uniform(-1_000, 500), 2), + ) + # Some large gains, so federal taxable income is positive. + large_gain = ( + round(rng.uniform(20_000, 80_000), 2) + if can_be_large and rng.random() < 0.15 + else 0 + ) + return ( + round(rng.uniform(-1_000, 6_000), 2) + large_gain, + round(rng.uniform(-500, 2_000), 2), + ) def _sample_units(rng: np.random.Generator) -> list: @@ -43,11 +77,7 @@ def _sample_units(rng: np.random.Generator) -> list: married = rng.random() < 0.4 members = [] for role in ["head", "spouse"] if married else ["head"]: - gains = rng.random() < 0.7 - # Some large gains, so federal taxable income is positive. - large_gain = ( - round(rng.uniform(20_000, 80_000), 2) if rng.random() < 0.15 else 0 - ) + long_term, short_term = _capital_gains(rng, can_be_large=True) members.append( { "role": role, @@ -58,24 +88,23 @@ def _sample_units(rng: np.random.Generator) -> list: "taxable_interest_income": round(rng.uniform(0, 800), 2), "tax_exempt_interest_income": round(rng.uniform(0, 500), 2), "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), - "long_term_capital_gains": ( - round(rng.uniform(-1_000, 6_000), 2) + large_gain - if gains - else 0.0 + "long_term_capital_gains": long_term, + "short_term_capital_gains": short_term, + "taxable_private_pension_income": float( + rng.choice([0, round(rng.uniform(0, 3_000), 2)]) ), - "short_term_capital_gains": ( - round(rng.uniform(-500, 2_000), 2) if gains else 0.0 + "tax_exempt_public_pension_income": float( + rng.choice([0, round(rng.uniform(0, 3_000), 2)]) ), - "taxable_private_pension_income": round(rng.uniform(0, 3_000), 2), - "tax_exempt_public_pension_income": round(rng.uniform(0, 1_000), 2), "social_security_retirement": float( rng.choice([0, round(rng.uniform(0, 30_000), 2)]) ), } ) if rng.random() < 0.35: - # A child dependent with income of their own. Dependents' net - # capital losses are not modeled, so their gains stay at or above 0. + # A child dependent with income of their own, including net + # capital losses, which count up to the dependent's own limit. + long_term, short_term = _capital_gains(rng, can_be_large=False) members.append( { "role": "dependent", @@ -87,8 +116,8 @@ def _sample_units(rng: np.random.Generator) -> list: "taxable_interest_income": round(rng.uniform(0, 300), 2), "tax_exempt_interest_income": round(rng.uniform(0, 200), 2), "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), - "long_term_capital_gains": round(rng.uniform(0, 4_000), 2), - "short_term_capital_gains": round(rng.uniform(0, 1_000), 2), + "long_term_capital_gains": long_term, + "short_term_capital_gains": short_term, "taxable_private_pension_income": 0.0, "tax_exempt_public_pension_income": 0.0, "social_security_survivors": float( @@ -96,22 +125,47 @@ def _sample_units(rng: np.random.Generator) -> list: ), } ) - for member in members: - net_gain = ( - member["long_term_capital_gains"] + member["short_term_capital_gains"] + if rng.random() < 0.15: + # Little income besides a capital loss and a small tax-exempt + # pension, so line J can be negative (2021-2025 instructions, + # page 4, line J note). + for member in members: + for key in [ + "employment_income", + "taxable_interest_income", + "qualified_dividend_income", + "taxable_private_pension_income", + ]: + member[key] = 0.0 + member["tax_exempt_interest_income"] = round(rng.uniform(0, 200), 2) + members[0]["long_term_capital_gains"] = round( + rng.uniform(-6_000, -1_000), 2 + ) + members[0]["tax_exempt_public_pension_income"] = round( + rng.uniform(0, 1_500), 2 ) - assert net_gain >= -MEMBER_LOSS_LIMIT units.append( { "members": members, "rent": round(rng.uniform(1_000, 12_000), 2), - "gain_increase": round(rng.uniform(1, 5_000), 2), + "gain_change": round(rng.uniform(1, 5_000), 2), } ) return units -def _situation(units: list, raise_gains: bool) -> dict: +def _with_head_gains_changed(units: list, sign: int) -> list: + changed = [] + for unit in units: + members = [dict(m) for m in unit["members"]] + members[0]["long_term_capital_gains"] = round( + members[0]["long_term_capital_gains"] + sign * unit["gain_change"], 2 + ) + changed.append({**unit, "members": members}) + return changed + + +def _situation(units: list) -> dict: people, tax_units, households = {}, {}, {} year = str(YEAR) for i, unit in enumerate(units): @@ -122,10 +176,6 @@ def _situation(units: list, raise_gains: bool) -> dict: person = { key: {year: value} for key, value in member.items() if key != "role" } - if raise_gains and member["role"] == "head": - person["long_term_capital_gains"] = { - year: member["long_term_capital_gains"] + unit["gain_increase"] - } if member["role"] == "head": person["rent"] = {year: unit["rent"]} people[name] = person @@ -137,6 +187,15 @@ def _situation(units: list, raise_gains: bool) -> dict: return {"people": people, "tax_units": tax_units, "households": households} +def _net_gain(member: dict) -> float: + return member["long_term_capital_gains"] + member["short_term_capital_gains"] + + +def _line_d(member: dict) -> float: + """Form 140PTC line D for one member: a net loss is limited to (1,500).""" + return max(_net_gain(member), -MEMBER_LOSS_LIMIT) + + def _form_140ptc_line_j(units: list) -> np.ndarray: """Form 140PTC Part 1 line J from the line A to I instructions.""" totals = [] @@ -149,63 +208,125 @@ def _form_140ptc_line_j(units: list) -> np.ndarray: + m["tax_exempt_interest_income"] + m["qualified_dividend_income"] ) - line_d = max( - m["long_term_capital_gains"] + m["short_term_capital_gains"], - -MEMBER_LOSS_LIMIT, - ) line_e = ( m["taxable_private_pension_income"] + m["tax_exempt_public_pension_income"] ) # Social Security benefits are not income for the credit. Every # member counts, dependent or not (A.A.C. R15-2C-502(A)(2), (B)). - total += line_a + line_b + line_d + line_e + total += line_a + line_b + _line_d(m) + line_e totals.append(total) return np.array(totals) -def _units_with_nonnegative_income() -> list: - rng = np.random.default_rng(SEED) - units = _sample_units(rng) - # Keep each unit's income other than Social Security at or above zero, so - # the reference sum is not clipped (az_property_tax_credit_agi floors - # federal AGI less taxable Social Security at zero). - for unit, line_j in zip(units, _form_140ptc_line_j(units)): - exempt = sum( - m["tax_exempt_interest_income"] + m["tax_exempt_public_pension_income"] - for m in unit["members"] - ) - shortfall = exempt - line_j - if shortfall > 0: - unit["members"][0]["employment_income"] += round(shortfall, 2) + 1 - return units +UNITS = _sample_units(np.random.default_rng(SEED)) +RAISED = _with_head_gains_changed(UNITS, sign=1) +LOWERED = _with_head_gains_changed(UNITS, sign=-1) +LINE_J = _form_140ptc_line_j(UNITS) -UNITS = _units_with_nonnegative_income() +def test_sample_covers_the_loss_limit_and_negative_line_j(): + members = [m for unit in UNITS for m in unit["members"]] + beyond_limit = [m for m in members if _net_gain(m) < -MEMBER_LOSS_LIMIT] + assert len(beyond_limit) >= 20 + assert any(m["role"] == "dependent" for m in beyond_limit) + # Units where federal AGI's capital gains and losses (non-dependents' net + # gains, less losses limited to $3,000 per return) differ from line D. + federal = np.array( + [ + sum( + max(0, _net_gain(m)) + for m in unit["members"] + if m["role"] != "dependent" + ) + - min(3_000, sum(max(0, -_net_gain(m)) for m in unit["members"])) + for unit in UNITS + ] + ) + line_d = np.array([sum(_line_d(m) for m in unit["members"]) for unit in UNITS]) + assert (np.abs(federal - line_d) > 0.01).sum() >= 20 + assert (LINE_J < 0).sum() >= 3 def test_household_income_matches_form_140ptc_lines(): - sim = Simulation(situation=_situation(UNITS, raise_gains=False)) + sim = Simulation(situation=_situation(UNITS)) income = sim.calculate("az_property_tax_credit_income", YEAR) - np.testing.assert_allclose(income, _form_140ptc_line_j(UNITS), atol=0.01) + np.testing.assert_allclose(income, LINE_J, atol=0.01) -def test_gains_count_once(): - base = Simulation(situation=_situation(UNITS, raise_gains=False)) - raised = Simulation(situation=_situation(UNITS, raise_gains=True)) - increase = np.array([unit["gain_increase"] for unit in UNITS]) +def test_gains_count_once_up_to_the_loss_limit(): + base = Simulation(situation=_situation(UNITS)) + raised = Simulation(situation=_situation(RAISED)) + expected = np.array( + [ + _line_d(r["members"][0]) - _line_d(u["members"][0]) + for u, r in zip(UNITS, RAISED) + ] + ) income_change = raised.calculate( "az_property_tax_credit_income", YEAR ) - base.calculate("az_property_tax_credit_income", YEAR) - np.testing.assert_allclose(income_change, increase, atol=0.01) + np.testing.assert_allclose(income_change, expected, atol=0.01) + # Where the head's net gain stays at or above the limit, the change is d. + stays_above = np.array( + [_net_gain(u["members"][0]) >= -MEMBER_LOSS_LIMIT for u in UNITS] + ) + increase = np.array([unit["gain_change"] for unit in UNITS]) + assert stays_above.sum() >= 20 + np.testing.assert_allclose( + income_change[stays_above], increase[stays_above], atol=0.01 + ) credit_change = raised.calculate("az_property_tax_credit", YEAR) - base.calculate( "az_property_tax_credit", YEAR ) assert (credit_change <= 0.01).all() +def test_member_loss_limit(): + base = Simulation(situation=_situation(UNITS)) + lowered = Simulation(situation=_situation(LOWERED)) + income_change = lowered.calculate( + "az_property_tax_credit_income", YEAR + ) - base.calculate("az_property_tax_credit_income", YEAR) + expected = np.array( + [ + _line_d(w["members"][0]) - _line_d(u["members"][0]) + for u, w in zip(UNITS, LOWERED) + ] + ) + np.testing.assert_allclose(income_change, expected, atol=0.01) + # Once a member's net gain is at or below the limit, more loss is ignored. + at_limit = np.array( + [_net_gain(u["members"][0]) <= -MEMBER_LOSS_LIMIT for u in UNITS] + ) + assert at_limit.sum() >= 10 + np.testing.assert_allclose(income_change[at_limit], 0, atol=0.01) + # Each member's line D is bounded below by the limit. + line_d = base.calculate("az_property_tax_credit_capital_gains", YEAR) + assert (line_d >= -MEMBER_LOSS_LIMIT - 0.01).all() + + +def test_credit_uses_zero_for_negative_household_income(): + sim = Simulation(situation=_situation(UNITS)) + credit = sim.calculate("az_property_tax_credit", YEAR) + floored = Simulation(situation=_situation(UNITS)) + floored.set_input("az_property_tax_credit_income", YEAR, np.maximum(LINE_J, 0)) + np.testing.assert_allclose( + credit, floored.calculate("az_property_tax_credit", YEAR), atol=0.01 + ) + # A negative line J gets the zero-income schedule amount ($502 on both + # schedules), limited by the renter's property taxes. + negative = LINE_J < 0 + rent = np.array([unit["rent"] for unit in UNITS]) + np.testing.assert_allclose( + credit[negative], + np.minimum(502, RENT_PROPERTY_TAX_RATE * rent[negative]), + atol=0.01, + ) + + def test_income_does_not_read_preferential_rate_amount(): - sim = Simulation(situation=_situation(UNITS, raise_gains=False)) + sim = Simulation(situation=_situation(UNITS)) rng = np.random.default_rng(SEED + 1) sim.set_input( "capital_gains_excluded_from_taxable_income", @@ -213,4 +334,4 @@ def test_income_does_not_read_preferential_rate_amount(): rng.uniform(0, 50_000, len(UNITS)), ) income = sim.calculate("az_property_tax_credit_income", YEAR) - np.testing.assert_allclose(income, _form_140ptc_line_j(UNITS), atol=0.01) + np.testing.assert_allclose(income, LINE_J, atol=0.01) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py index da5ead3a06e..0c71a4aa8dd 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py @@ -27,10 +27,17 @@ def formula(tax_unit, period, parameters): | (household_size > 1) ) + # Household income (Form 140PTC line J) can be negative, for example + # after a line D capital loss. The 2021-2025 instructions (page 4, line + # J note) then treat household income as zero for the Schedule 1 and 2 + # lookup. The schedules start at 0, and a single-amount scale returns + # 0 below its first threshold, so a negative income must be floored + # here rather than passed to calc. + schedule_income = max_(income, 0) cap = where( lives_with_others, - p.amount.cohabitating.calc(income), - p.amount.living_alone.calc(income), + p.amount.cohabitating.calc(schedule_income), + p.amount.living_alone.calc(schedule_income), ) # ARS 43-1072(B): the credit is the lesser of the table amount and diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py index 90e6818719f..3d36fc1cb5e 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py @@ -13,11 +13,17 @@ class az_property_tax_credit_agi(Variable): "excludes items specifically listed in ARS 43-1072(I): Social Security, " "railroad retirement, workers compensation, Arizona unemployment, veterans " "disability pensions, welfare, and gifts. Unlike regular Arizona income tax, " - "this does NOT exclude pension income, capital gains, or Arizona exemptions." + "this does NOT exclude pension income, capital gains, or Arizona exemptions. " + "Capital gains and losses are each member's Form 140PTC line D amount, with " + "a net loss limited per member, instead of federal AGI's net capital gain " + "less a capital loss limited per return. Covers members who are not " + "dependents, as federal AGI does, and can be negative." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072 "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf", # ITR 12-1 + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", ] defined_for = StateCode.AZ @@ -48,4 +54,31 @@ def formula(tax_unit, period, parameters): # - US Government interest should be INCLUDED per ITR 12-1 item (2) # - Arizona exemptions (aged, blind) should NOT be subtracted - return max_(0, federal_agi - taxable_social_security) + # Capital gains and losses (Form 140PTC Part 1 line D). Federal AGI + # adds each non-dependent's net capital gain (irs_gross_income) and + # subtracts the capital loss deduction, limited to $3,000 per return + # (limited_capital_loss, part of loss_ald). A.A.C. R15-2C-502(C)(3) + # and ITR 12-1 item (7) instead limit each member's net loss to + # $1,500, so the federal amounts are replaced by each member's line D + # amount. Dependents' line D amounts are in + # az_property_tax_credit_dependent_income, with their other income. + person = tax_unit.members + is_dependent = person("is_tax_unit_dependent", period) + federal_capital_gains = tax_unit.sum( + ~is_dependent * max_(0, person("capital_gains", period)) + ) + federal_capital_loss = tax_unit("limited_capital_loss", period) + line_d = tax_unit.sum( + ~is_dependent * person("az_property_tax_credit_capital_gains", period) + ) + + # Not floored at zero: household income (Form 140PTC line J) can be + # negative, and az_property_tax_credit treats it as zero only for the + # Schedule 1 and 2 lookup. + return ( + federal_agi + - taxable_social_security + - federal_capital_gains + + federal_capital_loss + + line_d + ) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py new file mode 100644 index 00000000000..b5e8ceb9dce --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_capital_gains(Variable): + value_type = float + entity = Person + label = "Arizona property tax credit capital gains and losses" + unit = USD + definition_period = YEAR + documentation = ( + "A household member's gains and losses from the sale or exchange of " + "property for the Arizona property tax credit (Form 140PTC Part 1 line D): " + "the year's gains and losses combined, with a net loss counted only up to " + "the per-member limit. No prior-year capital loss carryover is used." + ) + reference = [ + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] + defined_for = StateCode.AZ + + def formula(person, period, parameters): + p = parameters(period).gov.states.az.tax.income.credits.property_tax + # A.A.C. R15-2C-502(C)(3): "Income from capital gains is the net capital + # gains and losses for each member of the household. Net losses are + # limited to $1,500 for each household member." The limit applies to + # each member separately, not to the return as the federal limit does. + return max_(person("capital_gains", period), -p.capital_loss_limit) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py index 969bb276c0a..5d3a4fc0d22 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py @@ -11,7 +11,9 @@ class az_property_tax_credit_dependent_income(Variable): "Income of the tax unit's dependents for the Arizona property tax credit. " "Federal AGI, which az_property_tax_credit_agi starts from, holds only the " "income of members who are not dependents; Arizona combines the income of " - "every member of the household, whether or not the member is a dependent." + "every member of the household, whether or not the member is a dependent. " + "A dependent's capital gains and losses are the dependent's Form 140PTC " + "line D amount, so a net loss counts up to the per-member limit." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(4)-(6), (I) @@ -30,8 +32,13 @@ def formula(tax_unit, period, parameters): income = 0 for source in sources: # ARS 43-1072(I): Social Security benefits are not income. - if source == "taxable_social_security": + # Capital gains and losses are added below as line D. + if source in ("taxable_social_security", "capital_gains"): continue income += max_(0, add(person, period, [source])) + # Form 140PTC line D: a dependent is a household member like any + # other, so a net capital loss counts, limited to $1,500 for the + # dependent (A.A.C. R15-2C-502(C)(3)), as it is for the claimant. + income += person("az_property_tax_credit_capital_gains", period) is_dependent = person("is_tax_unit_dependent", period) return tax_unit.sum(is_dependent * income) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py index 8f3163bfab1..dbcca77417f 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py @@ -7,6 +7,11 @@ class az_property_tax_credit_income(Variable): label = "Income for the Arizona property tax credit" unit = USD definition_period = YEAR + documentation = ( + "Household income for the Arizona property tax credit, Form 140PTC Part 1 " + "line J. It can be negative after a capital loss; the credit schedules " + "then use zero." + ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(6) "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", From 0450a28801c22e745d298ce515f21f0656be0e36 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 10:36:04 -0400 Subject: [PATCH 04/14] Cite the line J note where it appears: 2023-2025 instructions, page 5 The 2021 and 2022 instructions have no negative household income note; the floor at the schedule lookup applies in every year because the schedules start at zero. Co-Authored-By: Claude Opus 5.5 --- .../income/credits/az_property_tax_credit.yaml | 2 +- .../credits/az_property_tax_credit_income.yaml | 4 ++-- .../tests/test_az_property_tax_credit_income.py | 7 ++++--- .../property_tax_credit/az_property_tax_credit.py | 15 ++++++++++----- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml index f9be8c98981..40ef7dda72b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml @@ -145,7 +145,7 @@ output: az_property_tax_credit: 78 -# Form 140PTC line J note (2021-2025 instructions, page 4): a negative household +# Form 140PTC line J note (2023-2025 instructions, page 5): a negative household # income is entered as is, and household income is considered zero for the # Schedule 1 and 2 amounts. - name: negative household income uses the zero-income schedule amount diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index bed6e7187df..07e440d33af 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -286,8 +286,8 @@ # Line D loss limit. A.A.C. R15-2C-502(C)(3), ITR 12-1 item (7) and the line D # instructions limit each member's net loss to (1,500). Federal AGI instead # limits a return's net loss to (3,000), so household income replaces it. A -# negative line J is entered as is and treated as zero for Schedules 1 and 2 -# (2021-2025 instructions, page 4, line J note). +# negative line J is entered as is and considered zero for Schedules 1 and 2 +# (2023-2025 instructions, page 5, line J note). - name: Single filer's net loss counts only up to the 1,500 limit period: 2025 input: diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index a2b66b5e356..6678f7c1f5d 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -12,7 +12,8 @@ for every household member whether or not a dependent. Federal AGI also limits a net capital loss to $3,000 per return rather than $1,500 per member, so line D replaces its capital gains and losses. Household income (line J) can be -negative; the instructions then use zero for the Schedule 1 and 2 amounts. +negative; the 2023-2025 instructions (page 5) then consider it zero for the +Schedule 1 and 2 amounts. Invariants, checked on a seeded sample of Arizona tax units (some with a child dependent who has income) whose members' net capital gains run from well below @@ -127,8 +128,8 @@ def _sample_units(rng: np.random.Generator) -> list: ) if rng.random() < 0.15: # Little income besides a capital loss and a small tax-exempt - # pension, so line J can be negative (2021-2025 instructions, - # page 4, line J note). + # pension, so line J can be negative (2023-2025 instructions, + # page 5, line J note). for member in members: for key in [ "employment_income", diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py index 0c71a4aa8dd..6ffe7f57b75 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py @@ -7,6 +7,10 @@ class az_property_tax_credit(Variable): label = "Arizona Property Tax Credit" unit = USD definition_period = YEAR + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(B) + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=5", + ] defined_for = "az_property_tax_credit_eligible" def formula(tax_unit, period, parameters): @@ -28,11 +32,12 @@ def formula(tax_unit, period, parameters): ) # Household income (Form 140PTC line J) can be negative, for example - # after a line D capital loss. The 2021-2025 instructions (page 4, line - # J note) then treat household income as zero for the Schedule 1 and 2 - # lookup. The schedules start at 0, and a single-amount scale returns - # 0 below its first threshold, so a negative income must be floored - # here rather than passed to calc. + # after a line D capital loss. The 2023-2025 instructions (page 5, line + # J note) then consider household income to be zero for the Schedule 1 + # and 2 lookup. The 2021 and 2022 instructions have no such note; the + # schedules start at 0, so the same floor is applied in every year. A + # single-amount scale returns 0 below its first threshold, so a + # negative income must be floored here rather than passed to calc. schedule_income = max_(income, 0) cap = where( lives_with_others, From 74917947662042f5cdf8cb009c054c220b56d36e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:40:15 -0400 Subject: [PATCH 05/14] Combine members' income before any floor in Arizona property tax credit income Review r2: federal AGI deducts every member's losses, dependents' included, but az_property_tax_credit_agi floored at zero before dependents' income was added, so a loss counted only against the claimant's or spouse's own income. A claimant with no income whose dependent had $3,000 of wages and a $1,000 capital loss got household income of $3,000 instead of $2,000. az_property_tax_credit_agi is no longer floored. Form 140PTC line J can be negative, and az_property_tax_credit uses zero for the Schedule 1 and 2 lookup (the same change as in #9770, so that PR rebases cleanly). Adds regressions for a loss on the dependent, on the claimant, a rental loss and a negative line J; the property sample now includes dependents' losses, raises a dependent's gains, and states its actual domain. Notes that the federal source list is not Arizona's (unemployment by paying state, estate income, strike benefits, nontaxable alimony). Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-az-ptc-capital-gains.fixed.md | 2 +- .../credits/property_tax/income_sources.yaml | 3 +- .../income/az_property_tax_credit_agi.yaml | 6 +- .../credits/az_property_tax_credit.yaml | 17 +++ .../az_property_tax_credit_income.yaml | 130 ++++++++++++++++++ .../test_az_property_tax_credit_income.py | 59 ++++---- .../az_property_tax_credit.py | 16 ++- .../az_property_tax_credit_agi.py | 11 +- ...az_property_tax_credit_dependent_income.py | 6 + 9 files changed, 212 insertions(+), 38 deletions(-) diff --git a/changelog.d/fix-az-ptc-capital-gains.fixed.md b/changelog.d/fix-az-ptc-capital-gains.fixed.md index 29547a2d693..7f3e217a3de 100644 --- a/changelog.d/fix-az-ptc-capital-gains.fixed.md +++ b/changelog.d/fix-az-ptc-capital-gains.fixed.md @@ -1 +1 @@ -Arizona property tax credit household income counts capital gains and qualified dividends once, through federal AGI, instead of adding the federal preferential-rate amount again, and counts dependents' income, which federal AGI leaves out. +Arizona property tax credit household income counts capital gains and qualified dividends once, through federal AGI, instead of adding the federal preferential-rate amount again; counts dependents' income, which federal AGI leaves out; and is no longer floored at zero before members' income is combined (the schedules use zero for a negative total). diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml index c6e515d7d66..eba323c4803 100644 --- a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml @@ -19,7 +19,8 @@ values: # the adjusted net capital gain), which federal AGI already holds. # Federal AGI limits a net capital loss to $3,000 per return ($1,500 if # married filing separately); A.A.C. R15-2C-502(C)(3) limits it to $1,500 - # for each household member. + # for each household member. The total can be negative; + # az_property_tax_credit uses zero for the schedules. - az_property_tax_credit_agi # Federal AGI leaves out dependents' income; Arizona counts every household # member's income whether or not the member is a dependent diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml index 5016feb4b85..f4fa2f26a34 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml @@ -38,14 +38,16 @@ output: az_property_tax_credit_agi: 25_000 -- name: Capped at zero when Social Security exceeds other income +- name: Negative when losses leave other income below taxable Social Security period: 2024 input: state_code: AZ adjusted_gross_income: 5_000 tax_unit_taxable_social_security: 6_000 output: - az_property_tax_credit_agi: 0 + # $5,000 - $6,000 = -$1,000. Not floored: Form 140PTC line J can be + # negative, and az_property_tax_credit uses zero only for the schedules. + az_property_tax_credit_agi: -1_000 - name: Combined income types with Social Security exclusion period: 2024 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml index 2f4afe51114..40ef7dda72b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml @@ -144,3 +144,20 @@ households: {household: {members: [head], state_code: AZ}} output: az_property_tax_credit: 78 + +# Form 140PTC line J note (2023-2025 instructions, page 5): a negative household +# income is entered as is, and household income is considered zero for the +# Schedule 1 and 2 amounts. +- name: negative household income uses the zero-income schedule amount + period: 2025 + input: + state_code: AZ + cohabitating_spouses: false + az_property_tax_credit_income: -1_000 + az_property_tax_credit_eligible: true + real_estate_taxes: 0 + rent: 4_000 + output: + # Line 11a: Schedule 1, 0 - 1,750 -> 502. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 502 and 600. + az_property_tax_credit: 502 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 9a778e00879..c6a96c1e821 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -282,3 +282,133 @@ az_property_tax_credit_income: 3_000 # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. Line 13: 750. az_property_tax_credit: 412 + +# Losses offset other members' income. Federal AGI deducts every member's +# losses, and it is not floored at zero before dependents' income is added +# (Form 140PTC line J can be negative; A.A.C. R15-2C-502(B), (C)). +- name: A dependent's capital loss offsets the dependent's wages + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_000 + long_term_capital_gains: -1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,000. Line D: column 3 net loss (1,000). + # Line J: 2,000. + az_property_tax_credit_income: 2_000 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A dependent's capital loss with a claimant's pension + period: 2025 + input: + people: + head: + age: 70 + taxable_private_pension_income: 400 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_000 + long_term_capital_gains: -1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: 3,000. Line D: (1,000). Line E: column 1 pension 400. + # Line J: 2,400. + az_property_tax_credit_income: 2_400 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A claimant's capital loss offsets a dependent's wages + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_000 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_400 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,400. Line D: column 1 net loss (1,000). + # Line J: 2,400. + az_property_tax_credit_income: 2_400 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A dependent's rental loss offsets the dependent's wages + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_000 + rental_income: -1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,000. Line F: column 3 rent loss (1,000). + # Line J: 2,000. + az_property_tax_credit_income: 2_000 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A net loss alone leaves household income negative + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: net loss (1,000). Line J: (1,000), considered zero for the + # schedules. + az_property_tax_credit_income: -1_000 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. + az_property_tax_credit: 502 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index 25a1efc2e50..8d8b2f13ec4 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -9,17 +9,18 @@ federal taxable income taxed at the capital gains rates (at zero taxable income, the adjusted net capital gain), so gains and qualified dividends counted twice. Federal AGI leaves out dependents' income, which Arizona counts -for every household member whether or not a dependent. +for every household member whether or not a dependent. Federal AGI deducts +every member's losses, so it is not floored before dependents' income is added. Invariants, checked on a seeded sample of Arizona tax units (some with a child -dependent who has income) whose per-member net capital gain is at least -$1,500 -(where the federal and Arizona loss rules agree), whose dependents have no net -capital loss, and whose income other than Social Security is not negative: +dependent who has income). The sample stays where the federal and Arizona +capital loss rules agree: each member's net capital gain is at least -$1,500 +and a tax unit's net losses total at most $3,000, the federal limit per return. 1. Differential: the model equals an independent line A + B + D + E sum over every member from Form 140PTC Part 1, with Social Security excluded. -2. Counted once: adding d to a member's long-term gains raises household - income by exactly d. +2. Counted once: adding d to one member's long-term gains (the dependent's + where there is one, else the head's) raises household income by exactly d. 3. The income does not depend on capital_gains_excluded_from_taxable_income. 4. The credit never rises when gains rise. """ @@ -32,9 +33,11 @@ N = 60 SEED = 43_1072 # A.A.C. R15-2C-502(C)(3): net capital losses are limited to $1,500 for each -# household member. The sample stays at or above it, where federal AGI's -# $3,000-per-return limit gives the same answer. +# household member. The sample stays at or above it, and keeps each tax unit's +# net losses within federal AGI's $3,000-per-return limit, so both rules give +# the same answer. MEMBER_LOSS_LIMIT = 1_500 +RETURN_LOSS_LIMIT = 3_000 def _sample_units(rng: np.random.Generator) -> list: @@ -74,8 +77,9 @@ def _sample_units(rng: np.random.Generator) -> list: } ) if rng.random() < 0.35: - # A child dependent with income of their own. Dependents' net - # capital losses are not modeled, so their gains stay at or above 0. + # A child dependent with income of their own, sometimes with a net + # capital loss. A couple can already reach the federal limit, so + # only a single head's dependent gets a loss. members.append( { "role": "dependent", @@ -87,7 +91,9 @@ def _sample_units(rng: np.random.Generator) -> list: "taxable_interest_income": round(rng.uniform(0, 300), 2), "tax_exempt_interest_income": round(rng.uniform(0, 200), 2), "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), - "long_term_capital_gains": round(rng.uniform(0, 4_000), 2), + "long_term_capital_gains": round( + rng.uniform(0 if married else -1_500, 4_000), 2 + ), "short_term_capital_gains": round(rng.uniform(0, 1_000), 2), "taxable_private_pension_income": 0.0, "tax_exempt_public_pension_income": 0.0, @@ -101,6 +107,11 @@ def _sample_units(rng: np.random.Generator) -> list: member["long_term_capital_gains"] + member["short_term_capital_gains"] ) assert net_gain >= -MEMBER_LOSS_LIMIT + net_losses = sum( + max(0, -(m["long_term_capital_gains"] + m["short_term_capital_gains"])) + for m in members + ) + assert net_losses <= RETURN_LOSS_LIMIT units.append( { "members": members, @@ -111,6 +122,11 @@ def _sample_units(rng: np.random.Generator) -> list: return units +def _raised_role(unit: dict) -> str: + roles = [member["role"] for member in unit["members"]] + return "dependent" if "dependent" in roles else "head" + + def _situation(units: list, raise_gains: bool) -> dict: people, tax_units, households = {}, {}, {} year = str(YEAR) @@ -122,7 +138,7 @@ def _situation(units: list, raise_gains: bool) -> dict: person = { key: {year: value} for key, value in member.items() if key != "role" } - if raise_gains and member["role"] == "head": + if raise_gains and member["role"] == _raised_role(unit): person["long_term_capital_gains"] = { year: member["long_term_capital_gains"] + unit["gain_increase"] } @@ -164,24 +180,7 @@ def _form_140ptc_line_j(units: list) -> np.ndarray: return np.array(totals) -def _units_with_nonnegative_income() -> list: - rng = np.random.default_rng(SEED) - units = _sample_units(rng) - # Keep each unit's income other than Social Security at or above zero, so - # the reference sum is not clipped (az_property_tax_credit_agi floors - # federal AGI less taxable Social Security at zero). - for unit, line_j in zip(units, _form_140ptc_line_j(units)): - exempt = sum( - m["tax_exempt_interest_income"] + m["tax_exempt_public_pension_income"] - for m in unit["members"] - ) - shortfall = exempt - line_j - if shortfall > 0: - unit["members"][0]["employment_income"] += round(shortfall, 2) + 1 - return units - - -UNITS = _units_with_nonnegative_income() +UNITS = _sample_units(np.random.default_rng(SEED)) def test_household_income_matches_form_140ptc_lines(): diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py index da5ead3a06e..6ffe7f57b75 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py @@ -7,6 +7,10 @@ class az_property_tax_credit(Variable): label = "Arizona Property Tax Credit" unit = USD definition_period = YEAR + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(B) + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=5", + ] defined_for = "az_property_tax_credit_eligible" def formula(tax_unit, period, parameters): @@ -27,10 +31,18 @@ def formula(tax_unit, period, parameters): | (household_size > 1) ) + # Household income (Form 140PTC line J) can be negative, for example + # after a line D capital loss. The 2023-2025 instructions (page 5, line + # J note) then consider household income to be zero for the Schedule 1 + # and 2 lookup. The 2021 and 2022 instructions have no such note; the + # schedules start at 0, so the same floor is applied in every year. A + # single-amount scale returns 0 below its first threshold, so a + # negative income must be floored here rather than passed to calc. + schedule_income = max_(income, 0) cap = where( lives_with_others, - p.amount.cohabitating.calc(income), - p.amount.living_alone.calc(income), + p.amount.cohabitating.calc(schedule_income), + p.amount.living_alone.calc(schedule_income), ) # ARS 43-1072(B): the credit is the lesser of the table amount and diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py index 90e6818719f..fac61ac19e6 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py @@ -13,7 +13,9 @@ class az_property_tax_credit_agi(Variable): "excludes items specifically listed in ARS 43-1072(I): Social Security, " "railroad retirement, workers compensation, Arizona unemployment, veterans " "disability pensions, welfare, and gifts. Unlike regular Arizona income tax, " - "this does NOT exclude pension income, capital gains, or Arizona exemptions." + "this does NOT exclude pension income, capital gains, or Arizona exemptions. " + "Covers members who are not dependents, as federal AGI does, and can be " + "negative." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072 @@ -48,4 +50,9 @@ def formula(tax_unit, period, parameters): # - US Government interest should be INCLUDED per ITR 12-1 item (2) # - Arizona exemptions (aged, blind) should NOT be subtracted - return max_(0, federal_agi - taxable_social_security) + # Not floored at zero: household income (Form 140PTC line J) can be + # negative, and az_property_tax_credit treats it as zero only for the + # Schedule 1 and 2 lookup. Federal AGI deducts every member's losses, + # dependents' included, so a floor here would drop losses that + # az_property_tax_credit_dependent_income's positive amounts offset. + return federal_agi - taxable_social_security diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py index 969bb276c0a..2c0a5892635 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py @@ -26,6 +26,12 @@ def formula(tax_unit, period, parameters): # separately determined income of each member, "whether or not the # person is related to, or a dependent of, the claimant". irs_gross_income # counts the same federal sources for members who are not dependents. + # Only positive amounts are added: federal AGI (loss_ald) already + # deducts every member's losses, dependents' included. The federal + # list is not Arizona's: like federal AGI for the other members, it + # counts unemployment whichever state paid it (ARS 43-1072(I) excludes + # Arizona's) and leaves out estate income, strike benefits and + # alimony that is not federally taxable. sources = parameters(period).gov.irs.gross_income.sources income = 0 for source in sources: From 7d69e827fb7144e6666060d86d14ee1f44d3cbc0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:50:26 -0400 Subject: [PATCH 06/14] Swap only the capital amounts federal AGI holds for Arizona line D Review r1 of #9770 found that az_property_tax_credit_agi added back limited_capital_loss even when federal AGI never deducted it (a reform that drops loss_ald from the above-the-line list, or loss_ald set to zero), manufacturing income. The swap now takes out only the positive capital gains irs_gross_income puts in AGI (per gross income source list) and the capital loss deduction loss_ald actually contains, then adds each non-dependent's line D. Gains a reform leaves out of AGI still count, as ARS 43-1072(H)(6)(b) adds them back. Line D now combines capital gain distributions reported without Schedule D with the member's other gains and losses before the $1,500 limit: they are long-term capital gains (26 U.S.C. 852(b)(3)(B)), and R15-2C-502(C)(3) nets each member's capital gains and losses. The dependent loop skips them so they count once. Tests: YAML cases for the two reforms, a zero loss_ald input, distributions for a claimant and a dependent, the three r3 finding-1 households (losses beyond $1,500 with tax-exempt income), and r3's estate/rental case, now right on main since #9633. Property sample adds distributions and signed rental income (dependents too) to the Form 140PTC oracle, uses only non-dependents' losses in the federal coverage check (#9647), and compares the preferential-rate mutation with an unmodified run (r1 nit). Corrects the spouse-netting comment (r1 nit). Co-Authored-By: Claude Opus 5.5 --- changelog.d/az-ptc-line-d-loss-limit.fixed.md | 2 +- .../az_property_tax_credit_income.yaml | 250 +++++++++++++++++- .../test_az_property_tax_credit_income.py | 82 ++++-- .../az_property_tax_credit_agi.py | 52 ++-- .../az_property_tax_credit_capital_gains.py | 14 +- ...az_property_tax_credit_dependent_income.py | 10 +- 6 files changed, 366 insertions(+), 44 deletions(-) diff --git a/changelog.d/az-ptc-line-d-loss-limit.fixed.md b/changelog.d/az-ptc-line-d-loss-limit.fixed.md index 0a254ec33c0..0b51b5b3d03 100644 --- a/changelog.d/az-ptc-line-d-loss-limit.fixed.md +++ b/changelog.d/az-ptc-line-d-loss-limit.fixed.md @@ -1 +1 @@ -Limit each Arizona household member's net capital loss to $1,500 in property tax credit household income (Form 140PTC line D), and let household income go negative, using zero only for the credit schedules (line J). +Limit each Arizona household member's net capital loss, capital gain distributions included, to $1,500 in property tax credit household income (Form 140PTC line D), in place of federal AGI's $3,000 per-return limit; count dependents' rental, business and other losses, which federal AGI leaves out; and let household income go negative, using zero only for the credit schedules (line J). diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 6dcaa2df9a4..122ce76c240 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -495,7 +495,8 @@ # Line A: wages 3,000 (column 1). # Line D: (4,000) limited to (1,500) (column 1) + 2,000 (column 2) = 500. # Line J: 3,000 + 500 = 3,500. - # Federal AGI would net the return first: 3,000 + 2,000 - 3,000 = 2,000. + # The federal model's AGI is 3,000 + 2,000 - 3,000 = 2,000 (netting the + # return's gains and losses first would give 1,000); neither is line D. az_property_tax_credit_income: 3_500 # Line 11b: Schedule 2, 3,401 - 3,550 -> 345. # Line 13: 750. Line 15: smaller of 345 and 750. @@ -588,3 +589,250 @@ # Line 11b: Schedule 2, 0 - 2,500 -> 502. # Line 13: 750. Line 15: smaller of 502 and 750. az_property_tax_credit: 502 + +# Losses beyond the member limit with tax-exempt income. Federal AGI deducts +# up to 3,000 of capital loss, which would offset the tax-exempt interest and +# pensions household income adds; line D allows (1,500). +- name: A loss beyond the limit does not offset tax-exempt interest + period: 2025 + input: + people: + head: + age: 70 + tax_exempt_interest_income: 5_500 + long_term_capital_gains: -3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line B: tax-exempt interest 5,500. + # Line D: net loss (3,000), limited to (1,500). + # Line J: 5,500 - 1,500 = 4,000. Federal AGI would give 5,500 - 3,000. + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +- name: A loss beyond the limit with taxable and tax-exempt pensions + period: 2025 + input: + people: + head: + age: 70 + taxable_private_pension_income: 1_500 + tax_exempt_public_pension_income: 3_000 + long_term_capital_gains: -3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: net loss (3,000), limited to (1,500). + # Line E: pensions 1,500 + 3,000 = 4,500. + # Line J: 4,500 - 1,500 = 3,000. + az_property_tax_credit_agi: 0 + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. + # Line 13: 750. Line 15: smaller of 212 and 750. + az_property_tax_credit: 212 + +- name: A dependent's loss beyond the limit does not offset the claimant's interest + period: 2025 + input: + people: + head: + age: 70 + tax_exempt_interest_income: 7_100 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + long_term_capital_gains: -3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line B: column 1 tax-exempt interest 7,100. + # Line D: column 3 net loss (3,000), limited to (1,500). + # Line J: 7,100 - 1,500 = 5,600. + az_property_tax_credit_dependent_income: -1_500 + az_property_tax_credit_income: 5_600 + # Line 11b: Schedule 2 pays nothing at 5,501 or more. + az_property_tax_credit: 0 + +# Capital gain distributions are long-term capital gains (26 U.S.C. +# 852(b)(3)(B)), so they combine with the member's other gains and losses on +# line D before the (1,500) limit. +- name: Capital gain distributions net against the member's capital loss + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + non_sch_d_capital_gains: 1_000 + long_term_capital_gains: -4_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 4,000. + # Line D: distributions 1,000 + net loss (4,000) = (3,000), limited to + # (1,500). + # Line J: 4,000 - 1,500 = 2,500. Federal AGI is 4,000 + 1,000 - 3,000. + adjusted_gross_income: 2_000 + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. + # Line 13: 750. Line 15: smaller of 323 and 750. + az_property_tax_credit: 323 + +- name: A dependent's capital gain distributions count once on line D + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_400 + non_sch_d_capital_gains: 1_000 + long_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,400. + # Line D: column 3 distributions 1,000 + net loss (4,000) = (3,000), + # limited to (1,500). + # Line J: 3,400 - 1,500 = 1,900. + az_property_tax_credit_dependent_income: 1_900 + az_property_tax_credit_income: 1_900 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +# Line G: estate and trust income counts, and line F nets a rental loss. +- name: Estate income and a rental loss with tax-exempt interest + period: 2025 + input: + people: + head: + age: 70 + estate_income: 2_000 + rental_income: -1_000 + tax_exempt_interest_income: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line B: tax-exempt interest 3,000. Line F: rent loss (1,000). + # Line G: estate income 2,000. + # Line J: 3,000 - 1,000 + 2,000 = 4,000. + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +# Federal AGI's capital amounts are swapped for line D only where AGI holds +# them, so a reform that changes federal AGI's capital treatment leaves line D +# whole. +- name: Line D is unchanged when federal AGI takes no capital loss deduction + period: 2025 + input: + # The 2025 above-the-line deductions without loss_ald. + gov.irs.ald.deductions: [self_employment_tax_ald, student_loan_interest_ald, early_withdrawal_penalty, alimony_expense_ald, educator_expense, health_savings_account_ald, self_employed_health_insurance_ald, self_employed_pension_contribution_ald, traditional_ira_contributions, qualified_adoption_assistance_expense, us_bonds_for_higher_ed, specified_possession_income, puerto_rico_income] + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: wages 4,000, with no capital loss deducted. + adjusted_gross_income: 4_000 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line J: 2,500. + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. Line 13: 750. + az_property_tax_credit: 323 + +- name: Line D is unchanged when the federal loss deduction is zero + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + loss_ald: 0 + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 4_000 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line J: 2,500. + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. Line 13: 750. + az_property_tax_credit: 323 + +- name: Gains left out of federal gross income still count on line D + period: 2025 + input: + # ARS 43-1072(H)(6)(b) adds capital gains excluded from adjusted gross + # income. The 2025 gross income sources without capital_gains. + gov.irs.gross_income.sources: [irs_employment_income, self_employment_income, sstb_self_employment_income, partnership_s_corp_income, farm_operations_income, farm_rent_income, other_net_gain_gross_income, non_sch_d_capital_gains, taxable_interest_income, rental_income, dividend_income, taxable_pension_income, debt_relief, estate_income, taxable_unemployment_compensation, taxable_social_security, illicit_income, taxable_retirement_distributions, taxable_roth_conversions, miscellaneous_income, ak_permanent_fund_dividend, taxable_alimony_income, salt_refund_income] + people: + head: + age: 70 + long_term_capital_gains: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 0 + # Line D: gain 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index 6678f7c1f5d..a179dc80383 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -19,9 +19,10 @@ dependent who has income) whose members' net capital gains run from well below -$1,500 to large gains, including units whose line J is negative: -1. Differential: the model equals an independent line A + B + D + E sum over - every member from Form 140PTC Part 1, with Social Security excluded and - each member's line D floored at -$1,500. +1. Differential: the model equals an independent line A + B + D + E + F sum + over every member from Form 140PTC Part 1, with Social Security excluded, + rental losses netted (A.A.C. R15-2C-502(C)(2)) and each member's line D, + capital gain distributions included, floored at -$1,500. 2. Counted once: adding d to a member's long-term gains changes household income by exactly the change in that member's line D, which is d when the member's net gain stays at or above -$1,500. The credit never rises. @@ -72,6 +73,16 @@ def _capital_gains(rng: np.random.Generator, can_be_large: bool) -> tuple: ) +def _distributions(rng: np.random.Generator) -> float: + """Capital gain distributions reported without Schedule D, for some members.""" + return round(rng.uniform(0, 800), 2) if rng.random() < 0.25 else 0.0 + + +def _rental_income(rng: np.random.Generator) -> float: + """Net rent and royalty income or loss (Form 140PTC line F), for some members.""" + return round(rng.uniform(-3_000, 3_000), 2) if rng.random() < 0.2 else 0.0 + + def _sample_units(rng: np.random.Generator) -> list: units = [] for _ in range(N): @@ -91,6 +102,8 @@ def _sample_units(rng: np.random.Generator) -> list: "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), "long_term_capital_gains": long_term, "short_term_capital_gains": short_term, + "non_sch_d_capital_gains": _distributions(rng), + "rental_income": _rental_income(rng), "taxable_private_pension_income": float( rng.choice([0, round(rng.uniform(0, 3_000), 2)]) ), @@ -119,6 +132,8 @@ def _sample_units(rng: np.random.Generator) -> list: "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), "long_term_capital_gains": long_term, "short_term_capital_gains": short_term, + "non_sch_d_capital_gains": _distributions(rng), + "rental_income": _rental_income(rng), "taxable_private_pension_income": 0.0, "tax_exempt_public_pension_income": 0.0, "social_security_survivors": float( @@ -135,6 +150,8 @@ def _sample_units(rng: np.random.Generator) -> list: "employment_income", "taxable_interest_income", "qualified_dividend_income", + "non_sch_d_capital_gains", + "rental_income", "taxable_private_pension_income", ]: member[key] = 0.0 @@ -189,7 +206,13 @@ def _situation(units: list) -> dict: def _net_gain(member: dict) -> float: - return member["long_term_capital_gains"] + member["short_term_capital_gains"] + # Capital gain distributions are long-term capital gains (26 U.S.C. + # 852(b)(3)(B)), combined with the member's other gains and losses. + return ( + member["long_term_capital_gains"] + + member["short_term_capital_gains"] + + member["non_sch_d_capital_gains"] + ) def _line_d(member: dict) -> float: @@ -213,9 +236,11 @@ def _form_140ptc_line_j(units: list) -> np.ndarray: m["taxable_private_pension_income"] + m["tax_exempt_public_pension_income"] ) + # A rental loss counts in full (A.A.C. R15-2C-502(C)(2)). + line_f = m["rental_income"] # Social Security benefits are not income for the credit. Every # member counts, dependent or not (A.A.C. R15-2C-502(A)(2), (B)). - total += line_a + line_b + _line_d(m) + line_e + total += line_a + line_b + _line_d(m) + line_e + line_f totals.append(total) return np.array(totals) @@ -231,22 +256,35 @@ def test_sample_covers_the_loss_limit_and_negative_line_j(): beyond_limit = [m for m in members if _net_gain(m) < -MEMBER_LOSS_LIMIT] assert len(beyond_limit) >= 20 assert any(m["role"] == "dependent" for m in beyond_limit) - # Units where federal AGI's capital gains and losses (non-dependents' net - # gains, less losses limited to $3,000 per return) differ from line D. - federal = np.array( - [ - sum( - max(0, _net_gain(m)) - for m in unit["members"] - if m["role"] != "dependent" - ) - - min(3_000, sum(max(0, -_net_gain(m)) for m in unit["members"])) - for unit in UNITS - ] - ) + + # Units where federal AGI's capital gains and losses (non-dependents' + # positive gains and distributions, less their losses limited to $3,000 + # per return) differ from every member's line D. + def federal_capital(unit: dict) -> float: + filers = [m for m in unit["members"] if m["role"] != "dependent"] + gains = sum( + max(0, m["long_term_capital_gains"] + m["short_term_capital_gains"]) + + m["non_sch_d_capital_gains"] + for m in filers + ) + losses = sum( + max(0, -(m["long_term_capital_gains"] + m["short_term_capital_gains"])) + for m in filers + ) + return gains - min(3_000, losses) + + federal = np.array([federal_capital(unit) for unit in UNITS]) line_d = np.array([sum(_line_d(m) for m in unit["members"]) for unit in UNITS]) assert (np.abs(federal - line_d) > 0.01).sum() >= 20 assert (LINE_J < 0).sum() >= 3 + # Distributions alongside a net capital loss, and rental losses, including + # dependents'. + assert any( + m["non_sch_d_capital_gains"] > 0 + and m["long_term_capital_gains"] + m["short_term_capital_gains"] < 0 + for m in members + ) + assert any(m["rental_income"] < 0 for m in members if m["role"] == "dependent") def test_household_income_matches_form_140ptc_lines(): @@ -327,6 +365,7 @@ def test_credit_uses_zero_for_negative_household_income(): def test_income_does_not_read_preferential_rate_amount(): + base = Simulation(situation=_situation(UNITS)) sim = Simulation(situation=_situation(UNITS)) rng = np.random.default_rng(SEED + 1) sim.set_input( @@ -334,5 +373,8 @@ def test_income_does_not_read_preferential_rate_amount(): YEAR, rng.uniform(0, 50_000, len(UNITS)), ) - income = sim.calculate("az_property_tax_credit_income", YEAR) - np.testing.assert_allclose(income, LINE_J, atol=0.01) + np.testing.assert_allclose( + sim.calculate("az_property_tax_credit_income", YEAR), + base.calculate("az_property_tax_credit_income", YEAR), + atol=0.01, + ) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py index 5a637fde24a..13174058394 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py @@ -16,9 +16,10 @@ class az_property_tax_credit_agi(Variable): "unemployment is not removed. Unlike regular Arizona income tax, this does " "NOT exclude pension income, capital gains, or Arizona exemptions. " "Capital gains and losses are each member's Form 140PTC line D amount, with " - "a net loss limited per member, instead of federal AGI's net capital gain " - "less a capital loss limited per return. Covers members who are not " - "dependents, as federal AGI does, and can be negative." + "a net loss limited per member, in place of the capital gains and capital " + "loss deduction that federal AGI holds. Dependents' income, including their " + "line D amounts, is in az_property_tax_credit_dependent_income. Can be " + "negative." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072 @@ -55,22 +56,39 @@ def formula(tax_unit, period, parameters): # - US Government interest should be INCLUDED per ITR 12-1 item (2) # - Arizona exemptions (aged, blind) should NOT be subtracted - # Capital gains and losses (Form 140PTC Part 1 line D). Federal AGI - # adds each non-dependent's net capital gain (irs_gross_income) and - # subtracts the capital loss deduction, limited to $3,000 per return - # (limited_capital_loss, part of loss_ald). A.A.C. R15-2C-502(C)(3) - # and ITR 12-1 item (7) instead limit each member's net loss to - # $1,500, so the federal amounts are replaced by each member's line D - # amount. Dependents' line D amounts are in - # az_property_tax_credit_dependent_income, with their other income. + # Capital gains and losses (Form 140PTC Part 1 line D). A.A.C. + # R15-2C-502(C)(3) and ITR 12-1 item (7) count each member's net + # capital gain or loss, with a net loss limited to $1,500 for each + # member. Federal AGI instead adds each non-dependent's positive + # capital gains (irs_gross_income) and subtracts a capital loss + # deduction limited per return (limited_capital_loss, within + # loss_ald). Those federal amounts are taken out and each + # non-dependent's line D amount is put in. Only what federal AGI + # actually holds is taken out: a reform that drops a source from + # gross income or the loss deduction from the above-the-line list + # leaves nothing to reverse. Line D still counts gains left out of + # federal AGI, as ARS 43-1072(H)(6)(b) adds them back. + p = parameters(period).gov.irs person = tax_unit.members - is_dependent = person("is_tax_unit_dependent", period) - federal_capital_gains = tax_unit.sum( - ~is_dependent * max_(0, person("capital_gains", period)) - ) - federal_capital_loss = tax_unit("limited_capital_loss", period) + not_dependent = ~person("is_tax_unit_dependent", period) + gross_income_sources = p.gross_income.sources + capital_gains_in_agi = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in gross_income_sources: + capital_gains_in_agi += max_(0, person(source, period)) + federal_capital_gains = tax_unit.sum(not_dependent * capital_gains_in_agi) + if "loss_ald" in p.ald.deductions: + # loss_ald is the business loss allowed under section 461(l) plus + # limited_capital_loss, so it covers the capital part unless + # loss_ald itself is replaced. + federal_capital_loss = min_( + tax_unit("limited_capital_loss", period), + tax_unit("loss_ald", period), + ) + else: + federal_capital_loss = 0 line_d = tax_unit.sum( - ~is_dependent * person("az_property_tax_credit_capital_gains", period) + not_dependent * person("az_property_tax_credit_capital_gains", period) ) # Not floored at zero: household income (Form 140PTC line J) can be diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py index b5e8ceb9dce..08aaa68430f 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py @@ -10,13 +10,15 @@ class az_property_tax_credit_capital_gains(Variable): documentation = ( "A household member's gains and losses from the sale or exchange of " "property for the Arizona property tax credit (Form 140PTC Part 1 line D): " - "the year's gains and losses combined, with a net loss counted only up to " - "the per-member limit. No prior-year capital loss carryover is used." + "the year's gains and losses combined, including capital gain " + "distributions, with a net loss counted only up to the per-member limit. " + "No prior-year capital loss carryover is used." ) reference = [ "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2", "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + "https://www.law.cornell.edu/uscode/text/26/852#b_3_B", ] defined_for = StateCode.AZ @@ -26,4 +28,10 @@ def formula(person, period, parameters): # gains and losses for each member of the household. Net losses are # limited to $1,500 for each household member." The limit applies to # each member separately, not to the return as the federal limit does. - return max_(person("capital_gains", period), -p.capital_loss_limit) + # Capital gain distributions reported without Schedule D are long-term + # capital gains (26 U.S.C. 852(b)(3)(B)), so they are combined with the + # member's other gains and losses before the limit. + net_capital_gain = add( + person, period, ["capital_gains", "non_sch_d_capital_gains"] + ) + return max_(net_capital_gain, -p.capital_loss_limit) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py index e39f6ce124f..45b20a29896 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py @@ -19,6 +19,7 @@ class az_property_tax_credit_dependent_income(Variable): reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(4)-(6), (I) "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2", "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", ] defined_for = StateCode.AZ @@ -42,8 +43,13 @@ def formula(tax_unit, period, parameters): income = 0 for source in sources: # ARS 43-1072(I): Social Security benefits are not income. - # Capital gains and losses are added below as line D. - if source in ("taxable_social_security", "capital_gains"): + # Capital gains and losses, including capital gain distributions, + # are added below as line D. + if source in ( + "taxable_social_security", + "capital_gains", + "non_sch_d_capital_gains", + ): continue income += add(person, period, [source]) # Form 140PTC line D: a dependent is a household member like any From 8d162893c7e22de8e4ebe9fd539d0e3f060b138a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 03:18:28 -0400 Subject: [PATCH 07/14] Add back only loss_ald's capital part in Arizona household income Review r1 of the fold (GPT-6.1 Sol, 21cb1892) found that min(limited_capital_loss, loss_ald) cannot tell loss_ald's capital part from its business part: a reform or supplied loss_ald that holds only a rental loss was reversed as a capital deduction ($2,500 / $323 instead of the form's $1,500 / $502). loss_ald's section 461(l) business part moves, unchanged, into a new limited_business_loss variable, and loss_ald adds limited_business_loss and limited_capital_loss (identical federal results). Arizona now adds back loss_ald less limited_business_loss, clipped to [0, limited_capital_loss]. Tests: a loss_ald case pins both parts; Arizona cases for a rental and capital loss together and for a supplied rental-only loss_ald; a pytest with a Reform whose loss_ald holds only rental losses. Both Arizona tests fail on 21cb1892's formula. Co-Authored-By: Claude Opus 5.5 --- .../above_the_line_deductions/loss_ald.yaml | 2 + .../az_property_tax_credit_income.yaml | 53 ++++++++++ .../test_az_property_tax_credit_income.py | 55 +++++++++++ .../limited_business_loss.py | 96 +++++++++++++++++++ .../above_the_line_deductions/loss_ald.py | 84 +--------------- .../az_property_tax_credit_agi.py | 13 ++- 6 files changed, 218 insertions(+), 85 deletions(-) create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml index 3275b8fc174..878c3c7267e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml @@ -40,6 +40,8 @@ # Total loss_ald: 610,000 + 3,000 = 613,000 # Gross income: 82,813 + 20 (interest) = 82,833 # AGI: 82,833 - 613,000 = -530,167 + limited_business_loss: 610_000 + limited_capital_loss: 3_000 loss_ald: 613_000 adjusted_gross_income: -530_167 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 122ce76c240..520e02f71f4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -836,3 +836,56 @@ az_property_tax_credit_income: 3_000 # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. az_property_tax_credit: 212 + +- name: A rental loss and a capital loss beyond the limit together + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rental_income: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: 4,000 - rental loss 1,000 - capital loss 3,000 = 0. + adjusted_gross_income: 0 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line F: (1,000). + # Line J: 4,000 - 1,500 - 1,000 = 1,500. + az_property_tax_credit_income: 1_500 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A supplied loss deduction holding only the rental loss is not read as a capital deduction + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rental_income: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + # Federal AGI takes the rental loss only. + loss_ald: 1_000 + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 3_000 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line F: (1,000). + # Line J: 1,500, as when federal AGI takes both losses. + az_property_tax_credit_income: 1_500 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. + az_property_tax_credit: 502 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index a179dc80383..f2994107a1f 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -378,3 +378,58 @@ def test_income_does_not_read_preferential_rate_amount(): base.calculate("az_property_tax_credit_income", YEAR), atol=0.01, ) + + +def test_line_d_survives_a_loss_deduction_without_capital_losses(): + """A reform whose loss_ald holds only rental losses leaves line J whole. + + az_property_tax_credit_agi adds back only the capital part of loss_ald + (loss_ald less limited_business_loss), so a federal loss deduction with no + capital loss in it is not reversed as one. + """ + from policyengine_core.reforms import Reform + from policyengine_us.model_api import YEAR as ANNUAL + from policyengine_us.model_api import TaxUnit, USD, Variable, max_ + + class rental_losses_only(Reform): + def apply(self): + class loss_ald(Variable): + value_type = float + entity = TaxUnit + label = "Rental losses only" + unit = USD + definition_period = ANNUAL + + def formula(tax_unit, period, parameters): + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + rental_loss = max_(0, -person("rental_income", period)) + return tax_unit.sum(not_dependent * rental_loss) + + self.update_variable(loss_ald) + + year = str(YEAR) + situation = { + "people": { + "head": { + "age": {year: 70}, + "employment_income": {year: 4_000}, + "long_term_capital_gains": {year: -5_000}, + "rental_income": {year: -1_000}, + "rent": {year: 5_000}, + } + }, + "tax_units": {"tax_unit": {"members": ["head"]}}, + "households": {"household": {"members": ["head"], "state_code": {year: "AZ"}}}, + } + sim = Simulation(situation=situation, reform=rental_losses_only) + # Federal AGI takes the rental loss only: 4,000 - 1,000. + assert sim.calculate("adjusted_gross_income", YEAR)[0] == 3_000 + # Form 140PTC: line A 4,000 + line D (1,500) + line F (1,000) = 1,500. + np.testing.assert_allclose( + sim.calculate("az_property_tax_credit_income", YEAR), [1_500], atol=0.01 + ) + # Schedule 1, 0 - 1,750 -> 502; property taxes 0.15 x 5,000 = 750. + np.testing.assert_allclose( + sim.calculate("az_property_tax_credit", YEAR), [502], atol=0.01 + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py new file mode 100644 index 00000000000..694638a0e07 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py @@ -0,0 +1,96 @@ +from policyengine_us.model_api import * + + +class limited_business_loss(Variable): + value_type = float + entity = TaxUnit + label = "Business loss deduction allowed under section 461(l)" + unit = USD + documentation = ( + "Business, farm, rental, estate and partnership/S corporation losses of " + "members who are not dependents, deducted above the line up to the " + "section 461(l) limit: business income plus the threshold amount. " + "loss_ald is this plus limited_capital_loss." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/165", + "https://www.law.cornell.edu/uscode/text/26/461#l", + ) + + def formula(tax_unit, period, parameters): + filing_status = tax_unit("filing_status", period) + # Section 461(l) excess business loss limitation + threshold_amount = parameters(period).gov.irs.ald.loss.max[filing_status] + person = tax_unit.members + # A tax-unit dependent's business, rental and estate items belong on + # the dependent's own return, as in irs_gross_income. They neither + # add to this return's losses nor raise its Section 461(l) limit. + not_dependent = ~person("is_tax_unit_dependent", period) + + # Section 461(l) compares business deductions to business gross + # income/gains plus the threshold amount. In this model, positive + # business amounts flow into gross income and losses are deducted here, + # so allowable business losses are capped at business income plus + # the threshold. + indiv_se_income = max_(0, person("total_self_employment_income", period)) + indiv_se_loss = max_(0, -person("total_self_employment_income", period)) + self_employment_income = tax_unit.sum(not_dependent * indiv_se_income) + self_employment_loss = tax_unit.sum(not_dependent * indiv_se_loss) + + # Schedule F farm business income/losses. + indiv_farm_operations_income = max_(0, person("farm_operations_income", period)) + indiv_farm_loss = max_(0, -person("farm_operations_income", period)) + farm_operations_income = tax_unit.sum( + not_dependent * indiv_farm_operations_income + ) + farm_loss = tax_unit.sum(not_dependent * indiv_farm_loss) + + # Schedule E rental, farm-rent, and estate/trust items. + indiv_rental_income = max_(0, person("rental_income", period)) + indiv_rental_loss = max_(0, -person("rental_income", period)) + rental_income = tax_unit.sum(not_dependent * indiv_rental_income) + rental_loss = tax_unit.sum(not_dependent * indiv_rental_loss) + + indiv_farm_rent_income = max_(0, person("farm_rent_income", period)) + indiv_farm_rent_loss = max_(0, -person("farm_rent_income", period)) + farm_rent_income = tax_unit.sum(not_dependent * indiv_farm_rent_income) + farm_rent_loss = tax_unit.sum(not_dependent * indiv_farm_rent_loss) + + indiv_estate_income = max_(0, person("estate_income", period)) + indiv_estate_loss = max_(0, -person("estate_income", period)) + estate_income = tax_unit.sum(not_dependent * indiv_estate_income) + estate_loss = tax_unit.sum(not_dependent * indiv_estate_loss) + + # Partnership/S-corp losses (Schedule E) + indiv_scorp_income = max_(0, person("partnership_s_corp_income", period)) + indiv_scorp_loss = max_(0, -person("partnership_s_corp_income", period)) + partnership_s_corp_income = tax_unit.sum(not_dependent * indiv_scorp_income) + partnership_s_corp_loss = tax_unit.sum(not_dependent * indiv_scorp_loss) + + other_net_gain = tax_unit("other_net_gain", period) + other_business_gain = max_(0, other_net_gain) + other_business_loss = max_(0, -other_net_gain) + + # Total business losses subject to Section 461(l) limitation + total_business_income = ( + self_employment_income + + farm_operations_income + + rental_income + + farm_rent_income + + estate_income + + partnership_s_corp_income + + other_business_gain + ) + total_business_loss = ( + self_employment_loss + + farm_loss + + rental_loss + + farm_rent_loss + + estate_loss + + partnership_s_corp_loss + + other_business_loss + ) + max_deductible_business_loss = total_business_income + threshold_amount + limited_business_loss = min_(total_business_loss, max_deductible_business_loss) + return limited_business_loss diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py index 8eedf2bcb62..9decc261ad6 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py @@ -13,85 +13,9 @@ class loss_ald(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/26/165", "https://www.law.cornell.edu/uscode/text/26/461#l", + "https://www.law.cornell.edu/uscode/text/26/1211", ) - def formula(tax_unit, period, parameters): - filing_status = tax_unit("filing_status", period) - # Section 461(l) excess business loss limitation - threshold_amount = parameters(period).gov.irs.ald.loss.max[filing_status] - person = tax_unit.members - # A tax-unit dependent's business, rental and estate items belong on - # the dependent's own return, as in irs_gross_income. They neither - # add to this return's losses nor raise its Section 461(l) limit. - not_dependent = ~person("is_tax_unit_dependent", period) - - # Section 461(l) compares business deductions to business gross - # income/gains plus the threshold amount. In this model, positive - # business amounts flow into gross income and losses are deducted here, - # so allowable business losses are capped at business income plus - # the threshold. - indiv_se_income = max_(0, person("total_self_employment_income", period)) - indiv_se_loss = max_(0, -person("total_self_employment_income", period)) - self_employment_income = tax_unit.sum(not_dependent * indiv_se_income) - self_employment_loss = tax_unit.sum(not_dependent * indiv_se_loss) - - # Schedule F farm business income/losses. - indiv_farm_operations_income = max_(0, person("farm_operations_income", period)) - indiv_farm_loss = max_(0, -person("farm_operations_income", period)) - farm_operations_income = tax_unit.sum( - not_dependent * indiv_farm_operations_income - ) - farm_loss = tax_unit.sum(not_dependent * indiv_farm_loss) - - # Schedule E rental, farm-rent, and estate/trust items. - indiv_rental_income = max_(0, person("rental_income", period)) - indiv_rental_loss = max_(0, -person("rental_income", period)) - rental_income = tax_unit.sum(not_dependent * indiv_rental_income) - rental_loss = tax_unit.sum(not_dependent * indiv_rental_loss) - - indiv_farm_rent_income = max_(0, person("farm_rent_income", period)) - indiv_farm_rent_loss = max_(0, -person("farm_rent_income", period)) - farm_rent_income = tax_unit.sum(not_dependent * indiv_farm_rent_income) - farm_rent_loss = tax_unit.sum(not_dependent * indiv_farm_rent_loss) - - indiv_estate_income = max_(0, person("estate_income", period)) - indiv_estate_loss = max_(0, -person("estate_income", period)) - estate_income = tax_unit.sum(not_dependent * indiv_estate_income) - estate_loss = tax_unit.sum(not_dependent * indiv_estate_loss) - - # Partnership/S-corp losses (Schedule E) - indiv_scorp_income = max_(0, person("partnership_s_corp_income", period)) - indiv_scorp_loss = max_(0, -person("partnership_s_corp_income", period)) - partnership_s_corp_income = tax_unit.sum(not_dependent * indiv_scorp_income) - partnership_s_corp_loss = tax_unit.sum(not_dependent * indiv_scorp_loss) - - other_net_gain = tax_unit("other_net_gain", period) - other_business_gain = max_(0, other_net_gain) - other_business_loss = max_(0, -other_net_gain) - - # Total business losses subject to Section 461(l) limitation - total_business_income = ( - self_employment_income - + farm_operations_income - + rental_income - + farm_rent_income - + estate_income - + partnership_s_corp_income - + other_business_gain - ) - total_business_loss = ( - self_employment_loss - + farm_loss - + rental_loss - + farm_rent_loss - + estate_loss - + partnership_s_corp_loss - + other_business_loss - ) - max_deductible_business_loss = total_business_income + threshold_amount - limited_business_loss = min_(total_business_loss, max_deductible_business_loss) - - # Capital losses have separate limit under Section 1211 ($3,000) - limited_capital_loss = tax_unit("limited_capital_loss", period) - - return limited_business_loss + limited_capital_loss + # Business losses allowed under section 461(l), plus capital losses + # allowed under section 1211(b), each limited separately. + adds = ["limited_business_loss", "limited_capital_loss"] diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py index 13174058394..40dffeb24af 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py @@ -78,12 +78,15 @@ def formula(tax_unit, period, parameters): capital_gains_in_agi += max_(0, person(source, period)) federal_capital_gains = tax_unit.sum(not_dependent * capital_gains_in_agi) if "loss_ald" in p.ald.deductions: - # loss_ald is the business loss allowed under section 461(l) plus - # limited_capital_loss, so it covers the capital part unless - # loss_ald itself is replaced. - federal_capital_loss = min_( + # loss_ald adds limited_business_loss (section 461(l)) and + # limited_capital_loss. Its capital part is what remains after the + # business part, so a replaced or supplied loss_ald that holds + # business losses only is not read as a capital deduction. + federal_capital_loss = clip( + tax_unit("loss_ald", period) + - tax_unit("limited_business_loss", period), + 0, tax_unit("limited_capital_loss", period), - tax_unit("loss_ald", period), ) else: federal_capital_loss = 0 From 2ea9d6bcedb14dfcd870d03cd76d8877d208e624 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:38:22 -0400 Subject: [PATCH 08/14] Add the dependent IRA household now that #9801 is on main Delta review r2 asked for the r1 household as a regression once #9801 (keep dependents' above-the-line deductions off the filer's AGI) was in this branch. Claimant aged 70 with a 4,000 long-term gain; dependent with 1,600 wages and a 200 traditional IRA contribution: federal AGI 4,000, line J 5,600, no credit (Schedule 2 limit 5,501). Co-Authored-By: Claude Opus 5.5 --- .../az_property_tax_credit_income.yaml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 137b9702c8f..f26dd8e18a9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -890,3 +890,35 @@ az_property_tax_credit_income: 1_500 # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. az_property_tax_credit: 502 + +# A dependent's federal adjustments belong on the dependent's own return +# (#9801), so they never reduce the claimant's federal AGI; Form 140PTC lines A +# to I take no IRA adjustment. +- name: A dependent's IRA contribution does not lower household income + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 4_000 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 1_600 + traditional_ira_contributions: 200 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # The claimant's federal AGI: long-term gain 4,000. + adjusted_gross_income: 4_000 + # Line A: column 3 wages 1,600. Line D: column 1 gain 4,000. + # Line J: 5,600. + az_property_tax_credit_income: 5_600 + # Line 11b: Schedule 2 pays nothing at 5,501 or more. + az_property_tax_credit: 0 From 8e1e519c7e0db89e852490d4249e0ebec14d2175 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 05:40:40 -0400 Subject: [PATCH 09/14] Leave the prior-year long-term carryover out of Arizona line D Delta review r3 (Sol, 2ea9d6bced): #9841 added long_term_capital_loss_carryover, a memo amount that long_term_capital_gains already includes as a loss (Schedule D line 15). ITR 12-1 item (7) bars a prior-year carryover from reducing the year's gains for household income, so line D adds the carryover back before the $1,500 member limit. Federal AGI keeps it. Tests: three YAML cases (a carryover netting a gain, a carryover making the whole net loss, a dependent's carryover); the property sample draws carryovers with long_term_capital_gains net of them and the Form 140PTC oracle adds them back. All fail on the previous formula (3 YAML, 4 property). Co-Authored-By: Claude Opus 5.5 --- .../az_property_tax_credit_income.yaml | 83 +++++++++++++++++++ .../test_az_property_tax_credit_income.py | 39 +++++++-- .../az_property_tax_credit_capital_gains.py | 14 +++- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index f26dd8e18a9..9caffb461c3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -922,3 +922,86 @@ az_property_tax_credit_income: 5_600 # Line 11b: Schedule 2 pays nothing at 5,501 or more. az_property_tax_credit: 0 + +# ITR 12-1 item (7): a prior-year capital loss carryover cannot reduce the +# year's gains. long_term_capital_gains is net of the long-term carryover (a +# memo amount, as on Schedule D line 15), so line D adds it back. +- name: A long-term capital loss carryover does not reduce line D + period: 2025 + input: + people: + head: + age: 70 + # The year's long-term gain is 4,000; a 3,000 carryover nets it to 1,000. + long_term_capital_gains: 1_000 + long_term_capital_loss_carryover: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI uses the carryover: 1,000. + adjusted_gross_income: 1_000 + # Line D: the year's gain 4,000. Line J: 4,000. + az_property_tax_credit_capital_gains: [4_000] + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +- name: A carryover that makes a net loss is not a line D loss + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + # No sales this year; the 3,000 carryover is the whole net loss. + long_term_capital_gains: -3_000 + long_term_capital_loss_carryover: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: 4,000 - 3,000 = 1,000. + adjusted_gross_income: 1_000 + # Line A: 4,000. Line D: 0. Line J: 4,000. + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +- name: A dependent's carryover is not a line D loss + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 4_000 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + long_term_capital_gains: -3_000 + long_term_capital_loss_carryover: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line D: column 1 gain 4,000; column 3 the year's net 0. Line J: 4,000. + az_property_tax_credit_dependent_income: 0 + az_property_tax_credit_income: 4_000 + # Line 11b: Schedule 2, 3,851 - 4,000 -> 279. + # Line 13: 750. Line 15: smaller of 279 and 750. + az_property_tax_credit: 279 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index f9f45a4ed44..09e9fe29064 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -22,7 +22,8 @@ 1. Differential: the model equals an independent line A + B + D + E + F sum over every member from Form 140PTC Part 1, with Social Security excluded, rental losses netted (A.A.C. R15-2C-502(C)(2)) and each member's line D, - capital gain distributions included, floored at -$1,500. + capital gain distributions included and any prior-year carryover left out + (ITR 12-1 item (7)), floored at -$1,500. 2. Counted once: adding d to a member's long-term gains changes household income by exactly the change in that member's line D, which is d when the member's net gain stays at or above -$1,500. The credit never rises. @@ -73,6 +74,11 @@ def _capital_gains(rng: np.random.Generator, can_be_large: bool) -> tuple: ) +def _carryover(rng: np.random.Generator) -> float: + """A prior-year long-term capital loss carryover, for some members.""" + return round(rng.uniform(0, 4_000), 2) if rng.random() < 0.15 else 0.0 + + def _distributions(rng: np.random.Generator) -> float: """Capital gain distributions reported without Schedule D, for some members.""" return round(rng.uniform(0, 800), 2) if rng.random() < 0.25 else 0.0 @@ -90,6 +96,7 @@ def _sample_units(rng: np.random.Generator) -> list: members = [] for role in ["head", "spouse"] if married else ["head"]: long_term, short_term = _capital_gains(rng, can_be_large=True) + carryover = _carryover(rng) members.append( { "role": role, @@ -100,7 +107,10 @@ def _sample_units(rng: np.random.Generator) -> list: "taxable_interest_income": round(rng.uniform(0, 800), 2), "tax_exempt_interest_income": round(rng.uniform(0, 500), 2), "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), - "long_term_capital_gains": long_term, + # long_term_capital_gains is net of the carryover, as + # Schedule D line 15 is. + "long_term_capital_gains": round(long_term - carryover, 2), + "long_term_capital_loss_carryover": carryover, "short_term_capital_gains": short_term, "non_sch_d_capital_gains": _distributions(rng), "rental_income": _rental_income(rng), @@ -119,6 +129,7 @@ def _sample_units(rng: np.random.Generator) -> list: # A child dependent with income of their own, including net # capital losses, which count up to the dependent's own limit. long_term, short_term = _capital_gains(rng, can_be_large=False) + carryover = _carryover(rng) members.append( { "role": "dependent", @@ -130,7 +141,10 @@ def _sample_units(rng: np.random.Generator) -> list: "taxable_interest_income": round(rng.uniform(0, 300), 2), "tax_exempt_interest_income": round(rng.uniform(0, 200), 2), "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), - "long_term_capital_gains": long_term, + # long_term_capital_gains is net of the carryover, as + # Schedule D line 15 is. + "long_term_capital_gains": round(long_term - carryover, 2), + "long_term_capital_loss_carryover": carryover, "short_term_capital_gains": short_term, "non_sch_d_capital_gains": _distributions(rng), "rental_income": _rental_income(rng), @@ -206,10 +220,16 @@ def _situation(units: list) -> dict: def _net_gain(member: dict) -> float: - # Capital gain distributions are long-term capital gains (26 U.S.C. - # 852(b)(3)(B)), combined with the member's other gains and losses. + """The member's own net gain or loss for the year (Form 140PTC line D). + + Capital gain distributions are long-term capital gains (26 U.S.C. + 852(b)(3)(B)), combined with the member's other gains and losses. A + prior-year carryover cannot reduce them (ITR 12-1 item (7)), and the + long_term_capital_gains input is net of it, so it is added back. + """ return ( member["long_term_capital_gains"] + + member["long_term_capital_loss_carryover"] + member["short_term_capital_gains"] + member["non_sch_d_capital_gains"] ) @@ -289,6 +309,15 @@ def federal_capital(unit: dict) -> float: for m in members ) assert any(m["rental_income"] < 0 for m in members if m["role"] == "dependent") + # Carryovers, including some that make the input a loss beyond the limit + # while the year's own net gain is not. + assert sum(m["long_term_capital_loss_carryover"] > 0 for m in members) >= 10 + assert any( + m["long_term_capital_gains"] + m["short_term_capital_gains"] + < -MEMBER_LOSS_LIMIT + <= _net_gain(m) + for m in members + ) def test_household_income_matches_form_140ptc_lines(): diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py index 08aaa68430f..deeb493482e 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py @@ -12,13 +12,15 @@ class az_property_tax_credit_capital_gains(Variable): "property for the Arizona property tax credit (Form 140PTC Part 1 line D): " "the year's gains and losses combined, including capital gain " "distributions, with a net loss counted only up to the per-member limit. " - "No prior-year capital loss carryover is used." + "No prior-year capital loss carryover is used: the long-term carryover " + "that long_term_capital_gains nets is added back." ) reference = [ "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2", "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", "https://www.law.cornell.edu/uscode/text/26/852#b_3_B", + "https://www.law.cornell.edu/uscode/text/26/1212#b_1_B", ] defined_for = StateCode.AZ @@ -31,7 +33,13 @@ def formula(person, period, parameters): # Capital gain distributions reported without Schedule D are long-term # capital gains (26 U.S.C. 852(b)(3)(B)), so they are combined with the # member's other gains and losses before the limit. - net_capital_gain = add( - person, period, ["capital_gains", "non_sch_d_capital_gains"] + # ITR 12-1 item (7): a prior-year capital loss carryover cannot reduce + # the year's gains. long_term_capital_gains includes the long-term + # carryover (26 U.S.C. 1212(b)(1)(B)) as a loss, as Schedule D line 15 + # does, so it is added back to give the year's own net gain or loss. + carryover = max_(0, person("long_term_capital_loss_carryover", period)) + net_capital_gain = ( + add(person, period, ["capital_gains", "non_sch_d_capital_gains"]) + + carryover ) return max_(net_capital_gain, -p.capital_loss_limit) From 4e36f89ae07064f4c93f582d5ddae048b3b9d5d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 11:10:56 -0400 Subject: [PATCH 10/14] Cover dependent Arizona capital-gain changes (#9762) Preserve all 80 original sampled households and their independent line-D oracle. Add dependent gain and loss perturbations for the 26 households with dependents in the same simulations, preserving member loss floors and filer/dependent accounting. Use the educator deduction variable introduced on main in the test that removes loss_ald from the federal deduction list. Expected outputs and public carryover inputs remain unchanged. --- .../az_property_tax_credit_income.yaml | 2 +- .../test_az_property_tax_credit_income.py | 62 +++++++++++++------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index 9caffb461c3..e02e684e6c3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -767,7 +767,7 @@ period: 2025 input: # The 2025 above-the-line deductions without loss_ald. - gov.irs.ald.deductions: [self_employment_tax_ald, student_loan_interest_ald, early_withdrawal_penalty, alimony_expense_ald, educator_expense, health_savings_account_ald, self_employed_health_insurance_ald, self_employed_pension_contribution_ald, traditional_ira_contributions, qualified_adoption_assistance_expense, us_bonds_for_higher_ed, specified_possession_income, puerto_rico_income] + gov.irs.ald.deductions: [self_employment_tax_ald, student_loan_interest_ald, early_withdrawal_penalty, alimony_expense_ald, educator_expense_ald, health_savings_account_ald, self_employed_health_insurance_ald, self_employed_pension_contribution_ald, traditional_ira_contributions, qualified_adoption_assistance_expense, us_bonds_for_higher_ed, specified_possession_income, puerto_rico_income] people: head: age: 70 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py index 09e9fe29064..b95ffe1b383 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_income.py +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -186,14 +186,20 @@ def _sample_units(rng: np.random.Generator) -> list: return units -def _with_head_gains_changed(units: list, sign: int) -> list: +def _member(unit: dict, role: str = "head") -> dict: + return next(member for member in unit["members"] if member["role"] == role) + + +def _with_gains_changed(units: list, sign: int, role: str = "head") -> list: changed = [] for unit in units: members = [dict(m) for m in unit["members"]] - members[0]["long_term_capital_gains"] = round( - members[0]["long_term_capital_gains"] + sign * unit["gain_change"], 2 + changed_unit = {**unit, "members": members} + member = _member(changed_unit, role) + member["long_term_capital_gains"] = round( + member["long_term_capital_gains"] + sign * unit["gain_change"], 2 ) - changed.append({**unit, "members": members}) + changed.append(changed_unit) return changed @@ -266,9 +272,20 @@ def _form_140ptc_line_j(units: list) -> np.ndarray: UNITS = _sample_units(np.random.default_rng(SEED)) -RAISED = _with_head_gains_changed(UNITS, sign=1) -LOWERED = _with_head_gains_changed(UNITS, sign=-1) LINE_J = _form_140ptc_line_j(UNITS) +# Keep every original head case, then repeat households with dependents to +# change the dependent's gains in the same vectorized simulations. +DEPENDENT_UNITS = [ + unit for unit in UNITS if any(m["role"] == "dependent" for m in unit["members"]) +] +GAIN_CHANGE_UNITS = UNITS + DEPENDENT_UNITS +GAIN_CHANGE_ROLES = ["head"] * len(UNITS) + ["dependent"] * len(DEPENDENT_UNITS) +RAISED = _with_gains_changed(UNITS, sign=1) + _with_gains_changed( + DEPENDENT_UNITS, sign=1, role="dependent" +) +LOWERED = _with_gains_changed(UNITS, sign=-1) + _with_gains_changed( + DEPENDENT_UNITS, sign=-1, role="dependent" +) def test_sample_covers_the_loss_limit_and_negative_line_j(): @@ -327,24 +344,29 @@ def test_household_income_matches_form_140ptc_lines(): def test_gains_count_once_up_to_the_loss_limit(): - base = Simulation(situation=_situation(UNITS)) + base = Simulation(situation=_situation(GAIN_CHANGE_UNITS)) raised = Simulation(situation=_situation(RAISED)) expected = np.array( [ - _line_d(r["members"][0]) - _line_d(u["members"][0]) - for u, r in zip(UNITS, RAISED) + _line_d(_member(r, role)) - _line_d(_member(u, role)) + for u, r, role in zip(GAIN_CHANGE_UNITS, RAISED, GAIN_CHANGE_ROLES) ] ) income_change = raised.calculate( "az_property_tax_credit_income", YEAR ) - base.calculate("az_property_tax_credit_income", YEAR) np.testing.assert_allclose(income_change, expected, atol=0.01) - # Where the head's net gain stays at or above the limit, the change is d. + # Where the changed member's net gain stays at or above the limit, + # the change is d, for heads and dependents alike. stays_above = np.array( - [_net_gain(u["members"][0]) >= -MEMBER_LOSS_LIMIT for u in UNITS] + [ + _net_gain(_member(unit, role)) >= -MEMBER_LOSS_LIMIT + for unit, role in zip(GAIN_CHANGE_UNITS, GAIN_CHANGE_ROLES) + ] ) - increase = np.array([unit["gain_change"] for unit in UNITS]) - assert stays_above.sum() >= 20 + increase = np.array([unit["gain_change"] for unit in GAIN_CHANGE_UNITS]) + assert stays_above[:N].sum() >= 20 + assert stays_above[N:].any() np.testing.assert_allclose( income_change[stays_above], increase[stays_above], atol=0.01 ) @@ -355,23 +377,27 @@ def test_gains_count_once_up_to_the_loss_limit(): def test_member_loss_limit(): - base = Simulation(situation=_situation(UNITS)) + base = Simulation(situation=_situation(GAIN_CHANGE_UNITS)) lowered = Simulation(situation=_situation(LOWERED)) income_change = lowered.calculate( "az_property_tax_credit_income", YEAR ) - base.calculate("az_property_tax_credit_income", YEAR) expected = np.array( [ - _line_d(w["members"][0]) - _line_d(u["members"][0]) - for u, w in zip(UNITS, LOWERED) + _line_d(_member(w, role)) - _line_d(_member(u, role)) + for u, w, role in zip(GAIN_CHANGE_UNITS, LOWERED, GAIN_CHANGE_ROLES) ] ) np.testing.assert_allclose(income_change, expected, atol=0.01) # Once a member's net gain is at or below the limit, more loss is ignored. at_limit = np.array( - [_net_gain(u["members"][0]) <= -MEMBER_LOSS_LIMIT for u in UNITS] + [ + _net_gain(_member(unit, role)) <= -MEMBER_LOSS_LIMIT + for unit, role in zip(GAIN_CHANGE_UNITS, GAIN_CHANGE_ROLES) + ] ) - assert at_limit.sum() >= 10 + assert at_limit[:N].sum() >= 10 + assert at_limit[N:].any() np.testing.assert_allclose(income_change[at_limit], 0, atol=0.01) # Each member's line D is bounded below by the limit. line_d = base.calculate("az_property_tax_credit_capital_gains", YEAR) From 57eceac99d6fb0561daad75638a341c30e868b76 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 19:43:03 -0400 Subject: [PATCH 11/14] Clarify Arizona capital input convention and consolidate release notes --- changelog.d/az-ptc-line-d-loss-limit.fixed.md | 1 - changelog.d/fix-az-ptc-capital-gains.fixed.md | 2 +- .../az_property_tax_credit_capital_gains.py | 4 +++- 3 files changed, 4 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/az-ptc-line-d-loss-limit.fixed.md diff --git a/changelog.d/az-ptc-line-d-loss-limit.fixed.md b/changelog.d/az-ptc-line-d-loss-limit.fixed.md deleted file mode 100644 index 0b51b5b3d03..00000000000 --- a/changelog.d/az-ptc-line-d-loss-limit.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Limit each Arizona household member's net capital loss, capital gain distributions included, to $1,500 in property tax credit household income (Form 140PTC line D), in place of federal AGI's $3,000 per-return limit; count dependents' rental, business and other losses, which federal AGI leaves out; and let household income go negative, using zero only for the credit schedules (line J). diff --git a/changelog.d/fix-az-ptc-capital-gains.fixed.md b/changelog.d/fix-az-ptc-capital-gains.fixed.md index 7f3e217a3de..cfca69393b1 100644 --- a/changelog.d/fix-az-ptc-capital-gains.fixed.md +++ b/changelog.d/fix-az-ptc-capital-gains.fixed.md @@ -1 +1 @@ -Arizona property tax credit household income counts capital gains and qualified dividends once, through federal AGI, instead of adding the federal preferential-rate amount again; counts dependents' income, which federal AGI leaves out; and is no longer floored at zero before members' income is combined (the schedules use zero for a negative total). +Arizona property tax credit household income counts capital gains and qualified dividends once, includes dependents' income and losses, and limits each member's net capital loss (including capital gain distributions) to $1,500 instead of federal AGI's $3,000 per-return limit. Household income can be negative, with zero used only for the credit schedules. Short-term capital inputs must exclude prior-year carryovers. diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py index deeb493482e..8c6f3334a3c 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py @@ -13,7 +13,9 @@ class az_property_tax_credit_capital_gains(Variable): "the year's gains and losses combined, including capital gain " "distributions, with a net loss counted only up to the per-member limit. " "No prior-year capital loss carryover is used: the long-term carryover " - "that long_term_capital_gains nets is added back." + "that long_term_capital_gains nets is added back. Short-term inputs " + "must contain only the current year's net gains and losses, without " + "Schedule D line 6 carryovers; no short-term carryover memo is modeled." ) reference = [ "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", From a05c9318d96a808844f2be3aba75568d3eb65e4a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 20:13:54 -0400 Subject: [PATCH 12/14] Test bounded whole-dollar parity of federal loss extraction --- .../core/test_loss_ald_extraction_parity.py | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 policyengine_us/tests/core/test_loss_ald_extraction_parity.py diff --git a/policyengine_us/tests/core/test_loss_ald_extraction_parity.py b/policyengine_us/tests/core/test_loss_ald_extraction_parity.py new file mode 100644 index 00000000000..d7c2ee4ccfb --- /dev/null +++ b/policyengine_us/tests/core/test_loss_ald_extraction_parity.py @@ -0,0 +1,292 @@ +"""Bounded whole-dollar compatibility of the extracted business-loss component. + +YAML covers the policy examples. This differential test covers Core's float32 +storage and ``adds`` accumulation, which YAML cannot compare with the former +single-formula calculation. The independent reference applies sections 461(l) +and 1211(b) to leaf inputs and casts the combined deduction only once, as the +pre-extraction formula did. + +All intermediate totals stay below 2**24, the exact-integer range of float32. +This is a bounded guarantee, not parity for fractional dollars or larger totals. +Use production classes in one small Core system, without building or copying a +country model per case; package import constructs its global model once. This +module belongs to the existing rest-python core CI group. +""" + +from pathlib import Path + +import numpy as np +import pytest +import yaml +from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters.config import Loader +from policyengine_core.simulations import Simulation +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +import policyengine_us +from policyengine_us.entities import Person, TaxUnit +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.capital_losses_allowed_against_gains import ( + capital_losses_allowed_against_gains, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.limited_business_loss import ( + limited_business_loss, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.limited_capital_loss import ( + limited_capital_loss, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.loss_ald import ( + loss_ald, +) +from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( + FilingStatus, + filing_status, +) +from policyengine_us.variables.household.demographic.tax_unit.is_tax_unit_dependent import ( + is_tax_unit_dependent, +) +from policyengine_us.variables.household.income.person.capital_gains.capital_gains import ( + capital_gains, +) +from policyengine_us.variables.household.income.person.capital_gains.capital_losses import ( + capital_losses, +) +from policyengine_us.variables.household.income.person.capital_gains.long_term_capital_gains import ( + long_term_capital_gains, +) +from policyengine_us.variables.household.income.person.capital_gains.non_sch_d_capital_gains import ( + non_sch_d_capital_gains, +) +from policyengine_us.variables.household.income.person.capital_gains.other_net_gain import ( + other_net_gain, +) +from policyengine_us.variables.household.income.person.capital_gains.short_term_capital_gains import ( + short_term_capital_gains, +) +from policyengine_us.variables.household.income.person.estate.estate_income import ( + estate_income, +) +from policyengine_us.variables.household.income.person.farm.farm_operations_income import ( + farm_operations_income, +) +from policyengine_us.variables.household.income.person.farm.farm_rent_income import ( + farm_rent_income, +) +from policyengine_us.variables.household.income.person.misc.rental_income import ( + rental_income, +) +from policyengine_us.variables.household.income.person.self_employment.partnership_income import ( + partnership_income, +) +from policyengine_us.variables.household.income.person.self_employment.partnership_s_corp_income import ( + partnership_s_corp_income, +) +from policyengine_us.variables.household.income.person.self_employment.s_corp_income import ( + s_corp_income, +) +from policyengine_us.variables.household.income.person.self_employment.total_self_employment_income import ( + total_self_employment_income, +) +from policyengine_us.variables.input.self_employment_income import ( + self_employment_income, +) +from policyengine_us.variables.input.sstb_self_employment_income import ( + sstb_self_employment_income, +) + +INPUT_CLASSES = ( + self_employment_income, + sstb_self_employment_income, + farm_operations_income, + rental_income, + farm_rent_income, + estate_income, + partnership_income, + s_corp_income, + short_term_capital_gains, + long_term_capital_gains, + non_sch_d_capital_gains, +) +INPUTS = tuple(variable.__name__ for variable in INPUT_CLASSES) +ZERO = dict.fromkeys(INPUTS, 0) +STATUSES = tuple(status.name for status in FilingStatus) +EXACT_INTEGER_LIMIT = 2**24 + + +@pytest.fixture(scope="module") +def system(): + result = TaxBenefitSystem(entities=[Person, TaxUnit]) + result.add_variables( + *INPUT_CLASSES, + filing_status, + is_tax_unit_dependent, + other_net_gain, + total_self_employment_income, + partnership_s_corp_income, + capital_gains, + capital_losses, + limited_business_loss, + capital_losses_allowed_against_gains, + limited_capital_loss, + loss_ald, + ) + parameter_root = Path(policyengine_us.__file__).parent / "parameters" + + def load(relative_path): + return yaml.load((parameter_root / relative_path).read_text(), Loader=Loader) + + # Read only the published thresholds and gross-income source list needed + # by these formulas; no full parameter-tree construction or copying. + result.parameters = ParameterNode( + "", + data={ + "gov": { + "irs": { + "ald": { + "loss": { + "max": load("gov/irs/ald/loss/max.yaml"), + "capital": { + "max": load("gov/irs/ald/loss/capital/max.yaml") + }, + } + }, + "gross_income": { + "sources": load("gov/irs/gross_income/sources.yaml") + }, + } + } + }, + ) + return result + + +def _draw_units(year, thresholds, capital_caps): + rng = np.random.default_rng(9762 + year) + units = [] + for i in range(120): + members = [] + for dependent in [False] + ([False] if i % 2 else []) + [True] * (i % 3): + amounts = {name: int(rng.integers(-100_000, 100_001)) for name in INPUTS} + amounts["non_sch_d_capital_gains"] = abs(amounts["non_sch_d_capital_gains"]) + members.append((amounts, dependent)) + units.append( + { + "status": STATUSES[i % len(STATUSES)], + "other": int(rng.integers(-100_000, 100_001)), + "members": members, + } + ) + for status in STATUSES: + for offset in (-1, 0, 1): + # Below/at/above business and capital limits, including MFS. + # Opposite-signed short/long-term sources net before the + # distribution offsets the resulting loss. + head = { + **ZERO, + "farm_rent_income": 12_345, + "rental_income": -(thresholds[status] + 12_345 + 17 + offset), + "short_term_capital_gains": -( + 2_000 + capital_caps[status] + offset + 700 + ), + "long_term_capital_gains": 700, + "non_sch_d_capital_gains": 2_000, + } + dependent = { + **ZERO, + "estate_income": -800_000, + "partnership_income": 400_000, + "short_term_capital_gains": -400_000, + "long_term_capital_gains": 100_000, + "non_sch_d_capital_gains": 200_000, + } + units.append( + { + "status": status, + "other": 17, + "members": [(head, False), (dependent, True)], + } + ) + return units + + +def _pre_extraction_reference(unit, threshold, capital_cap): + """Independent scalar reference, retaining the former one final cast.""" + business_income = max(0, unit["other"]) + business_losses = max(0, -unit["other"]) + capital_losses = gains_in_gross_income = 0 + for person, dependent in unit["members"]: + if dependent: + continue + business_sources = ( + person["self_employment_income"] + person["sstb_self_employment_income"], + person["farm_operations_income"], + person["rental_income"], + person["farm_rent_income"], + person["estate_income"], + person["partnership_income"] + person["s_corp_income"], + ) + business_income += sum(max(0, value) for value in business_sources) + business_losses += sum(max(0, -value) for value in business_sources) + net_capital = ( + person["short_term_capital_gains"] + person["long_term_capital_gains"] + ) + capital_losses += max(0, -net_capital) + gains_in_gross_income += max(0, net_capital) + max( + 0, person["non_sch_d_capital_gains"] + ) + business = min(business_losses, business_income + threshold) + against_gains = min(capital_losses, gains_in_gross_income) + net_capital_loss = min(capital_cap, capital_losses - against_gains) + combined = business + against_gains + net_capital_loss + # Check every total, not only individual leaf inputs: an intermediate + # sum outside this domain can round before the final combined deduction. + assert ( + max( + business_income + threshold, + business_losses, + capital_losses, + gains_in_gross_income, + combined, + ) + < EXACT_INTEGER_LIMIT + ) + return np.float32(combined) + + +@pytest.mark.parametrize("year", [2021, 2025, 2026]) +def test_whole_dollar_loss_ald_matches_pre_extraction_formula(system, year): + """Dependent masking, status limits and source offsets preserve exact totals.""" + p = system.parameters(f"{year}-01-01").gov.irs.ald.loss + thresholds = {status: p.max[status] for status in STATUSES} + capital_caps = {status: p.capital.max[status] for status in STATUSES} + units = _draw_units(year, thresholds, capital_caps) + people, tax_units = {}, {} + for i, unit in enumerate(units): + members = [] + for j, (amounts, dependent) in enumerate(unit["members"]): + name = f"u{i}_p{j}" + members.append(name) + people[name] = { + "is_tax_unit_dependent": {year: dependent}, + **{name: {year: value} for name, value in amounts.items()}, + } + tax_units[f"t{i}"] = { + "members": members, + "filing_status": {year: unit["status"]}, + "other_net_gain": {year: unit["other"]}, + } + simulation = Simulation( + tax_benefit_system=system, + situation={"people": people, "tax_units": tax_units}, + ) + expected = np.array( + [ + _pre_extraction_reference( + unit, thresholds[unit["status"]], capital_caps[unit["status"]] + ) + for unit in units + ], + dtype=np.float32, + ) + actual = simulation.calculate("loss_ald", year) + assert actual.dtype == np.float32 + np.testing.assert_array_equal(actual, expected) From c6ad076aaca4507ba288071155295ffbffb44d51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 20:14:13 -0400 Subject: [PATCH 13/14] Test bounded whole-dollar parity of federal loss extraction From 3603ddc030e5d0bae632930ade0f1a28fd7150b6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 06:27:43 -0400 Subject: [PATCH 14/14] Pin d1107 terminating-trust carryover treatment for Arizona credit --- .../az_property_tax_credit_income.yaml | 28 +++++++++++++++++++ .../az_property_tax_credit_capital_gains.py | 7 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml index e02e684e6c3..f1f7d8f85e2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -952,6 +952,34 @@ # Line 11a: Schedule 1 pays nothing at 3,751 or more. az_property_tax_credit: 0 +# Reg. 1.642(h)-1(a): a terminating trust transfers its unused capital loss +# as a carryover. Max's d1107 ruling (2026-10-09) keeps final Form 1041 K-1 +# box 11 code D in the carryover memo, even for losses from termination-year +# sales. Arizona adds it back; the beneficiary's own gains remain 4,000. +- name: A terminating trust capital loss remains a carryover for line D + period: 2025 + input: + people: + head: + age: 70 + # Own gains 4,000 less the trust's 3,000 loss transferred in 2025. + long_term_capital_gains: 1_000 + long_term_capital_loss_carryover: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 1_000 + az_property_tax_credit_capital_gains: [4_000] + az_property_tax_credit_agi: 4_000 + az_property_tax_credit_income: 4_000 + az_property_tax_credit: 0 + - name: A carryover that makes a net loss is not a line D loss period: 2025 input: diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py index 8c6f3334a3c..f0d2ca0e313 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py @@ -13,7 +13,8 @@ class az_property_tax_credit_capital_gains(Variable): "the year's gains and losses combined, including capital gain " "distributions, with a net loss counted only up to the per-member limit. " "No prior-year capital loss carryover is used: the long-term carryover " - "that long_term_capital_gains nets is added back. Short-term inputs " + "that long_term_capital_gains nets is added back, including unused " + "losses transferred by a terminating estate or trust. Short-term inputs " "must contain only the current year's net gains and losses, without " "Schedule D line 6 carryovers; no short-term carryover memo is modeled." ) @@ -23,6 +24,7 @@ class az_property_tax_credit_capital_gains(Variable): "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", "https://www.law.cornell.edu/uscode/text/26/852#b_3_B", "https://www.law.cornell.edu/uscode/text/26/1212#b_1_B", + "https://www.law.cornell.edu/cfr/text/26/1.642(h)-1", ] defined_for = StateCode.AZ @@ -39,6 +41,9 @@ def formula(person, period, parameters): # the year's gains. long_term_capital_gains includes the long-term # carryover (26 U.S.C. 1212(b)(1)(B)) as a loss, as Schedule D line 15 # does, so it is added back to give the year's own net gain or loss. + # Reg. 1.642(h)-1(a) transfers a terminating estate/trust's unused + # capital loss as a carryover. Max's d1107 ruling (2026-10-09) keeps + # final Form 1041 K-1 box 11 code D losses in this addback. carryover = max_(0, person("long_term_capital_loss_carryover", period)) net_capital_gain = ( add(person, period, ["capital_gains", "non_sch_d_capital_gains"])