diff --git a/changelog.d/az-ptc-household-income.fixed.md b/changelog.d/az-ptc-household-income.fixed.md new file mode 100644 index 00000000000..3d26f1626a2 --- /dev/null +++ b/changelog.d/az-ptc-household-income.fixed.md @@ -0,0 +1 @@ +Count the income of everyone living in an Arizona property tax credit claimant's home, allow one claimant per household, exclude Arizona unemployment, count other states' unemployment, nontaxable strike benefits and nontaxable alimony, and stop subtracting federal above-the-line deductions other than business and capital losses. 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 8c819559cb7..ab8ecb4f3a3 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,4 +1,4 @@ -description: Arizona accounts for the following income sources when computing household income for the property tax credit. +description: Arizona accounts for the following income sources of each tax unit's members when computing household income for the property tax credit. values: 2021-01-01: # Item (2): All interest income including tax-exempt @@ -7,7 +7,12 @@ values: # (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), with + # federal above-the-line deductions other than loss_ald added back: + # A.A.C. R15-2C-502(A)(1) counts "the sum of all income not specifically + # excluded in A.R.S. § 43-1072", and Form 140PTC lines A to I subtract no + # adjustment (ruling us-az:ptc-above-the-line-deductions). loss_ald stays + # subtracted for its business losses; its capital part is replaced below. # Item (7) capital gains and losses (Form 140PTC Part 1 line D) are counted # 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 @@ -27,6 +32,14 @@ values: # (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 + # ARS 43-1072(I) excludes Arizona unemployment insurance payments, which + # the two variables above take out; Form 140PTC line I counts other + # states' payments. + - az_property_tax_credit_unemployment + # Line H counts all alimony received and line I nontaxable strike + # benefits (ARS 43-1072(H)(6)(c)); this adds the part federal gross + # income leaves out, for every member. + - az_property_tax_credit_nontaxable_income metadata: unit: list period: year 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 2f920a71272..35f8565d5f3 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 @@ -104,3 +104,85 @@ # 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 + +# Ruling us-az:ptc-above-the-line-deductions: household income does not +# subtract federal above-the-line deductions other than the business and +# capital loss deduction. A.A.C. R15-2C-502(A)(1) counts "the sum of all +# income not specifically excluded in A.R.S. § 43-1072", and Form 140PTC +# Part 1 lines A to I subtract no adjustment. 2025 Table 1 (ARS +# 43-1072(B)(1)): 2,951 - 3,050 -> 212; property taxes 0.15 x 6,000 = 900. + +- name: A student loan interest deduction does not reduce household income + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + student_loan_interest: 1_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + student_loan_interest_ald: 1_000 + # Federal AGI: 3,000 - 1,000. + adjusted_gross_income: 2_000 + # 2,000 + 1,000 added back. Without the add-back, 2,000 -> Table 1 + # 1,951 - 2,050 -> 435. + az_property_tax_credit_agi: 3_000 + az_property_tax_credit_household_income: 3_000 + az_property_tax_credit: 212 + +- name: Half of self-employment tax does not reduce household income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + self_employment_income: 3_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # 3,000 x 0.9235 = 2,770.50; x 0.153 = 423.89; half = 211.94. + self_employment_tax_ald: 211.94 + adjusted_gross_income: 2_788.06 + az_property_tax_credit_agi: 3_000 + az_property_tax_credit_household_income: 3_000 + az_property_tax_credit: 212 + +- name: A Schedule C loss still reduces household income + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + self_employment_income: -1_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # loss_ald carries the business loss, which ITR 12-1 items (3) to (5) + # count; it is not added back. + loss_ald: 1_000 + adjusted_gross_income: 3_000 + az_property_tax_credit_agi: 3_000 + az_property_tax_credit_household_income: 3_000 + az_property_tax_credit: 212 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 226dbbebd62..c655985eff6 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 @@ -164,3 +164,29 @@ # 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 + +- name: A co-resident's rent does not make a claimant who paid none eligible + period: 2025 + input: + people: + parent: + age: 70 + roommate: + age: 40 + rent: 6_000 + tax_units: + parent_unit: + members: [parent] + roommate_unit: + members: [roommate] + households: + household: + members: [parent, roommate] + state_code: AZ + output: + # Ruling us-az:ptc-rent-and-taxes-shared-home: ARS 43-1072(A)(2) requires + # that the claimant "paid either property taxes or rent", and Form 201 + # line 13 enters "the total rents you paid". The parent paid none; the + # roommate is under 65. + az_property_tax_credit_eligible: [false, false] + az_property_tax_credit: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_claimant.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_claimant.yaml new file mode 100644 index 00000000000..d1173b456ce --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_claimant.yaml @@ -0,0 +1,155 @@ +# ARS 43-1072(G): "Only one claimant per household per year shall be entitled +# to a tax credit." Under (H)(1) qualifying members choose the claimant; the +# model picks the tax unit with the largest claim. + +- name: Two qualifying roommates, the larger claim gets the credit + period: 2025 + input: + people: + roommate_a: + age: 68 + employment_income: 1_000 + rent: 6_000 + roommate_b: + age: 68 + employment_income: 500 + rent: 2_000 + tax_units: + unit_a: + members: [roommate_a] + unit_b: + members: [roommate_b] + households: + household: + members: [roommate_a, roommate_b] + state_code: AZ + output: + az_property_tax_credit_household_income: 1_500 + az_property_tax_credit_eligible: [true, true] + # Table 2, 0 - 2,500 -> 502. A: min(502, 900) = 502; B: min(502, 300) = 300. + az_property_tax_credit_potential: [502, 300] + az_property_tax_credit_claimant: [true, false] + az_property_tax_credit: [502, 0] + +- name: The larger claim wins whatever the member order + period: 2025 + input: + people: + roommate_b: + age: 68 + employment_income: 500 + rent: 2_000 + roommate_a: + age: 68 + employment_income: 1_000 + rent: 6_000 + tax_units: + unit_b: + members: [roommate_b] + unit_a: + members: [roommate_a] + households: + household: + members: [roommate_b, roommate_a] + state_code: AZ + output: + az_property_tax_credit_potential: [300, 502] + az_property_tax_credit: [0, 502] + +- name: A tie goes to the tax unit whose head comes first in the person order + period: 2025 + input: + people: + roommate_a: + age: 68 + rent: 6_000 + roommate_b: + age: 68 + rent: 6_000 + tax_units: + unit_a: + members: [roommate_a] + unit_b: + members: [roommate_b] + households: + household: + members: [roommate_a, roommate_b] + state_code: AZ + output: + az_property_tax_credit_potential: [502, 502] + az_property_tax_credit: [502, 0] + +- name: A tie goes to the tax unit holding the household head + period: 2025 + input: + people: + roommate_a: + age: 68 + rent: 6_000 + roommate_b: + age: 68 + rent: 6_000 + is_household_head: true + tax_units: + unit_a: + members: [roommate_a] + unit_b: + members: [roommate_b] + households: + household: + members: [roommate_a, roommate_b] + state_code: AZ + output: + az_property_tax_credit_potential: [502, 502] + az_property_tax_credit: [0, 502] + +- name: Separate households each keep a claimant + period: 2025 + input: + people: + person_a: + age: 68 + rent: 6_000 + person_b: + age: 68 + rent: 2_000 + tax_units: + unit_a: + members: [person_a] + unit_b: + members: [person_b] + households: + household_a: + members: [person_a] + state_code: AZ + household_b: + members: [person_b] + state_code: AZ + output: + # Table 1 at zero income: 502. B: min(502, 300) = 300. + az_property_tax_credit: [502, 300] + +- name: An ineligible co-resident is never the claimant + period: 2025 + input: + people: + parent: + age: 70 + rent: 1_000 + son: + age: 40 + rent: 6_000 + tax_units: + parent_unit: + members: [parent] + son_unit: + members: [son] + households: + household: + members: [parent, son] + state_code: AZ + output: + az_property_tax_credit_eligible: [true, false] + # Parent: min(502, 0.15 x 1,000 = 150) = 150. + az_property_tax_credit_claimant: [true, false] + az_property_tax_credit: [150, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_household_income.yaml new file mode 100644 index 00000000000..7a7b193ae23 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_household_income.yaml @@ -0,0 +1,284 @@ +# ARS 43-1072(H)(4)-(5) and A.A.C. R15-2C-502(A)(2), (B): household income is +# the income of everyone living in the claimant's home, including co-residents +# who file their own returns. The 2025 schedules are in ARS 43-1072(B)(1) +# (Table 1, living alone) and (B)(2) (Table 2, living with others). + +- name: A son filing his own return with $30,000 of wages ends his parent's credit + period: 2025 + input: + people: + parent: + age: 70 + rent: 6_000 + son: + age: 40 + employment_income: 30_000 + tax_units: + parent_unit: + members: [parent] + son_unit: + members: [son] + households: + household: + members: [parent, son] + state_code: AZ + output: + # Each tax unit's own amount: the parent has none; the son's is his AGI. + az_property_tax_credit_income: [0, 30_000] + # Line J: 0 + 30,000. + az_property_tax_credit_household_income: 30_000 + az_property_tax_credit_eligible: [true, false] + # Table 2 ends at $5,500: 0. Without the son's wages the parent would get + # min(502, 0.15 x 6,000 = 900) = 502. + az_property_tax_credit: [0, 0] + +- name: A son filing his own return with $3,000 of wages reduces his parent's credit + period: 2025 + input: + people: + parent: + age: 70 + rent: 6_000 + son: + age: 40 + employment_income: 3_000 + tax_units: + parent_unit: + members: [parent] + son_unit: + members: [son] + households: + household: + members: [parent, son] + state_code: AZ + output: + az_property_tax_credit_income: [0, 3_000] + az_property_tax_credit_household_income: 3_000 + # Table 2, 2,951 - 3,100 -> 412; property taxes 0.15 x 6,000 = 900. + az_property_tax_credit: [412, 0] + +- name: Arizona unemployment compensation is not household income + period: 2025 + input: + people: + head: + age: 70 + unemployment_compensation: 2_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI holds the 2,000; ARS 43-1072(I) excludes it. + adjusted_gross_income: 2_000 + az_property_tax_credit_unemployment: 0 + az_property_tax_credit_household_income: 0 + # Table 1, 0 - 1,750 -> 502; property taxes 900. + az_property_tax_credit: 502 + +- name: Another state's unemployment compensation is household income + period: 2025 + input: + people: + head: + age: 70 + unemployment_compensation: 2_000 + az_other_state_unemployment_compensation: 2_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 2_000 + # Form 140PTC line I: other states' unemployment insurance payments. + az_property_tax_credit_unemployment: 2_000 + az_property_tax_credit_household_income: 2_000 + # Table 1, 1,951 - 2,050 -> 435. + az_property_tax_credit: 435 + +- name: Only the other-state part of unemployment counts, for a dependent too + period: 2025 + input: + people: + head: + age: 70 + unemployment_compensation: 1_500 + az_other_state_unemployment_compensation: 500 + rent: 6_000 + child: + age: 17 + is_tax_unit_dependent: true + unemployment_compensation: 1_000 + az_other_state_unemployment_compensation: 400 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + az_property_tax_credit_unemployment: [500, 400] + # The child's 1,000 of unemployment is left out of dependent income; + # the 400 paid by another state comes back through the line above. + az_property_tax_credit_dependent_income: 0 + # Arizona's 1,000 + 600 is left out; 500 + 400 counts. + az_property_tax_credit_household_income: 900 + # Table 2, 0 - 2,500 -> 502. + az_property_tax_credit: 502 + +- name: The other-state amount is limited to the unemployment compensation received + period: 2025 + input: + people: + head: + age: 70 + unemployment_compensation: 1_000 + az_other_state_unemployment_compensation: 3_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + az_property_tax_credit_unemployment: 1_000 + az_property_tax_credit_household_income: 1_000 + +- name: Strike benefits left out of federal gross income are household income + period: 2025 + input: + people: + head: + age: 70 + strike_benefits: 2_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 0 + # ARS 43-1072(H)(6)(c); Form 140PTC line I. + az_property_tax_credit_nontaxable_income: 2_000 + az_property_tax_credit_household_income: 2_000 + # Table 1, 1,951 - 2,050 -> 435. + az_property_tax_credit: 435 + +- name: Alimony that is not federally taxable is household income + period: 2025 + input: + people: + head: + age: 70 + alimony_income: 3_000 + divorce_year: 2020 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # A post-2018 divorce: no alimony in federal AGI. + adjusted_gross_income: 0 + # Form 140PTC line H: all alimony received. + az_property_tax_credit_nontaxable_income: 3_000 + az_property_tax_credit_household_income: 3_000 + # Table 1, 2,951 - 3,050 -> 212. + az_property_tax_credit: 212 + +- name: Federally taxable alimony counts once + period: 2025 + input: + people: + head: + age: 70 + alimony_income: 3_000 + divorce_year: 2015 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 3_000 + az_property_tax_credit_nontaxable_income: 0 + az_property_tax_credit_household_income: 3_000 + az_property_tax_credit: 212 + +- name: A dependent's nontaxable alimony and strike benefits count + period: 2025 + input: + people: + head: + age: 70 + rent: 6_000 + child: + age: 17 + is_tax_unit_dependent: true + strike_benefits: 1_000 + alimony_income: 500 + divorce_year: 2020 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + az_property_tax_credit_nontaxable_income: [0, 1_500] + az_property_tax_credit_household_income: 1_500 + # Table 2, 0 - 2,500 -> 502. + az_property_tax_credit: 502 + +- name: A co-resident's IRA deduction does not reduce household income + period: 2025 + input: + people: + parent: + age: 70 + employment_income: 2_000 + rent: 6_000 + son: + age: 40 + employment_income: 1_000 + traditional_ira_contributions: 500 + tax_units: + parent_unit: + members: [parent] + son_unit: + members: [son] + households: + household: + members: [parent, son] + state_code: AZ + output: + traditional_ira_deduction: [0, 500] + adjusted_gross_income: [2_000, 500] + # Ruling us-az:ptc-above-the-line-deductions: the son's unit adds his + # 1,000 of wages, not his federal AGI of 500. + az_property_tax_credit_income: [2_000, 1_000] + az_property_tax_credit_household_income: 3_000 + # Table 2, 2,951 - 3,100 -> 412 (at 2,500 it would be 502); property + # taxes 0.15 x 6,000 = 900. + az_property_tax_credit: [412, 0] diff --git a/policyengine_us/tests/test_az_property_tax_credit_household.py b/policyengine_us/tests/test_az_property_tax_credit_household.py new file mode 100644 index 00000000000..f0e2719ef8b --- /dev/null +++ b/policyengine_us/tests/test_az_property_tax_credit_household.py @@ -0,0 +1,380 @@ +"""Property tests for Arizona property tax credit household income and claimant. + +ARS 43-1072(H)(4) defines the household as "the household of the claimant and +such other persons as resided with the claimant in the claimant's homestead", +and (H)(5) household income as "all income received by all persons of a +household". A.A.C. R15-2C-502(A)(2) counts any person residing with the +claimant "whether or not the person is related to, or a dependent of, the +claimant". ARS 43-1072(G) allows "only one claimant per household per year". +Subsection (I) excludes Arizona unemployment insurance payments; Form 140PTC +line I counts other states' payments and tax-exempt strike benefits, and line +H all alimony received. + +Hypothesis draws Arizona homes of one to three tax units, each with a head, an +optional spouse and an optional dependent, and with wages, Arizona and +other-state unemployment, strike benefits, alimony (taxable or not), rent and +property tax. Each example builds one simulation that holds the drawn homes +and the variants the properties compare them with, so few simulations run: + +1. Every tax unit in a home sees the same household income, the sum of the + tax units' own amounts, and each eligible unit's claim is the schedule + amount at that income (Table 2 in a home of two or more), limited to the + unit's own property taxes paid. +2. At most one tax unit per home has a positive credit. +3. The claimant's credit is at least what any other eligible tax unit in the + home would get as claimant. +4. In a one-tax-unit home, household income equals the tax unit's income + before this change (federal AGI plus dependents' gross income, here wages, + all unemployment and taxable alimony), plus strike benefits and nontaxable + alimony, minus Arizona unemployment. Other-state unemployment was already + in the earlier income, as part of all unemployment, so it stays. +5. Raising a co-resident's wages never raises the home's total credit. Every + member of a home of two or more lives with others and uses Table 2, which + never rises with income (checked first). +6. Arizona unemployment never changes household income; other-state + unemployment changes it dollar for dollar. +7. Changing any federal above-the-line deduction other than loss_ald, + supplied as an input, never changes az_property_tax_credit_income + (ruling us-az:ptc-above-the-line-deductions: A.A.C. R15-2C-502(A)(1) + counts "the sum of all income not specifically excluded in A.R.S. + § 43-1072", and Form 140PTC lines A to I subtract no adjustment). Federal + AGI is checked to move, so the property is not vacuous. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2025 +# Form 140PTC line 13 for renters, modeled as +# gov.states.az.tax.income.credits.property_tax.rent_property_tax_rate. +RENT_PROPERTY_TAX_RATE = 0.15 + +# Most draws are zero or small, so that household income often stays under +# Table 2's $5,500 limit and credits are positive; some wages are large. +MONEY = st.sampled_from(["zero", "zero", "small", "large"]).flatmap( + lambda size: { + "zero": st.just(0.0), + "small": st.floats(0, 2_000).map(lambda x: round(x, 2)), + "large": st.floats(2_000, 40_000).map(lambda x: round(x, 2)), + }[size] +) +SMALL = st.sampled_from([True, True, False]).flatmap( + lambda zero: st.just(0.0) if zero else st.floats(0, 800).map(lambda x: round(x, 2)) +) + + +@st.composite +def _person(draw, adult: bool) -> dict: + unemployment = draw(SMALL) + return { + "age": draw(st.integers(60, 85) if adult else st.integers(5, 17)), + "employment_income": draw(MONEY if adult else SMALL), + "unemployment_compensation": unemployment, + # The other-state part of the person's unemployment compensation. + "az_other_state_unemployment_compensation": round( + unemployment * draw(st.sampled_from([0, 0.5, 1])), 2 + ), + "strike_benefits": draw(SMALL), + "alimony_income": draw(SMALL), + # 2010 is before the TCJA cutoff (taxable), 2020 after it (not). + "divorce_year": draw(st.sampled_from([2010, 2020])), + } + + +@st.composite +def _tax_unit(draw) -> dict: + members = [("head", draw(_person(adult=True)))] + if draw(st.booleans()): + members.append(("spouse", draw(_person(adult=True)))) + if draw(st.integers(0, 3)) == 0: + dependent = draw(_person(adult=False)) + dependent["is_tax_unit_dependent"] = True + members.append(("dependent", dependent)) + return { + "members": members, + "rent": draw(st.sampled_from([0.0, 2_000.0, 6_000.0, 12_000.0])), + "real_estate_taxes": draw(st.sampled_from([0.0, 0.0, 300.0, 1_000.0])), + } + + +HOMES = st.lists( + st.lists(_tax_unit(), min_size=1, max_size=3), + min_size=4, + max_size=6, +) + + +def _situation(homes: list) -> tuple: + """A situation with one household per home, and the tax unit index of + each household's tax units.""" + year = str(YEAR) + people, tax_units, households = {}, {}, {} + unit_home = [] + for h, home in enumerate(homes): + home_members = [] + for u, unit in enumerate(home): + names = [] + for role, values in unit["members"]: + name = f"{role}_{h}_{u}" + person = {k: {year: v} for k, v in values.items()} + if role == "head": + person["rent"] = {year: unit["rent"]} + person["real_estate_taxes"] = {year: unit["real_estate_taxes"]} + people[name] = person + names.append(name) + tax_units[f"tax_unit_{h}_{u}"] = {"members": names} + unit_home.append(h) + home_members += names + households[f"household_{h}"] = { + "members": home_members, + "state_code": {year: "AZ"}, + } + situation = {"people": people, "tax_units": tax_units, "households": households} + return situation, np.array(unit_home) + + +def _changed(homes: list, change) -> list: + """A deep copy of homes with change(person_values) applied to the first + adult of the last tax unit of each home.""" + copied = [ + [ + {**unit, "members": [(r, dict(v)) for r, v in unit["members"]]} + for unit in home + ] + for home in homes + ] + for home in copied: + change(home[-1]["members"][0][1]) + return copied + + +def _reference_unit_income(unit: dict) -> float: + """The tax unit's income before this change: wages, all unemployment and + taxable alimony of every member (federal AGI for the filers, gross income + for a dependent; no other income or deductions are drawn).""" + return sum( + v["employment_income"] + + v["unemployment_compensation"] + + (v["alimony_income"] if v["divorce_year"] < 2019 else 0) + for _, v in unit["members"] + ) + + +def _added(unit: dict) -> float: + """Strike benefits and nontaxable alimony.""" + return sum( + v["strike_benefits"] + (v["alimony_income"] if v["divorce_year"] >= 2019 else 0) + for _, v in unit["members"] + ) + + +def test_table_amounts_never_rise_with_income(): + from policyengine_us.system import system + + p = system.parameters(YEAR).gov.states.az.tax.income.credits.property_tax + for table in [p.amount.living_alone, p.amount.cohabitating]: + amounts = list(table.amounts) + assert all(a >= b for a, b in zip(amounts, amounts[1:])) + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(homes=HOMES, raise_by=st.floats(1, 20_000), uc=st.floats(1, 3_000)) +def test_household_income_and_one_claimant(homes, raise_by, uc): + raise_by, uc = round(raise_by, 2), round(uc, 2) + + def raise_wages(v): + v["employment_income"] = round(v["employment_income"] + raise_by, 2) + + def add_arizona_uc(v): + v["unemployment_compensation"] = round(v["unemployment_compensation"] + uc, 2) + + def add_other_state_uc(v): + add_arizona_uc(v) + v["az_other_state_unemployment_compensation"] = round( + v["az_other_state_unemployment_compensation"] + uc, 2 + ) + + variants = [ + homes, + _changed(homes, raise_wages), + _changed(homes, add_arizona_uc), + _changed(homes, add_other_state_uc), + ] + n = len(homes) + situation, unit_home = _situation([home for v in variants for home in v]) + sim = Simulation(situation=situation) + unit_income = sim.calculate("az_property_tax_credit_income", YEAR) + household_income = sim.calculate("az_property_tax_credit_household_income", YEAR) + # The household income each tax unit reads, as + # az_property_tax_credit_potential reads it. + seen_income = sim.populations["tax_unit"].household( + "az_property_tax_credit_household_income", YEAR + ) + credit = sim.calculate("az_property_tax_credit", YEAR) + potential = sim.calculate("az_property_tax_credit_potential", YEAR) + eligible = sim.calculate("az_property_tax_credit_eligible", YEAR) + total_credit = np.bincount(unit_home, weights=credit, minlength=4 * n) + + # 1. Each home's income is the sum of its tax units' amounts, and every + # tax unit in the home sees it. + np.testing.assert_allclose( + household_income, + np.bincount(unit_home, weights=unit_income, minlength=4 * n), + atol=0.01, + ) + np.testing.assert_allclose(seen_income, household_income[unit_home], atol=0.01) + # Each eligible tax unit's claim is the lesser of its property taxes paid + # and the schedule amount at the home's income, floored at zero: Table 2 + # for every unit in a home of two or more people, Table 1 otherwise. + from policyengine_us.system import system + + p = system.parameters(YEAR).gov.states.az.tax.income.credits.property_tax + all_units = [unit for v in variants for home in v for unit in home] + home_size = np.bincount( + unit_home, + weights=[len(unit["members"]) for unit in all_units], + minlength=4 * n, + )[unit_home] + schedule_income = np.maximum(household_income[unit_home], 0) + table = np.where( + home_size > 1, + p.amount.cohabitating.calc(schedule_income), + p.amount.living_alone.calc(schedule_income), + ) + taxes_paid = np.array( + [ + unit["real_estate_taxes"] + RENT_PROPERTY_TAX_RATE * unit["rent"] + for unit in all_units + ] + ) + np.testing.assert_allclose( + potential[eligible], np.minimum(table, taxes_paid)[eligible], atol=0.01 + ) + + for h in range(4 * n): + in_home = unit_home == h + # 2. At most one tax unit per home has a positive credit. + assert (credit[in_home] > 0).sum() <= 1 + # 3. The claimant's credit is the largest eligible claim. + if eligible[in_home].any(): + assert credit[in_home].max() >= potential[in_home & eligible].max() - 0.01 + + base = slice(0, n) + base_units = [unit for home in homes for unit in home] + base_unit_home = unit_home[unit_home < n] + # 4. One-tax-unit homes: the earlier income plus the added items, less + # Arizona unemployment (the drawn unemployment less its other-state part). + for h, home in enumerate(homes): + if len(home) == 1: + unit = home[0] + arizona_uc = sum( + v["unemployment_compensation"] + - v["az_other_state_unemployment_compensation"] + for _, v in unit["members"] + ) + expected = _reference_unit_income(unit) + _added(unit) - arizona_uc + assert abs(household_income[h] - expected) < 0.01 + # The reference matches the model's federal AGI for the filers. + filers = sum( + v["employment_income"] + + v["unemployment_compensation"] + + (v["alimony_income"] if v["divorce_year"] < 2019 else 0) + for r, v in unit["members"] + if r != "dependent" + ) + unit_index = base_units.index(unit) + agi = sim.calculate("adjusted_gross_income", YEAR)[unit_index] + assert abs(agi - filers) < 0.01 + assert len(base_unit_home) == len(base_units) + + # 5. Raising a co-resident's wages never raises a shared home's credit. + shared = np.array([sum(len(u["members"]) for u in home) > 1 for home in homes]) + raised = slice(n, 2 * n) + assert (total_credit[raised][shared] <= total_credit[base][shared] + 0.01).all() + + # 6. Arizona unemployment leaves household income unchanged; other-state + # unemployment raises it dollar for dollar. + np.testing.assert_allclose( + household_income[2 * n : 3 * n], household_income[base], atol=0.01 + ) + np.testing.assert_allclose( + household_income[3 * n : 4 * n], household_income[base] + uc, atol=0.01 + ) + + +@settings( + max_examples=4, + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + wages=st.floats(0, 30_000), + self_employment=st.floats(-5_000, 30_000), + social_security=st.sampled_from([0.0, 12_000.0, 30_000.0]), + dependent_wages=st.sampled_from([0.0, 3_000.0]), + amount=st.floats(1, 5_000), +) +def test_above_the_line_deductions_do_not_change_income( + wages, self_employment, social_security, dependent_wages, amount +): + from policyengine_us.system import system + + p = system.parameters(YEAR).gov.irs.ald + deductions = [d for d in p.deductions if d != "loss_ald"] + year = str(YEAR) + amount = round(amount, 2) + people, tax_units, households = {}, {}, {} + # Variant 0 is the base; variant i + 1 supplies deductions[i] as an input. + # Every variant supplies every listed deduction, at zero unless varied, so + # the base and the variant differ only in that one input. + for i in range(len(deductions) + 1): + varied = deductions[i - 1] if i else None + head = { + "age": {year: 70}, + "employment_income": {year: round(wages, 2)}, + "self_employment_income": {year: round(self_employment, 2)}, + "social_security": {year: social_security}, + } + dependent = { + "age": {year: 15}, + "is_tax_unit_dependent": {year: True}, + "employment_income": {year: dependent_wages}, + } + unit = {"members": [f"head_{i}", f"dependent_{i}"]} + for deduction in deductions: + value = amount if deduction == varied else 0.0 + if system.variables[deduction].entity.key == "person": + # On the dependent as well: federal AGI subtracts it only + # for filer amounts recorded on a dependent, and the add-back + # must match either way. + head[deduction] = {year: value} + dependent[deduction] = {year: value} + else: + unit[deduction] = {year: value} + people[f"head_{i}"] = head + people[f"dependent_{i}"] = dependent + tax_units[f"tax_unit_{i}"] = unit + households[f"household_{i}"] = { + "members": [f"head_{i}", f"dependent_{i}"], + "state_code": {year: "AZ"}, + } + sim = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + income = sim.calculate("az_property_tax_credit_income", YEAR) + agi = sim.calculate("adjusted_gross_income", YEAR) + for i, deduction in enumerate(deductions, start=1): + assert abs(income[i] - income[0]) < 0.01, deduction + # Federal AGI moves, so the add-back is what keeps income still. + assert agi[i] < agi[0] - 0.01, deduction diff --git a/policyengine_us/tests/test_az_property_tax_credit_source_reforms.py b/policyengine_us/tests/test_az_property_tax_credit_source_reforms.py new file mode 100644 index 00000000000..57ef0d940e3 --- /dev/null +++ b/policyengine_us/tests/test_az_property_tax_credit_source_reforms.py @@ -0,0 +1,124 @@ +"""Arizona property tax credit income when a reform drops a federal gross +income source. + +az_property_tax_credit_agi takes taxable_unemployment_compensation out of +federal AGI, and az_property_tax_credit_nontaxable_income adds the alimony +that federal gross income leaves out, only as far as +gov.irs.gross_income.sources lists the source. These tests drop both sources +and check that each amount is still counted exactly once. + +Two Arizona single filers, one per household, aged 40, with no other income +or deductions: + +- "uc": $30,000 wages and $5,000 unemployment compensation, $2,000 of it paid + under another state's law (Form 140PTC line I) and $3,000 under Arizona's + (excluded by ARS 43-1072(I)). +- "alimony": $20,000 wages and $4,000 alimony under a 2010 divorce, so + taxable_alimony_income is the full $4,000 at baseline. +""" + +import numpy as np +import pytest +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + +YEAR = 2025 + + +DROPPED = ["taxable_unemployment_compensation", "taxable_alimony_income"] + + +class drop_sources(Reform): + # One reform drops both sources: neither filer has the other's income, so + # each branch is checked on its own household, and only one reformed + # system is built. + def apply(self): + def modify(parameters): + node = parameters.gov.irs.gross_income.sources + start = instant(f"{YEAR}-01-01") + kept = [s for s in node(start) if s not in DROPPED] + node.update(start=start, stop=instant(f"{YEAR}-12-31"), value=kept) + return parameters + + self.modify_parameters(modify) + + +def _situation() -> dict: + year = str(YEAR) + people = { + "uc": { + "age": {year: 40}, + "employment_income": {year: 30_000}, + "unemployment_compensation": {year: 5_000}, + "az_other_state_unemployment_compensation": {year: 2_000}, + }, + "alimony": { + "age": {year: 40}, + "employment_income": {year: 20_000}, + "alimony_income": {year: 4_000}, + "divorce_year": {year: 2010}, + }, + } + return { + "people": people, + "tax_units": {f"{name}_unit": {"members": [name]} for name in people}, + "households": { + f"{name}_home": {"members": [name], "state_code": {year: "AZ"}} + for name in people + }, + } + + +UC, ALIMONY = 0, 1 # Index of each filer's person, tax unit and household. + + +@pytest.fixture(scope="module") +def baseline(): + return Simulation(situation=_situation()) + + +@pytest.fixture(scope="module") +def reformed(): + return Simulation(situation=_situation(), reform=drop_sources) + + +def test_unemployment_left_out_of_gross_income_is_not_subtracted(baseline, reformed): + # Federal AGI: $30,000 wages + $5,000 unemployment at baseline; the + # reform leaves the unemployment out, so $30,000. + assert baseline.calculate("adjusted_gross_income", YEAR)[UC] == 35_000 + assert reformed.calculate("adjusted_gross_income", YEAR)[UC] == 30_000 + # az_property_tax_credit_agi = federal AGI - unemployment that federal AGI + # holds. Baseline: 35,000 - 5,000 = 30,000. Reform: 30,000 - 0 = 30,000. + # Subtracting the $5,000 the reform never put in would give 25,000. + for sim in (baseline, reformed): + assert sim.calculate("az_property_tax_credit_agi", YEAR)[UC] == 30_000 + # Household income adds back the $2,000 other-state part (line I), once: + # 30,000 + 2,000 = 32,000 in both. + for sim in (baseline, reformed): + assert np.isclose( + sim.calculate("az_property_tax_credit_household_income", YEAR)[UC], + 32_000, + ) + + +def test_alimony_left_out_of_gross_income_is_added_in_full(baseline, reformed): + # Pre-2019 divorce: all $4,000 is taxable_alimony_income at baseline. + assert baseline.calculate("taxable_alimony_income", YEAR)[ALIMONY] == 4_000 + # Federal AGI: 20,000 + 4,000 = 24,000 at baseline, 20,000 under the reform. + assert baseline.calculate("adjusted_gross_income", YEAR)[ALIMONY] == 24_000 + assert reformed.calculate("adjusted_gross_income", YEAR)[ALIMONY] == 20_000 + # Nontaxable income = alimony - the part federal gross income holds. + # Baseline: 4,000 - 4,000 = 0. Reform: none is held, so all 4,000. + # Reading taxable_alimony_income under the reform would give 0. + nontaxable = "az_property_tax_credit_nontaxable_income" + assert baseline.calculate(nontaxable, YEAR)[ALIMONY] == 0 + assert reformed.calculate(nontaxable, YEAR)[ALIMONY] == 4_000 + # Household income counts the alimony once either way: + # baseline 24,000 (AGI) + 0; reform 20,000 (AGI) + 4,000. Both 24,000. + for sim in (baseline, reformed): + assert np.isclose( + sim.calculate("az_property_tax_credit_household_income", YEAR)[ALIMONY], + 24_000, + ) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_other_state_unemployment_compensation.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_other_state_unemployment_compensation.py new file mode 100644 index 00000000000..ba6bcc62840 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_other_state_unemployment_compensation.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class az_other_state_unemployment_compensation(Variable): + value_type = float + entity = Person + label = "Unemployment compensation paid under another state's law" + unit = USD + definition_period = YEAR + default_value = 0 + documentation = ( + "The part of a person's unemployment compensation paid under the " + "unemployment insurance law of a state other than Arizona. The Arizona " + "property tax credit excludes Arizona unemployment insurance payments " + "from household income (ARS 43-1072(I)) but counts other states' " + "payments (2025 Form 140PTC instructions, line I). The rest of " + "unemployment compensation is treated as paid under Arizona law, as a " + "claimant must be a full-year Arizona resident (ARS 43-1072(H)(1))." + ) + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(1), (I) + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] 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 6ffe7f57b75..7bcc2b33349 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 @@ -8,50 +8,14 @@ class az_property_tax_credit(Variable): unit = USD definition_period = YEAR reference = [ - "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(B) + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(B), (G) "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): - p = parameters(period).gov.states.az.tax.income.credits.property_tax - income = tax_unit("az_property_tax_credit_income", period) - - # ARS 43-1072(A)(3): a claimant who "lived with a spouse or one or - # more persons" uses the Table 2 (B)(2) schedule, while a claimant who - # "did not live with a spouse or any other persons" uses the Table 1 - # (B)(1) schedule. Any co-resident (spouse, adult child, sibling, - # roommate) triggers Table 2. Use the broadest available signal: - # explicit cohabitating-spouses input, a married couple filing jointly, - # or a household with more than one person. - household_size = tax_unit.household("household_size", period) - lives_with_others = ( - tax_unit("cohabitating_spouses", period) - | tax_unit("tax_unit_married", period) - | (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(schedule_income), - p.amount.living_alone.calc(schedule_income), - ) - - # ARS 43-1072(B): the credit is the lesser of the table amount and - # property taxes paid. For renters, taxes paid is rent multiplied by - # the landlord's property tax factor from Form 201 (Form 140PTC line - # 13); the rent_property_tax_rate parameter approximates this - # landlord-specific factor. - real_estate_taxes = add(tax_unit, period, ["real_estate_taxes"]) - rent = add(tax_unit, period, ["rent"]) - property_taxes_paid = real_estate_taxes + p.rent_property_tax_rate * rent - - return min_(cap, property_taxes_paid) + # ARS 43-1072(G): one claimant per household per year. Every other + # eligible tax unit in the household gets no credit. + potential = tax_unit("az_property_tax_credit_potential", period) + claimant = tax_unit("az_property_tax_credit_claimant", period) + return potential * claimant 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 febb7a11b74..026ffb8d2fd 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 @@ -11,9 +11,14 @@ class az_property_tax_credit_agi(Variable): "Adjusted gross income as defined for Arizona property tax credit purposes, " "for members who are not dependents. Per ARS 43-1072(H)(6) and ITR 12-1, " "this starts with federal AGI and subtracts taxable Social Security (ARS " - "43-1072(I)). Federal AGI already leaves out most other subsection (I) " - "items, but it counts unemployment whichever state paid it, so Arizona " - "unemployment is not removed. Unlike regular Arizona income tax, this does " + "43-1072(I)) and the unemployment compensation it holds, as (I) excludes " + "Arizona unemployment insurance payments; other states' payments are " + "added back in az_property_tax_credit_unemployment. Federal AGI already " + "leaves out most other subsection (I) items. Federal above-the-line " + "deductions other than the business and capital loss deduction are " + "added back, as A.A.C. R15-2C-502(A)(1) counts all income not excluded " + "by ARS 43-1072 and Form 140PTC lines A to I subtract no adjustment. " + "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, in place of the capital gains and capital " @@ -37,10 +42,10 @@ def formula(tax_unit, period, parameters): federal_agi = tax_unit("adjusted_gross_income", period) # Per ARS 43-1072(I) and ITR 12-1 "Items Excluded from Income", - # we ONLY exclude Social Security benefits (and other items like - # railroad retirement, workers comp, AZ unemployment, veterans - # disability pensions, welfare, and gifts - but these are typically - # not in Federal AGI anyway). + # we exclude Social Security benefits and Arizona unemployment + # compensation (below). Other excluded items, like railroad + # retirement, workers comp, veterans disability pensions, welfare and + # gifts, are typically not in Federal AGI anyway. # # Federal AGI only includes the TAXABLE portion of Social Security, # but for property tax credit, ALL Social Security should be excluded. @@ -102,6 +107,49 @@ def formula(tax_unit, period, parameters): not_dependent * person("az_property_tax_credit_capital_gains", period) ) + # ARS 43-1072(I) excludes "payments received under Arizona state + # unemployment insurance laws". Federal AGI holds each non-dependent's + # taxable_unemployment_compensation; all of it is taken out here, and + # az_property_tax_credit_unemployment adds back the part paid under + # another state's law (Form 140PTC line I). Only what federal AGI + # holds is taken out. + if "taxable_unemployment_compensation" in gross_income_sources: + federal_unemployment = tax_unit.sum( + not_dependent * person("taxable_unemployment_compensation", period) + ) + else: + federal_unemployment = 0 + + # Ruling us-az:ptc-above-the-line-deductions: household income does not + # subtract federal above-the-line deductions. ARS 43-1072(H)(6)(a) + # makes income "Adjusted gross income as defined by the department", + # and A.A.C. R15-2C-502(A)(1) defines it as "the sum of all income not + # specifically excluded in A.R.S. § 43-1072". R15-2C-502(C) nets only + # "net income or loss from business or farm activities" ((C)(1)), + # "gross income from rent or royalty activities less deductions" + # ((C)(2)) and capital gains ((C)(3)). ITR 12-1 items (1) to (11) and + # Form 140PTC Part 1 lines A to I subtract no adjustment. So every + # deduction federal AGI takes off a non-dependent's income (IRA, + # student loan interest, educator, HSA, half of self-employment tax, + # self-employed health insurance and pension, alimony paid, and the + # rest of the list) is added back. Income counts "whether or not + # subject to Arizona income tax", so the Puerto Rico, possession and + # savings bond exclusions come back too. + # loss_ald is left out: it carries the business losses that ITR 12-1 + # items (3) to (5) count, and its capital part is handled above. The + # sum mirrors above_the_line_deductions, so only what federal AGI + # holds comes back: a reform that drops a deduction from the list + # adds nothing back. Dependents' income never had them subtracted. + added_back_deductions = [ + deduction for deduction in p.ald.deductions if deduction != "loss_ald" + ] + federal_deductions = tax_unit_non_dep_add( + tax_unit, + period, + added_back_deductions, + include_dependents=p.ald.filer_amounts_recorded_on_dependents, + ) + # 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. @@ -111,4 +159,6 @@ def formula(tax_unit, period, parameters): - federal_capital_gains + federal_capital_loss + line_d + - federal_unemployment + + federal_deductions ) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_claimant.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_claimant.py new file mode 100644 index 00000000000..20adfbbecc5 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_claimant.py @@ -0,0 +1,38 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_claimant(Variable): + value_type = bool + entity = TaxUnit + label = "Claimant of the household's Arizona property tax credit" + definition_period = YEAR + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(G), (H)(1) + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + ] + defined_for = StateCode.AZ + + def formula(tax_unit, period, parameters): + # ARS 43-1072(G): "Only one claimant per household per year shall be + # entitled to a tax credit"; A.A.C. R15-2C-502(D) says the same. Under + # (H)(1), when two members qualify "they may determine between them as + # to whom the claimant shall be". As a modeling convention, the + # household picks the eligible tax unit whose claim + # (az_property_tax_credit_potential) is largest. A tie goes to the tax + # unit holding the household head, and otherwise to the tax unit whose + # head comes first in the simulation's person order. + person = tax_unit.members + candidate = person("is_tax_unit_head", period) & person.tax_unit( + "az_property_tax_credit_eligible", period + ) + claim = person.tax_unit("az_property_tax_credit_potential", period) + largest = person.household.max(where(candidate, claim, -np.inf)) + tied = candidate & (claim >= largest) + holds_household_head = person.tax_unit.any(person("is_household_head", period)) + # get_rank sorts by members_position, so rank 0 goes to the first tied + # tax unit holding the household head, or, if none does, to the tied + # tax unit whose head comes first in the simulation's person order. + rank = person.get_rank( + person.household, where(holds_household_head, 0, 1), condition=tied + ) + return tax_unit.any(tied & (rank == 0)) 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 45b20a29896..245bd005964 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 @@ -14,7 +14,10 @@ class az_property_tax_credit_dependent_income(Variable): "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; other " - "losses count in full." + "losses count in full. Unemployment compensation is left out here, as " + "ARS 43-1072(I) excludes Arizona's; other states' payments are added in " + "az_property_tax_credit_unemployment, and nontaxable strike benefits " + "and alimony in az_property_tax_credit_nontaxable_income." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(4)-(6), (I) @@ -35,18 +38,20 @@ def formula(tax_unit, period, parameters): # source is added with its sign: a business, farm, rental, partnership # or estate loss offsets the dependent's other income, as ITR 12-1 # items (3) to (5) use the year's loss. 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 strike benefits and alimony that is not - # federally taxable. + # Arizona's: unemployment compensation is skipped below, and strike + # benefits and alimony that federal gross income leaves out are added + # in az_property_tax_credit_nontaxable_income. sources = parameters(period).gov.irs.gross_income.sources income = 0 for source in sources: # ARS 43-1072(I): Social Security benefits are not income. # Capital gains and losses, including capital gain distributions, - # are added below as line D. + # are added below as line D. ARS 43-1072(I): Arizona unemployment + # insurance payments are not income; other states' payments are + # in az_property_tax_credit_unemployment. if source in ( "taxable_social_security", + "taxable_unemployment_compensation", "capital_gains", "non_sch_d_capital_gains", ): diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_household_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_household_income.py new file mode 100644 index 00000000000..fbf017e1065 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_household_income.py @@ -0,0 +1,36 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_household_income(Variable): + value_type = float + entity = Household + label = "Household 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: the az_property_tax_credit_income of every tax unit in the " + "household, so co-residents who file their own returns count. 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)(4), (H)(5) + "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=3", + ] + defined_for = StateCode.AZ + + def formula(household, period, parameters): + # ARS 43-1072(H)(4): "household" means "the household of the claimant + # and such other persons as resided with the claimant in the + # claimant's homestead during the taxable year"; (H)(5): household + # income is "all income received by all persons of a household". + # A.A.C. R15-2C-502(A)(2) and (B): a member is any person residing with + # the claimant, related or not, and each member's separately + # determined income is combined. Each tax unit's amount is counted + # once, through its head (as marginal_tax_rate_helpers sums a tax + # unit amount over a household). + person = household.members + head = person("is_tax_unit_head", period) + unit_income = person.tax_unit("az_property_tax_credit_income", period) + return household.sum(unit_income * head) 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 dbcca77417f..ec0be5cf219 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,13 +4,15 @@ class az_property_tax_credit_income(Variable): value_type = float entity = TaxUnit - label = "Income for the Arizona property tax credit" + label = "Arizona property tax credit income of the tax unit's members" 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." + "The income of the tax unit's members, dependents included, for the " + "Arizona property tax credit (Form 140PTC Part 1 lines A to I). " + "az_property_tax_credit_household_income adds it up over every tax " + "unit in the household to give line J. It can be negative after a " + "capital loss." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(6) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_nontaxable_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_nontaxable_income.py new file mode 100644 index 00000000000..dd6aaf7d7dc --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_nontaxable_income.py @@ -0,0 +1,40 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_nontaxable_income(Variable): + value_type = float + entity = Person + label = "Arizona property tax credit income left out of federal gross income" + unit = USD + definition_period = YEAR + documentation = ( + "Strike benefits and alimony received that federal gross income leaves " + "out, which Arizona household income counts for every household " + "member: nontaxable strike benefits (ARS 43-1072(H)(6)(c); Form " + "140PTC line I) and all alimony received (line H). Only the part of " + "each that federal gross income does not already hold is added, so " + "nothing counts twice." + ) + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(5), (H)(6) + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] + defined_for = StateCode.AZ + + def formula(person, period, parameters): + sources = parameters(period).gov.irs.gross_income.sources + # Federal gross income (irs_gross_income for members who are not + # dependents, the same sources in az_property_tax_credit_dependent_income + # for dependents) holds strike benefits only if they are a listed source. + strike = person("strike_benefits", period) + excluded_strike = 0 if "strike_benefits" in sources else strike + # Line H counts "all alimony received by each member of the household"; + # federal gross income holds taxable_alimony_income (alimony under + # pre-2019 divorce instruments). + alimony = person("alimony_income", period) + if "taxable_alimony_income" in sources: + taxable_alimony = person("taxable_alimony_income", period) + excluded_alimony = max_(alimony - taxable_alimony, 0) + else: + excluded_alimony = alimony + return excluded_strike + excluded_alimony diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_potential.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_potential.py new file mode 100644 index 00000000000..77b3a66b858 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_potential.py @@ -0,0 +1,68 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_potential(Variable): + value_type = float + entity = TaxUnit + label = "Arizona property tax credit if the tax unit were the household's claimant" + unit = USD + definition_period = YEAR + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(A)(3), (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): + p = parameters(period).gov.states.az.tax.income.credits.property_tax + # The schedules apply to household income, which counts every + # member of the household, including co-residents in other tax units. + income = tax_unit.household("az_property_tax_credit_household_income", period) + + # ARS 43-1072(A)(3): a claimant who "lived with a spouse or one or + # more persons" uses the Table 2 (B)(2) schedule, while a claimant who + # "did not live with a spouse or any other persons" uses the Table 1 + # (B)(1) schedule. Any co-resident (spouse, adult child, sibling, + # roommate) triggers Table 2. Use the broadest available signal: + # explicit cohabitating-spouses input, a married couple filing jointly, + # or a household with more than one person. + household_size = tax_unit.household("household_size", period) + lives_with_others = ( + tax_unit("cohabitating_spouses", period) + | tax_unit("tax_unit_married", period) + | (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(schedule_income), + p.amount.living_alone.calc(schedule_income), + ) + + # ARS 43-1072(B): the credit is the lesser of the table amount and + # property taxes paid. For renters, taxes paid is rent multiplied by + # the landlord's property tax factor from Form 201 (Form 140PTC line + # 13); the rent_property_tax_rate parameter approximates this + # landlord-specific factor. + # Ruling us-az:ptc-rent-and-taxes-shared-home: each tax unit counts + # only its own property taxes and rent, even where household income + # spans tax units. ARS 43-1072(A)(2) requires that "such person paid + # either property taxes or rent", and (B) caps the credit at "the + # amount of property taxes actually paid". The 2025 instructions for + # line 12 ask for "property taxes that you actually paid", Form 140PTC + # line 13 for "property taxes paid by your landlord on your portion of + # rents", and the Form 201 instructions for line 13 say "Enter the + # total rents you paid for the taxable year." + real_estate_taxes = add(tax_unit, period, ["real_estate_taxes"]) + rent = add(tax_unit, period, ["rent"]) + property_taxes_paid = real_estate_taxes + p.rent_property_tax_rate * rent + + return min_(cap, property_taxes_paid) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_unemployment.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_unemployment.py new file mode 100644 index 00000000000..144c65ba556 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_unemployment.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_unemployment(Variable): + value_type = float + entity = Person + label = "Unemployment compensation counted for the Arizona property tax credit" + unit = USD + definition_period = YEAR + documentation = ( + "A household member's unemployment compensation paid under another " + "state's law, which the Arizona property tax credit counts in full " + "(Form 140PTC line I). It is at most the member's unemployment " + "compensation. Arizona unemployment insurance payments are not income " + "for the credit (ARS 43-1072(I)); az_property_tax_credit_agi and " + "az_property_tax_credit_dependent_income take out all unemployment " + "compensation that federal gross income holds, and this adds back the " + "other states' part." + ) + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(I) + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] + defined_for = StateCode.AZ + + def formula(person, period, parameters): + total = max_(person("total_unemployment_compensation", period), 0) + other_state = max_( + person("az_other_state_unemployment_compensation", period), 0 + ) + return min_(other_state, total)