From 628ef23efb03a9275bfb3c41704d325669295b7b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 22:49:18 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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,