From a98da67704402a2f133bbbd40a213d4e0944fbaf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:27:17 +0000 Subject: [PATCH 1/8] Count every co-resident's income in the Arizona property tax credit ARS 43-1072(H)(4)-(5) defines household income as the income of everyone who lived in the claimant's homestead, and (G) allows one claimant per household. The credit summed only the claimant's tax unit and let every qualifying tax unit in a home claim. Household income now adds each tax unit's amount over the household, and only the eligible tax unit with the largest claim receives the credit. Subsection (I) excludes Arizona unemployment, so federal AGI's unemployment is taken out and other states' payments (a new input) added back; nontaxable strike benefits and alimony left out of federal gross income are added (lines H and I). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HuSwzHSJwmgBQXw2d --- changelog.d/az-ptc-household-income.fixed.md | 1 + .../credits/property_tax/income_sources.yaml | 10 +- .../az_property_tax_credit_claimant.yaml | 155 +++++++++++ ..._property_tax_credit_household_income.yaml | 252 ++++++++++++++++++ ...z_other_state_unemployment_compensation.py | 23 ++ .../az_property_tax_credit.py | 48 +--- .../az_property_tax_credit_agi.py | 30 ++- .../az_property_tax_credit_claimant.py | 38 +++ ...az_property_tax_credit_dependent_income.py | 17 +- ...az_property_tax_credit_household_income.py | 36 +++ .../az_property_tax_credit_income.py | 10 +- ...z_property_tax_credit_nontaxable_income.py | 40 +++ .../az_property_tax_credit_potential.py | 60 +++++ .../az_property_tax_credit_unemployment.py | 31 +++ 14 files changed, 691 insertions(+), 60 deletions(-) create mode 100644 changelog.d/az-ptc-household-income.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_claimant.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_household_income.yaml create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_other_state_unemployment_compensation.py create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_claimant.py create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_household_income.py create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_nontaxable_income.py create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_potential.py create mode 100644 policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_unemployment.py 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..c12ec4de4a9 --- /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, and count other states' unemployment, nontaxable strike benefits and nontaxable alimony. 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..00d300cdce3 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 @@ -27,6 +27,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/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..75a51a9fc63 --- /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 first tax unit in household 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..d6602c9e4ac --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_household_income.yaml @@ -0,0 +1,252 @@ +# 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 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..746cdeb8135 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,11 @@ 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. 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 +39,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 +104,19 @@ 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 + # 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 +126,5 @@ def formula(tax_unit, period, parameters): - federal_capital_gains + federal_capital_loss + line_d + - federal_unemployment ) 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..6ea6efdfb86 --- /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 first such + # tax unit in household member 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's stable sort gives rank 0 to the first tied tax unit + # holding the household head, or, if none does, to the first tied + # tax unit in household member 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..8c315033869 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_potential.py @@ -0,0 +1,60 @@ +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. Each tax unit counts its own property + # taxes and rent, as before household income spanned tax units. + 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) From 7e84eda4e08bdfc11b2280ccb9f332b89a1cb651 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:42:45 +0000 Subject: [PATCH 2/8] Add property tests for Arizona household income and the one-claimant rule Hypothesis draws homes of one to three tax units and checks, in one simulation per example, that every tax unit sees the same household income, that at most one unit per home gets a credit and it is the largest claim, that Arizona unemployment never changes household income while other states' does dollar for dollar, and that a co-resident's raise never raises a shared home's credit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HuSwzHSJwmgBQXw2d --- .../test_az_property_tax_credit_household.py | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 policyengine_us/tests/test_az_property_tax_credit_household.py 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..1221f5f6212 --- /dev/null +++ b/policyengine_us/tests/test_az_property_tax_credit_household.py @@ -0,0 +1,276 @@ +"""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. +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. +""" + +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) + + 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 + ) From a5f577afb49b6da19dc5d66f5a9272f79166ca25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:42:56 +0000 Subject: [PATCH 3/8] Check each Arizona claim reads household income in the property test A lookup on the tax unit's own income passed every earlier property, so the test now compares each eligible unit's claim with the schedule amount at the home's income, limited to the unit's own property taxes paid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HuSwzHSJwmgBQXw2d --- .../test_az_property_tax_credit_household.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/test_az_property_tax_credit_household.py b/policyengine_us/tests/test_az_property_tax_credit_household.py index 1221f5f6212..e555b533a91 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_household.py +++ b/policyengine_us/tests/test_az_property_tax_credit_household.py @@ -17,7 +17,9 @@ 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. + 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. @@ -224,6 +226,33 @@ def add_other_state_uc(v): 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 From 668c7e0f9c6a98baac61260f14e2e028761e18e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:49:00 +0000 Subject: [PATCH 4/8] Add back above-the-line deductions in Arizona property tax credit income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 neither ITR 12-1 nor Form 140PTC lines A to I subtracts an adjustment. Federal AGI took the IRA, student loan, educator, HSA, half-SE-tax and other deductions off non-dependents' income, so household income was understated. The add-back mirrors above_the_line_deductions over the same list, minus loss_ald, whose business part the rule counts. Ruling us-az:ptc-rent-and-taxes-shared-home keeps each tax unit's own rent and taxes; the comment now gives the statute and form words, and a YAML case covers a claimant whose co-resident pays the rent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GvzSJyt6QD8rg4qb2 --- changelog.d/az-ptc-household-income.fixed.md | 2 +- .../credits/property_tax/income_sources.yaml | 7 +- .../income/az_property_tax_credit_agi.yaml | 81 +++++++++++++++++++ .../credits/az_property_tax_credit.yaml | 26 ++++++ ..._property_tax_credit_household_income.yaml | 32 ++++++++ .../test_az_property_tax_credit_household.py | 75 +++++++++++++++++ .../az_property_tax_credit_agi.py | 38 ++++++++- .../az_property_tax_credit_potential.py | 12 ++- 8 files changed, 267 insertions(+), 6 deletions(-) diff --git a/changelog.d/az-ptc-household-income.fixed.md b/changelog.d/az-ptc-household-income.fixed.md index c12ec4de4a9..3d26f1626a2 100644 --- a/changelog.d/az-ptc-household-income.fixed.md +++ b/changelog.d/az-ptc-household-income.fixed.md @@ -1 +1 @@ -Count the income of everyone living in an Arizona property tax credit claimant's home, allow one claimant per household, exclude Arizona unemployment, and count other states' unemployment, nontaxable strike benefits and nontaxable alimony. +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 00d300cdce3..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 @@ -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 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..ef2d70559e4 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,84 @@ # 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 + 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_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_household_income.yaml index d6602c9e4ac..7a7b193ae23 100644 --- 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 @@ -250,3 +250,35 @@ 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 index e555b533a91..f0e2719ef8b 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_household.py +++ b/policyengine_us/tests/test_az_property_tax_credit_household.py @@ -33,6 +33,12 @@ 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 @@ -303,3 +309,72 @@ def add_other_state_uc(v): 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/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 746cdeb8135..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 @@ -14,8 +14,11 @@ class az_property_tax_credit_agi(Variable): "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. Unlike regular Arizona " - "income tax, this does " + "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 " @@ -117,6 +120,36 @@ def formula(tax_unit, period, parameters): 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. @@ -127,4 +160,5 @@ def formula(tax_unit, period, parameters): + 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_potential.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_potential.py index 8c315033869..77b3a66b858 100644 --- 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 @@ -51,8 +51,16 @@ def formula(tax_unit, period, parameters): # 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. Each tax unit counts its own property - # taxes and rent, as before household income spanned tax units. + # 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 From 57f6086f6d971f04ca78bdb4cec5d90229e086f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:48:36 +0000 Subject: [PATCH 5/8] Allow a cent of rounding on the Arizona self-employment tax case The half-SE-tax deduction is 211.94324 in float32, so an exact 211.94 fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GvzSJyt6QD8rg4qb2 --- .../gov/states/az/tax/income/az_property_tax_credit_agi.yaml | 1 + 1 file changed, 1 insertion(+) 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 ef2d70559e4..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 @@ -140,6 +140,7 @@ - name: Half of self-employment tax does not reduce household income period: 2025 + absolute_error_margin: 0.01 input: people: head: From 37c968bb264ccc06cfc34bd7bf8d88be3d7f4846 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 19:09:15 -0500 Subject: [PATCH 6/8] Say what the Arizona claimant tie-break orders by get_rank orders by members_position, the order of the people in the situation, so the household's members list doesn't move a tie. The comments and a test name said household member order. No behaviour changes. Co-Authored-By: Claude Opus 5.5 --- .../credits/az_property_tax_credit_claimant.yaml | 2 +- .../az_property_tax_credit_claimant.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index 75a51a9fc63..d1173b456ce 100644 --- 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 @@ -56,7 +56,7 @@ az_property_tax_credit_potential: [300, 502] az_property_tax_credit: [0, 502] -- name: A tie goes to the first tax unit in household order +- name: A tie goes to the tax unit whose head comes first in the person order period: 2025 input: people: 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 index 6ea6efdfb86..20adfbbecc5 100644 --- 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 @@ -19,8 +19,8 @@ def formula(tax_unit, period, parameters): # 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 first such - # tax unit in household member order. + # 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 @@ -29,9 +29,9 @@ def formula(tax_unit, period, parameters): 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's stable sort gives rank 0 to the first tied tax unit - # holding the household head, or, if none does, to the first tied - # tax unit in household member order. + # 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 ) From 589056aa96ec864b5b59c9a8486f1f869a021bef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:08:40 +0000 Subject: [PATCH 7/8] Test Arizona property tax credit income when a gross income source is dropped az_property_tax_credit_agi subtracts taxable unemployment compensation, and az_property_tax_credit_nontaxable_income nets taxable alimony, only when gov.irs.gross_income.sources lists them. No test ran the other branch, so a reform that drops either source was untested. These tests drop each source and check the amount is counted exactly once: AGI does not subtract unemployment federal AGI never held, and all alimony is added when federal gross income holds none of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GnePsyFdWib7Vp4kk --- ...t_az_property_tax_credit_source_reforms.py | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 policyengine_us/tests/test_az_property_tax_credit_source_reforms.py 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..d3958847657 --- /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 each source +in turn and check that the 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 + + +def _drop_gross_income_source(source: str) -> type: + class drop_source(Reform): + 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 != source] + node.update(start=start, stop=instant(f"{YEAR}-12-31"), value=kept) + return parameters + + self.modify_parameters(modify) + + return drop_source + + +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()) + + +def test_unemployment_left_out_of_gross_income_is_not_subtracted(baseline): + reformed = Simulation( + situation=_situation(), + reform=_drop_gross_income_source("taxable_unemployment_compensation"), + ) + # 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 = Simulation( + situation=_situation(), + reform=_drop_gross_income_source("taxable_alimony_income"), + ) + # 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, + ) From c70ec4c9ba7700fda3eda23e3336c7f0a0b9b18f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:15:27 +0000 Subject: [PATCH 8/8] Build one reformed system for both Arizona source-drop tests Each reform construction copies the parameter tree and took about 31 s. The two filers hold disjoint income, so one reform that drops both sources checks each branch on its own household at half the cost. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GnePsyFdWib7Vp4kk --- ...t_az_property_tax_credit_source_reforms.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) 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 index d3958847657..57ef0d940e3 100644 --- a/policyengine_us/tests/test_az_property_tax_credit_source_reforms.py +++ b/policyengine_us/tests/test_az_property_tax_credit_source_reforms.py @@ -4,8 +4,8 @@ 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 each source -in turn and check that the amount is still counted exactly once. +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: @@ -27,19 +27,22 @@ YEAR = 2025 -def _drop_gross_income_source(source: str) -> type: - class drop_source(Reform): - 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 != source] - node.update(start=start, stop=instant(f"{YEAR}-12-31"), value=kept) - return parameters +DROPPED = ["taxable_unemployment_compensation", "taxable_alimony_income"] - self.modify_parameters(modify) - return drop_source +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: @@ -76,11 +79,12 @@ def baseline(): return Simulation(situation=_situation()) -def test_unemployment_left_out_of_gross_income_is_not_subtracted(baseline): - reformed = Simulation( - situation=_situation(), - reform=_drop_gross_income_source("taxable_unemployment_compensation"), - ) +@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 @@ -99,11 +103,7 @@ def test_unemployment_left_out_of_gross_income_is_not_subtracted(baseline): ) -def test_alimony_left_out_of_gross_income_is_added_in_full(baseline): - reformed = Simulation( - situation=_situation(), - reform=_drop_gross_income_source("taxable_alimony_income"), - ) +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.