diff --git a/changelog.d/fix-az-ptc-capital-gains.fixed.md b/changelog.d/fix-az-ptc-capital-gains.fixed.md new file mode 100644 index 00000000000..cfca69393b1 --- /dev/null +++ b/changelog.d/fix-az-ptc-capital-gains.fixed.md @@ -0,0 +1 @@ +Arizona property tax credit household income counts capital gains and qualified dividends once, includes dependents' income and losses, and limits each member's net capital loss (including capital gain distributions) to $1,500 instead of federal AGI's $3,000 per-return limit. Household income can be negative, with zero used only for the credit schedules. Short-term capital inputs must exclude prior-year carryovers. diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml new file mode 100644 index 00000000000..4c6e88e13f5 --- /dev/null +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/capital_loss_limit.yaml @@ -0,0 +1,22 @@ +description: Arizona limits each household member's net capital loss to this amount when computing household income for the property tax credit. +values: + 2021-01-01: 1_500 +metadata: + unit: currency-USD + period: year + label: Arizona property tax credit capital loss limit per household member + reference: + - title: A.A.C. R15-2C-502(C)(3). Property Tax Credit + href: https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502 + - title: ITR 12-1. Income Tax Ruling - Property Tax Credit Household Income, item (7) + href: https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2 + - title: 2025 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4 + - title: 2024 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2024_140PTCi.pdf#page=4 + - title: 2023 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/2023-12/FORMS_INDIVIDUAL_2023_140PTCi.pdf#page=4 + - title: 2022 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2022_140PTCi-2D.pdf#page=4 + - title: 2021 Form 140PTC Instructions, Part 1 line D + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2021_140PTCi-2D.pdf#page=4 diff --git a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml index 76b29218242..8c819559cb7 100644 --- a/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/az/tax/income/credits/property_tax/income_sources.yaml @@ -1,16 +1,32 @@ description: Arizona accounts for the following income sources when computing household income for the property tax credit. values: 2021-01-01: - # Item (7): Capital gains - excluded gains not in Federal AGI - - capital_gains_excluded_from_taxable_income # Item (2): All interest income including tax-exempt - tax_exempt_interest_income # Item (9): Pension income - tax-exempt portions not in Federal AGI # (taxable pension is already in Federal AGI via az_property_tax_credit_agi) - tax_exempt_public_pension_income - tax_exempt_private_pension_income - # Federal AGI minus Social Security (per ARS 43-1072(I) exclusion) + # Federal AGI minus Social Security (per ARS 43-1072(I) exclusion). + # Item (7) capital gains and losses (Form 140PTC Part 1 line D) are counted + # here, once, for members who are not dependents: federal AGI's net capital + # gain and capital loss deduction (limited to $3,000 per return) are + # replaced by each member's combined gains and losses, with a net loss + # limited to $1,500 for each member (A.A.C. R15-2C-502(C)(3)). The total can + # be negative; az_property_tax_credit uses zero for the schedules. + # ARS 43-1072(H)(6)(b) also adds "the amount of capital gains excluded from + # adjusted gross income"; the model has no gain excluded from federal AGI + # (such as a section 121 home sale or section 1202 small business stock + # exclusion), so nothing is added for it. Do not add + # capital_gains_excluded_from_taxable_income: it is the part of federal + # taxable income taxed at the capital gains rates (at zero taxable income, + # the adjusted net capital gain), which federal AGI already holds. - az_property_tax_credit_agi + # Federal AGI leaves out dependents' income; Arizona counts every household + # member's income whether or not the member is a dependent + # (A.A.C. R15-2C-502(A)(2), (B)). This includes each dependent's line D + # amount, with the same $1,500 loss limit. + - az_property_tax_credit_dependent_income metadata: unit: list period: year @@ -18,13 +34,27 @@ metadata: reference: - title: ARS 43-1072. Property tax credit - Income definition href: https://www.azleg.gov/ars/43/01072.htm + - title: A.A.C. R15-2C-502. Property Tax Credit + href: https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502 - title: ITR 12-1. Income Tax Ruling - Property Tax Credit Household Income - href: https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf + href: https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2 + - title: 2025 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4 + - title: 2025 Property Tax Refund (Credit) Claim Form 140PTC, Part 1 + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTC_f.pdf#page=2 + - title: 2024 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2024_140PTCi.pdf#page=4 - title: 2024 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/forms/tax-credits-forms/property-tax-refund-credit-claim-form-fillable + - title: 2023 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/2023-12/FORMS_INDIVIDUAL_2023_140PTCi.pdf#page=4 - title: 2023 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/forms/tax-credits-forms/property-tax-refund-credit-claim-form-fillable + - title: 2022 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2022_140PTCi-2D.pdf#page=4 - title: 2022 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2022_140PTC.pdf + - title: 2021 Form 140PTC Instructions, Part 1 lines A to I + href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2021_140PTCi-2D.pdf#page=4 - title: 2021 Property Tax Refund (Credit) Claim Form 140PTC href: https://azdor.gov/sites/default/files/2023-03/FORMS_INDIVIDUAL_2021_140PTC.pdf diff --git a/policyengine_us/tests/core/test_loss_ald_extraction_parity.py b/policyengine_us/tests/core/test_loss_ald_extraction_parity.py new file mode 100644 index 00000000000..d7c2ee4ccfb --- /dev/null +++ b/policyengine_us/tests/core/test_loss_ald_extraction_parity.py @@ -0,0 +1,292 @@ +"""Bounded whole-dollar compatibility of the extracted business-loss component. + +YAML covers the policy examples. This differential test covers Core's float32 +storage and ``adds`` accumulation, which YAML cannot compare with the former +single-formula calculation. The independent reference applies sections 461(l) +and 1211(b) to leaf inputs and casts the combined deduction only once, as the +pre-extraction formula did. + +All intermediate totals stay below 2**24, the exact-integer range of float32. +This is a bounded guarantee, not parity for fractional dollars or larger totals. +Use production classes in one small Core system, without building or copying a +country model per case; package import constructs its global model once. This +module belongs to the existing rest-python core CI group. +""" + +from pathlib import Path + +import numpy as np +import pytest +import yaml +from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters.config import Loader +from policyengine_core.simulations import Simulation +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +import policyengine_us +from policyengine_us.entities import Person, TaxUnit +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.capital_losses_allowed_against_gains import ( + capital_losses_allowed_against_gains, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.limited_business_loss import ( + limited_business_loss, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.limited_capital_loss import ( + limited_capital_loss, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.loss_ald import ( + loss_ald, +) +from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( + FilingStatus, + filing_status, +) +from policyengine_us.variables.household.demographic.tax_unit.is_tax_unit_dependent import ( + is_tax_unit_dependent, +) +from policyengine_us.variables.household.income.person.capital_gains.capital_gains import ( + capital_gains, +) +from policyengine_us.variables.household.income.person.capital_gains.capital_losses import ( + capital_losses, +) +from policyengine_us.variables.household.income.person.capital_gains.long_term_capital_gains import ( + long_term_capital_gains, +) +from policyengine_us.variables.household.income.person.capital_gains.non_sch_d_capital_gains import ( + non_sch_d_capital_gains, +) +from policyengine_us.variables.household.income.person.capital_gains.other_net_gain import ( + other_net_gain, +) +from policyengine_us.variables.household.income.person.capital_gains.short_term_capital_gains import ( + short_term_capital_gains, +) +from policyengine_us.variables.household.income.person.estate.estate_income import ( + estate_income, +) +from policyengine_us.variables.household.income.person.farm.farm_operations_income import ( + farm_operations_income, +) +from policyengine_us.variables.household.income.person.farm.farm_rent_income import ( + farm_rent_income, +) +from policyengine_us.variables.household.income.person.misc.rental_income import ( + rental_income, +) +from policyengine_us.variables.household.income.person.self_employment.partnership_income import ( + partnership_income, +) +from policyengine_us.variables.household.income.person.self_employment.partnership_s_corp_income import ( + partnership_s_corp_income, +) +from policyengine_us.variables.household.income.person.self_employment.s_corp_income import ( + s_corp_income, +) +from policyengine_us.variables.household.income.person.self_employment.total_self_employment_income import ( + total_self_employment_income, +) +from policyengine_us.variables.input.self_employment_income import ( + self_employment_income, +) +from policyengine_us.variables.input.sstb_self_employment_income import ( + sstb_self_employment_income, +) + +INPUT_CLASSES = ( + self_employment_income, + sstb_self_employment_income, + farm_operations_income, + rental_income, + farm_rent_income, + estate_income, + partnership_income, + s_corp_income, + short_term_capital_gains, + long_term_capital_gains, + non_sch_d_capital_gains, +) +INPUTS = tuple(variable.__name__ for variable in INPUT_CLASSES) +ZERO = dict.fromkeys(INPUTS, 0) +STATUSES = tuple(status.name for status in FilingStatus) +EXACT_INTEGER_LIMIT = 2**24 + + +@pytest.fixture(scope="module") +def system(): + result = TaxBenefitSystem(entities=[Person, TaxUnit]) + result.add_variables( + *INPUT_CLASSES, + filing_status, + is_tax_unit_dependent, + other_net_gain, + total_self_employment_income, + partnership_s_corp_income, + capital_gains, + capital_losses, + limited_business_loss, + capital_losses_allowed_against_gains, + limited_capital_loss, + loss_ald, + ) + parameter_root = Path(policyengine_us.__file__).parent / "parameters" + + def load(relative_path): + return yaml.load((parameter_root / relative_path).read_text(), Loader=Loader) + + # Read only the published thresholds and gross-income source list needed + # by these formulas; no full parameter-tree construction or copying. + result.parameters = ParameterNode( + "", + data={ + "gov": { + "irs": { + "ald": { + "loss": { + "max": load("gov/irs/ald/loss/max.yaml"), + "capital": { + "max": load("gov/irs/ald/loss/capital/max.yaml") + }, + } + }, + "gross_income": { + "sources": load("gov/irs/gross_income/sources.yaml") + }, + } + } + }, + ) + return result + + +def _draw_units(year, thresholds, capital_caps): + rng = np.random.default_rng(9762 + year) + units = [] + for i in range(120): + members = [] + for dependent in [False] + ([False] if i % 2 else []) + [True] * (i % 3): + amounts = {name: int(rng.integers(-100_000, 100_001)) for name in INPUTS} + amounts["non_sch_d_capital_gains"] = abs(amounts["non_sch_d_capital_gains"]) + members.append((amounts, dependent)) + units.append( + { + "status": STATUSES[i % len(STATUSES)], + "other": int(rng.integers(-100_000, 100_001)), + "members": members, + } + ) + for status in STATUSES: + for offset in (-1, 0, 1): + # Below/at/above business and capital limits, including MFS. + # Opposite-signed short/long-term sources net before the + # distribution offsets the resulting loss. + head = { + **ZERO, + "farm_rent_income": 12_345, + "rental_income": -(thresholds[status] + 12_345 + 17 + offset), + "short_term_capital_gains": -( + 2_000 + capital_caps[status] + offset + 700 + ), + "long_term_capital_gains": 700, + "non_sch_d_capital_gains": 2_000, + } + dependent = { + **ZERO, + "estate_income": -800_000, + "partnership_income": 400_000, + "short_term_capital_gains": -400_000, + "long_term_capital_gains": 100_000, + "non_sch_d_capital_gains": 200_000, + } + units.append( + { + "status": status, + "other": 17, + "members": [(head, False), (dependent, True)], + } + ) + return units + + +def _pre_extraction_reference(unit, threshold, capital_cap): + """Independent scalar reference, retaining the former one final cast.""" + business_income = max(0, unit["other"]) + business_losses = max(0, -unit["other"]) + capital_losses = gains_in_gross_income = 0 + for person, dependent in unit["members"]: + if dependent: + continue + business_sources = ( + person["self_employment_income"] + person["sstb_self_employment_income"], + person["farm_operations_income"], + person["rental_income"], + person["farm_rent_income"], + person["estate_income"], + person["partnership_income"] + person["s_corp_income"], + ) + business_income += sum(max(0, value) for value in business_sources) + business_losses += sum(max(0, -value) for value in business_sources) + net_capital = ( + person["short_term_capital_gains"] + person["long_term_capital_gains"] + ) + capital_losses += max(0, -net_capital) + gains_in_gross_income += max(0, net_capital) + max( + 0, person["non_sch_d_capital_gains"] + ) + business = min(business_losses, business_income + threshold) + against_gains = min(capital_losses, gains_in_gross_income) + net_capital_loss = min(capital_cap, capital_losses - against_gains) + combined = business + against_gains + net_capital_loss + # Check every total, not only individual leaf inputs: an intermediate + # sum outside this domain can round before the final combined deduction. + assert ( + max( + business_income + threshold, + business_losses, + capital_losses, + gains_in_gross_income, + combined, + ) + < EXACT_INTEGER_LIMIT + ) + return np.float32(combined) + + +@pytest.mark.parametrize("year", [2021, 2025, 2026]) +def test_whole_dollar_loss_ald_matches_pre_extraction_formula(system, year): + """Dependent masking, status limits and source offsets preserve exact totals.""" + p = system.parameters(f"{year}-01-01").gov.irs.ald.loss + thresholds = {status: p.max[status] for status in STATUSES} + capital_caps = {status: p.capital.max[status] for status in STATUSES} + units = _draw_units(year, thresholds, capital_caps) + people, tax_units = {}, {} + for i, unit in enumerate(units): + members = [] + for j, (amounts, dependent) in enumerate(unit["members"]): + name = f"u{i}_p{j}" + members.append(name) + people[name] = { + "is_tax_unit_dependent": {year: dependent}, + **{name: {year: value} for name, value in amounts.items()}, + } + tax_units[f"t{i}"] = { + "members": members, + "filing_status": {year: unit["status"]}, + "other_net_gain": {year: unit["other"]}, + } + simulation = Simulation( + tax_benefit_system=system, + situation={"people": people, "tax_units": tax_units}, + ) + expected = np.array( + [ + _pre_extraction_reference( + unit, thresholds[unit["status"]], capital_caps[unit["status"]] + ) + for unit in units + ], + dtype=np.float32, + ) + actual = simulation.calculate("loss_ald", year) + assert actual.dtype == np.float32 + np.testing.assert_array_equal(actual, expected) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml index 3275b8fc174..878c3c7267e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.yaml @@ -40,6 +40,8 @@ # Total loss_ald: 610,000 + 3,000 = 613,000 # Gross income: 82,813 + 20 (interest) = 82,833 # AGI: 82,833 - 613,000 = -530,167 + limited_business_loss: 610_000 + limited_capital_loss: 3_000 loss_ald: 613_000 adjusted_gross_income: -530_167 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml index 5016feb4b85..2f920a71272 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/az_property_tax_credit_agi.yaml @@ -38,14 +38,16 @@ output: az_property_tax_credit_agi: 25_000 -- name: Capped at zero when Social Security exceeds other income +- name: Negative when losses leave other income below taxable Social Security period: 2024 input: state_code: AZ adjusted_gross_income: 5_000 tax_unit_taxable_social_security: 6_000 output: - az_property_tax_credit_agi: 0 + # $5,000 - $6,000 = -$1,000. Not floored: Form 140PTC line J can be + # negative, and az_property_tax_credit uses zero only for the schedules. + az_property_tax_credit_agi: -1_000 - name: Combined income types with Social Security exclusion period: 2024 @@ -56,3 +58,49 @@ output: # $22,250 - $4,250 = $18,000 az_property_tax_credit_agi: 18_000 + +# A.A.C. R15-2C-502(C)(3): each member's net capital loss is limited to +# $1,500, in place of federal AGI's $3,000 limit per return. +- name: Federal capital loss limit is replaced by the 1,500 member limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 10_000 + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: 10,000 - 3,000 = 7,000. + adjusted_gross_income: 7_000 + # Form 140PTC line A 10,000 + line D (1,500) = 8,500. + az_property_tax_credit_agi: 8_500 + +- name: A dependent's capital loss is left to dependent income + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + child: + age: 12 + is_tax_unit_dependent: true + long_term_capital_gains: -2_500 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Only the head's line A wages. The dependent's line D (1,500) is in + # az_property_tax_credit_dependent_income. + az_property_tax_credit_agi: 3_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit.yaml index aa50bb2e5e7..226dbbebd62 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 @@ -147,3 +147,20 @@ households: {household: {members: [head], state_code: AZ}} output: az_property_tax_credit: 78 + +# Form 140PTC line J note (2023-2025 instructions, page 5): a negative household +# income is entered as is, and household income is considered zero for the +# Schedule 1 and 2 amounts. +- name: negative household income uses the zero-income schedule amount + period: 2025 + input: + state_code: AZ + cohabitating_spouses: false + az_property_tax_credit_income: -1_000 + az_property_tax_credit_eligible: true + real_estate_taxes: 0 + rent: 4_000 + output: + # Line 11a: Schedule 1, 0 - 1,750 -> 502. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 502 and 600. + az_property_tax_credit: 502 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml new file mode 100644 index 00000000000..43feab97aea --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_capital_gains.yaml @@ -0,0 +1,57 @@ +# Form 140PTC Part 1 line D, for one household member: "combine gains and +# losses from the sale or exchange of property. If the result is a gain, enter +# the gain. If the result is a loss, enter the amount of the loss up to a +# maximum of ($1,500)." (2021-2025 instructions, page 4; +# A.A.C. R15-2C-502(C)(3); ITR 12-1 item (7).) + +- name: A net gain is entered in full + period: 2025 + input: + state_code: AZ + long_term_capital_gains: 3_000 + short_term_capital_gains: -500 + output: + # 3,000 - 500 = 2,500. + az_property_tax_credit_capital_gains: 2_500 + +- name: A net loss within the limit is entered in full + period: 2025 + input: + state_code: AZ + long_term_capital_gains: -1_000 + output: + az_property_tax_credit_capital_gains: -1_000 + +- name: A net loss equal to the limit is entered in full + period: 2025 + input: + state_code: AZ + short_term_capital_gains: -1_500 + output: + az_property_tax_credit_capital_gains: -1_500 + +- name: A net loss over the limit is entered as the limit + period: 2025 + input: + state_code: AZ + long_term_capital_gains: 2_000 + short_term_capital_gains: -6_000 + output: + # 2,000 - 6,000 = (4,000), limited to (1,500). + az_property_tax_credit_capital_gains: -1_500 + +- name: The limit applies in 2021 + period: 2021 + input: + state_code: AZ + long_term_capital_gains: -3_000 + output: + az_property_tax_credit_capital_gains: -1_500 + +- name: Not computed outside Arizona + period: 2025 + input: + state_code: CA + long_term_capital_gains: -3_000 + output: + az_property_tax_credit_capital_gains: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml new file mode 100644 index 00000000000..f1f7d8f85e2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/credits/az_property_tax_credit_income.yaml @@ -0,0 +1,1035 @@ +# Arizona property tax credit household income: Form 140PTC page 2, Part 1, +# Schedule of Household Income, lines A to J (ARS 43-1072(H)(6); +# A.A.C. R15-2C-502; ITR 12-1). Line D is "gain or loss from sale or exchange +# of property": each member's gains and losses combined, with a net loss +# limited to $1,500 (2021-2025 instructions, page 4). Federal AGI already holds +# those gains, so each dollar of gain must count once. +# +# Credit amounts: Form 140PTC line 11 is the Schedule 1 (lived alone) or +# Schedule 2 (lived with a spouse or others) amount at line J; line 13 is a +# renter's property taxes, modeled as 0.15 x rent; line 15 is the smaller. + +- name: Single renter with ordinary long-term gains counts them once + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: gain from the sale of property 3,000. + # Line J: total household income 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. + # Line 13: 0.15 x 5,000 = 750. Line 15: smaller of 212 and 750. + az_property_tax_credit: 212 + +- name: Single renter with qualified dividends counts them once + period: 2025 + input: + people: + head: + age: 70 + qualified_dividend_income: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line B: dividend income 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: Long-term gains above the federal deductions still count once + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 40_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal taxable income is positive here (40,000 less the standard, + # additional and senior deductions); the gain still counts once. + # Line D: gain 40,000. Line J: 40,000. + az_property_tax_credit_income: 40_000 + # Line 11a: Schedule 1, 3,751 and up -> 0. + az_property_tax_credit: 0 + +- name: Married couple combines each spouse's gains on line D + period: 2025 + input: + people: + head: + age: 70 + employment_income: 1_000 + long_term_capital_gains: 1_500 + rent: 4_000 + spouse: + age: 70 + short_term_capital_gains: 1_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line A: wages 1,000 (column 1). + # Line D: 1,500 (column 1) + 1,000 (column 2) = 2,500. + # Line J: 1,000 + 2,500 = 3,500. + az_property_tax_credit_income: 3_500 + # Line 11b: Schedule 2, 3,401 - 3,550 -> 345. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 345 and 600. + az_property_tax_credit: 345 + +- name: Short-term loss nets against long-term gain on line D + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 5_000 + short_term_capital_gains: -2_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: 5,000 - 2,000 = 3,000 net gain. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: Net loss within the 1,500 limit reduces household income + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 4,000. + # Line D: net loss (1,000), within the (1,500) limit. + # Line J: 4,000 - 1,000 = 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: Every income line together, with Social Security excluded + period: 2025 + input: + people: + head: + age: 72 + employment_income: 500 + taxable_interest_income: 200 + tax_exempt_interest_income: 300 + long_term_capital_gains: 400 + taxable_private_pension_income: 600 + tax_exempt_public_pension_income: 100 + social_security_retirement: 12_000 + rent: 6_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 500. + # Line B: interest 200 + tax-exempt interest 300 = 500. + # Line D: gain 400. + # Line E: pension 600 + tax-exempt public pension 100 = 700. + # Social Security is not income for the credit. + # Line J: 500 + 500 + 400 + 700 = 2,100. + az_property_tax_credit_income: 2_100 + # Line 11a: Schedule 1, 2,051 - 2,150 -> 412. + # Line 13: 0.15 x 6,000 = 900. Line 15: smaller of 412 and 900. + az_property_tax_credit: 412 + +- name: Long-term gains count once in 2021 + period: 2021 + input: + people: + head: + age: 66 + long_term_capital_gains: 2_000 + rent: 3_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: gain 2,000. Line J: 2,000. + az_property_tax_credit_income: 2_000 + # Line 11a: 2021 Schedule 1, 1,951 - 2,050 -> 435. + # Line 13: 0.15 x 3,000 = 450. Line 15: smaller of 435 and 450. + az_property_tax_credit: 435 + +# A.A.C. R15-2C-502(A)(2) and (B): household income combines every member's +# income, whether or not the member is a dependent. Federal AGI leaves out a +# dependent's income, so it is added separately. +- name: A dependent's long-term gains count once + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line D: column 3 (other persons) gain 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. + # Line 13: 0.15 x 5,000 = 750. Line 15: smaller of 412 and 750. + az_property_tax_credit: 412 + +- name: A dependent's qualified dividends count once + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 10 + is_tax_unit_dependent: true + qualified_dividend_income: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line B: column 3 dividends 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. Line 13: 750. + az_property_tax_credit: 412 + +- name: A dependent's wages count with the claimant's pension + period: 2025 + input: + people: + head: + age: 70 + taxable_private_pension_income: 1_000 + rent: 5_000 + grandchild: + age: 16 + is_tax_unit_dependent: true + employment_income: 2_000 + tax_units: + tax_unit: + members: [head, grandchild] + households: + household: + members: [head, grandchild] + state_code: AZ + output: + # Line A: column 3 wages 2,000. Line E: column 1 pension 1,000. + # Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. Line 13: 750. + az_property_tax_credit: 412 + +# Losses offset other members' income. Federal AGI deducts every member's +# losses, and it is not floored at zero before dependents' income is added +# (Form 140PTC line J can be negative; A.A.C. R15-2C-502(B), (C)). +- name: A dependent's capital loss offsets the dependent's wages + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_000 + long_term_capital_gains: -1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,000. Line D: column 3 net loss (1,000). + # Line J: 2,000. + az_property_tax_credit_income: 2_000 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A dependent's capital loss with a claimant's pension + period: 2025 + input: + people: + head: + age: 70 + taxable_private_pension_income: 400 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_000 + long_term_capital_gains: -1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: 3,000. Line D: (1,000). Line E: column 1 pension 400. + # Line J: 2,400. + az_property_tax_credit_income: 2_400 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A claimant's capital loss offsets a dependent's wages + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_000 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_400 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,400. Line D: column 1 net loss (1,000). + # Line J: 2,400. + az_property_tax_credit_income: 2_400 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A dependent's rental loss offsets the dependent's wages + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_000 + rental_income: -1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,000. Line F: column 3 rent loss (1,000). + # Line J: 2,000. + az_property_tax_credit_income: 2_000 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A net loss alone leaves household income negative + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: net loss (1,000). Line J: (1,000), considered zero for the + # schedules. + az_property_tax_credit_income: -1_000 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +# Line D loss limit. A.A.C. R15-2C-502(C)(3), ITR 12-1 item (7) and the line D +# instructions limit each member's net loss to (1,500). Federal AGI instead +# limits a return's net loss to (3,000), so household income replaces it. A +# negative line J is entered as is and considered zero for Schedules 1 and 2 +# (2023-2025 instructions, page 5, line J note). +- name: Single filer's net loss counts only up to the 1,500 limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -2_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 4,000. + # Line D: net loss (2,000), limited to (1,500). + # Line J: 4,000 - 1,500 = 2,500. + # Federal AGI would give 4,000 - 2,000 = 2,000. + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. + # Line 13: 0.15 x 5,000 = 750. Line 15: smaller of 323 and 750. + az_property_tax_credit: 323 + +- name: Each spouse's net loss counts up to that spouse's own 1,500 limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 6_000 + long_term_capital_gains: -1_500 + rent: 5_000 + spouse: + age: 68 + short_term_capital_gains: -1_500 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line A: wages 6,000 (column 1). + # Line D: (1,500) (column 1) + (1,500) (column 2) = (3,000). + # Line J: 6,000 - 3,000 = 3,000. + az_property_tax_credit_income: 3_000 + # Line 11b: Schedule 2, 2,951 - 3,100 -> 412. + # Line 13: 750. Line 15: smaller of 412 and 750. + az_property_tax_credit: 412 + +- name: One spouse's loss over the limit does not offset the other's gain + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + long_term_capital_gains: -4_000 + rent: 5_000 + spouse: + age: 70 + long_term_capital_gains: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line A: wages 3,000 (column 1). + # Line D: (4,000) limited to (1,500) (column 1) + 2,000 (column 2) = 500. + # Line J: 3,000 + 500 = 3,500. + # Federal AGI nets the return's gains and losses first: + # 3,000 + 2,000 - 2,000 - 2,000 = 1,000. + az_property_tax_credit_income: 3_500 + # Line 11b: Schedule 2, 3,401 - 3,550 -> 345. + # Line 13: 750. Line 15: smaller of 345 and 750. + az_property_tax_credit: 345 + +- name: A line D loss offsets a tax-exempt pension + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_500 + tax_exempt_public_pension_income: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: net loss (1,500), at the limit. + # Line E: Arizona public pension 3,000. + # Line J: 3,000 - 1,500 = 1,500. Flooring federal AGI less Social + # Security at zero before adding the pension would give 3,000. + az_property_tax_credit_agi: -1_500 + az_property_tax_credit_income: 1_500 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. + # Line 13: 750. Line 15: smaller of 502 and 750. + az_property_tax_credit: 502 + +- name: Negative line J with a tax-exempt pension uses zero for the schedule + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: -1_500 + tax_exempt_public_pension_income: 1_000 + rent: 4_000 + spouse: + age: 70 + short_term_capital_gains: -2_500 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: AZ + output: + # Line D: (1,500) (column 1) + (2,500) limited to (1,500) (column 2) + # = (3,000). + # Line E: Arizona public pension 1,000 (column 1). + # Line J: 1,000 - 3,000 = (2,000), entered as a negative amount. + az_property_tax_credit_agi: -3_000 + az_property_tax_credit_income: -2_000 + # Line 11b: household income is considered zero for the schedule. + # Schedule 2, 0 - 2,500 -> 502. + # Line 13: 0.15 x 4,000 = 600. Line 15: smaller of 502 and 600. + az_property_tax_credit: 502 + +- name: A dependent's net loss counts up to the dependent's own 1,500 limit + period: 2025 + input: + people: + head: + age: 70 + employment_income: 3_000 + rent: 5_000 + grandchild: + age: 15 + is_tax_unit_dependent: true + long_term_capital_gains: -2_500 + tax_units: + tax_unit: + members: [head, grandchild] + households: + household: + members: [head, grandchild] + state_code: AZ + output: + # Line A: wages 3,000 (column 1). + # Line D: column 3 net loss (2,500), limited to (1,500). + # Line J: 3,000 - 1,500 = 1,500. + az_property_tax_credit_agi: 3_000 + az_property_tax_credit_dependent_income: -1_500 + az_property_tax_credit_income: 1_500 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. + # Line 13: 750. Line 15: smaller of 502 and 750. + az_property_tax_credit: 502 + +# Losses beyond the member limit with tax-exempt income. Federal AGI deducts +# up to 3,000 of capital loss, which would offset the tax-exempt interest and +# pensions household income adds; line D allows (1,500). +- name: A loss beyond the limit does not offset tax-exempt interest + period: 2025 + input: + people: + head: + age: 70 + tax_exempt_interest_income: 5_500 + long_term_capital_gains: -3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line B: tax-exempt interest 5,500. + # Line D: net loss (3,000), limited to (1,500). + # Line J: 5,500 - 1,500 = 4,000. Federal AGI would give 5,500 - 3,000. + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +- name: A loss beyond the limit with taxable and tax-exempt pensions + period: 2025 + input: + people: + head: + age: 70 + taxable_private_pension_income: 1_500 + tax_exempt_public_pension_income: 3_000 + long_term_capital_gains: -3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line D: net loss (3,000), limited to (1,500). + # Line E: pensions 1,500 + 3,000 = 4,500. + # Line J: 4,500 - 1,500 = 3,000. + az_property_tax_credit_agi: 0 + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. + # Line 13: 750. Line 15: smaller of 212 and 750. + az_property_tax_credit: 212 + +- name: A dependent's loss beyond the limit does not offset the claimant's interest + period: 2025 + input: + people: + head: + age: 70 + tax_exempt_interest_income: 7_100 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + long_term_capital_gains: -3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line B: column 1 tax-exempt interest 7,100. + # Line D: column 3 net loss (3,000), limited to (1,500). + # Line J: 7,100 - 1,500 = 5,600. + az_property_tax_credit_dependent_income: -1_500 + az_property_tax_credit_income: 5_600 + # Line 11b: Schedule 2 pays nothing at 5,501 or more. + az_property_tax_credit: 0 + +# Capital gain distributions are long-term capital gains (26 U.S.C. +# 852(b)(3)(B)), so they combine with the member's other gains and losses on +# line D before the (1,500) limit. +- name: Capital gain distributions net against the member's capital loss + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + non_sch_d_capital_gains: 1_000 + long_term_capital_gains: -4_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line A: wages 4,000. + # Line D: distributions 1,000 + net loss (4,000) = (3,000), limited to + # (1,500). + # Line J: 4,000 - 1,500 = 2,500. Federal AGI nets the distributions + # against the loss first: 4,000 + 1,000 - 1,000 - 3,000 = 1,000. + adjusted_gross_income: 1_000 + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. + # Line 13: 750. Line 15: smaller of 323 and 750. + az_property_tax_credit: 323 + +- name: A dependent's capital gain distributions count once on line D + period: 2025 + input: + people: + head: + age: 70 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 3_400 + non_sch_d_capital_gains: 1_000 + long_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line A: column 3 wages 3,400. + # Line D: column 3 distributions 1,000 + net loss (4,000) = (3,000), + # limited to (1,500). + # Line J: 3,400 - 1,500 = 1,900. + az_property_tax_credit_dependent_income: 1_900 + az_property_tax_credit_income: 1_900 + # Line 11b: Schedule 2, 0 - 2,500 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +# Line G: estate and trust income counts, and line F nets a rental loss. +- name: Estate income and a rental loss with tax-exempt interest + period: 2025 + input: + people: + head: + age: 70 + estate_income: 2_000 + rental_income: -1_000 + tax_exempt_interest_income: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Line B: tax-exempt interest 3,000. Line F: rent loss (1,000). + # Line G: estate income 2,000. + # Line J: 3,000 - 1,000 + 2,000 = 4,000. + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +# Federal AGI's capital amounts are swapped for line D only where AGI holds +# them, so a reform that changes federal AGI's capital treatment leaves line D +# whole. +- name: Line D is unchanged when federal AGI takes no capital loss deduction + period: 2025 + input: + # The 2025 above-the-line deductions without loss_ald. + gov.irs.ald.deductions: [self_employment_tax_ald, student_loan_interest_ald, early_withdrawal_penalty, alimony_expense_ald, educator_expense_ald, health_savings_account_ald, self_employed_health_insurance_ald, self_employed_pension_contribution_ald, traditional_ira_contributions, qualified_adoption_assistance_expense, us_bonds_for_higher_ed, specified_possession_income, puerto_rico_income] + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: wages 4,000, with no capital loss deducted. + adjusted_gross_income: 4_000 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line J: 2,500. + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. Line 13: 750. + az_property_tax_credit: 323 + +- name: Line D is unchanged when the federal loss deduction is zero + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + loss_ald: 0 + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 4_000 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line J: 2,500. + az_property_tax_credit_income: 2_500 + # Line 11a: Schedule 1, 2,451 - 2,550 -> 323. Line 13: 750. + az_property_tax_credit: 323 + +- name: Gains left out of federal gross income still count on line D + period: 2025 + input: + # ARS 43-1072(H)(6)(b) adds capital gains excluded from adjusted gross + # income. The 2025 gross income sources without capital_gains. + gov.irs.gross_income.sources: [irs_employment_income, self_employment_income, sstb_self_employment_income, partnership_s_corp_income, farm_operations_income, farm_rent_income, other_net_gain_gross_income, non_sch_d_capital_gains, taxable_interest_income, rental_income, dividend_income, taxable_pension_income, debt_relief, estate_income, taxable_unemployment_compensation, taxable_social_security, illicit_income, taxable_retirement_distributions, taxable_roth_conversions, miscellaneous_income, ak_permanent_fund_dividend, taxable_alimony_income, salt_refund_income] + people: + head: + age: 70 + long_term_capital_gains: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 0 + # Line D: gain 3,000. Line J: 3,000. + az_property_tax_credit_income: 3_000 + # Line 11a: Schedule 1, 2,951 - 3,050 -> 212. Line 13: 750. + az_property_tax_credit: 212 + +- name: A rental loss and a capital loss beyond the limit together + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rental_income: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: 4,000 - rental loss 1,000 - capital loss 3,000 = 0. + adjusted_gross_income: 0 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line F: (1,000). + # Line J: 4,000 - 1,500 - 1,000 = 1,500. + az_property_tax_credit_income: 1_500 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +- name: A supplied loss deduction holding only the rental loss is not read as a capital deduction + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + long_term_capital_gains: -5_000 + rental_income: -1_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + # Federal AGI takes the rental loss only. + loss_ald: 1_000 + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 3_000 + # Line A: 4,000. Line D: (5,000) limited to (1,500). Line F: (1,000). + # Line J: 1,500, as when federal AGI takes both losses. + az_property_tax_credit_income: 1_500 + # Line 11a: Schedule 1, 0 - 1,750 -> 502. Line 13: 750. + az_property_tax_credit: 502 + +# A dependent's federal adjustments belong on the dependent's own return +# (#9801), so they never reduce the claimant's federal AGI; Form 140PTC lines A +# to I take no IRA adjustment. +- name: A dependent's IRA contribution does not lower household income + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 4_000 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + employment_income: 1_600 + traditional_ira_contributions: 200 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # The claimant's federal AGI: long-term gain 4,000. + adjusted_gross_income: 4_000 + # Line A: column 3 wages 1,600. Line D: column 1 gain 4,000. + # Line J: 5,600. + az_property_tax_credit_income: 5_600 + # Line 11b: Schedule 2 pays nothing at 5,501 or more. + az_property_tax_credit: 0 + +# ITR 12-1 item (7): a prior-year capital loss carryover cannot reduce the +# year's gains. long_term_capital_gains is net of the long-term carryover (a +# memo amount, as on Schedule D line 15), so line D adds it back. +- name: A long-term capital loss carryover does not reduce line D + period: 2025 + input: + people: + head: + age: 70 + # The year's long-term gain is 4,000; a 3,000 carryover nets it to 1,000. + long_term_capital_gains: 1_000 + long_term_capital_loss_carryover: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI uses the carryover: 1,000. + adjusted_gross_income: 1_000 + # Line D: the year's gain 4,000. Line J: 4,000. + az_property_tax_credit_capital_gains: [4_000] + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +# Reg. 1.642(h)-1(a): a terminating trust transfers its unused capital loss +# as a carryover. Max's d1107 ruling (2026-10-09) keeps final Form 1041 K-1 +# box 11 code D in the carryover memo, even for losses from termination-year +# sales. Arizona adds it back; the beneficiary's own gains remain 4,000. +- name: A terminating trust capital loss remains a carryover for line D + period: 2025 + input: + people: + head: + age: 70 + # Own gains 4,000 less the trust's 3,000 loss transferred in 2025. + long_term_capital_gains: 1_000 + long_term_capital_loss_carryover: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + adjusted_gross_income: 1_000 + az_property_tax_credit_capital_gains: [4_000] + az_property_tax_credit_agi: 4_000 + az_property_tax_credit_income: 4_000 + az_property_tax_credit: 0 + +- name: A carryover that makes a net loss is not a line D loss + period: 2025 + input: + people: + head: + age: 70 + employment_income: 4_000 + # No sales this year; the 3,000 carryover is the whole net loss. + long_term_capital_gains: -3_000 + long_term_capital_loss_carryover: 3_000 + rent: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: AZ + output: + # Federal AGI: 4,000 - 3,000 = 1,000. + adjusted_gross_income: 1_000 + # Line A: 4,000. Line D: 0. Line J: 4,000. + az_property_tax_credit_income: 4_000 + # Line 11a: Schedule 1 pays nothing at 3,751 or more. + az_property_tax_credit: 0 + +- name: A dependent's carryover is not a line D loss + period: 2025 + input: + people: + head: + age: 70 + long_term_capital_gains: 4_000 + rent: 5_000 + child: + age: 17 + is_tax_unit_dependent: true + long_term_capital_gains: -3_000 + long_term_capital_loss_carryover: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: AZ + output: + # Line D: column 1 gain 4,000; column 3 the year's net 0. Line J: 4,000. + az_property_tax_credit_dependent_income: 0 + az_property_tax_credit_income: 4_000 + # Line 11b: Schedule 2, 3,851 - 4,000 -> 279. + # Line 13: 750. Line 15: smaller of 279 and 750. + az_property_tax_credit: 279 diff --git a/policyengine_us/tests/test_az_property_tax_credit_income.py b/policyengine_us/tests/test_az_property_tax_credit_income.py new file mode 100644 index 00000000000..b95ffe1b383 --- /dev/null +++ b/policyengine_us/tests/test_az_property_tax_credit_income.py @@ -0,0 +1,494 @@ +"""Sampled property tests for Arizona property tax credit household income. + +ARS 43-1072(H)(6) defines income for the credit, and A.A.C. R15-2C-502 and the +Form 140PTC instructions (2021-2025, page 4) apply it line by line: line D is +each member's gains and losses from the sale or exchange of property, combined, +with a net loss limited to $1,500 for each member. Federal AGI already holds +those gains, so az_property_tax_credit_income must count each dollar of gain +once. It used to add capital_gains_excluded_from_taxable_income as well, which +is the part of federal taxable income taxed at the capital gains rates (at zero +taxable income, the adjusted net capital gain), so gains and qualified dividends +counted twice. Federal AGI leaves out dependents' income, which Arizona counts +for every household member whether or not a dependent. Federal AGI also limits +a net capital loss to $3,000 per return rather than $1,500 per member, so line D +replaces its capital gains and losses. Household income (line J) can be +negative; the 2023-2025 instructions (page 5) then consider it zero for the +Schedule 1 and 2 amounts. + +Invariants, checked on a seeded sample of Arizona tax units (some with a child +dependent who has income) whose members' net capital gains run from well below +-$1,500 to large gains, including units whose line J is negative: + +1. Differential: the model equals an independent line A + B + D + E + F sum + over every member from Form 140PTC Part 1, with Social Security excluded, + rental losses netted (A.A.C. R15-2C-502(C)(2)) and each member's line D, + capital gain distributions included and any prior-year carryover left out + (ITR 12-1 item (7)), floored at -$1,500. +2. Counted once: adding d to a member's long-term gains changes household + income by exactly the change in that member's line D, which is d when the + member's net gain stays at or above -$1,500. The credit never rises. +3. Loss limit: lowering a member's gains changes household income by the + change in that member's line D, which is 0 once the member's net gain is at + or below -$1,500. +4. Schedule floor: the credit depends on line J only through max(line J, 0), + so a negative line J gets the zero-income schedule amount. +5. The income does not depend on capital_gains_excluded_from_taxable_income. +""" + +import numpy as np + +from policyengine_us import Simulation + +YEAR = 2025 +N = 80 +SEED = 43_1072 +# A.A.C. R15-2C-502(C)(3): net capital losses are limited to $1,500 for each +# household member. +MEMBER_LOSS_LIMIT = 1_500 +# Form 140PTC line 13 for renters: rent times the property tax factor, +# modeled as gov.states.az.tax.income.credits.property_tax.rent_property_tax_rate. +RENT_PROPERTY_TAX_RATE = 0.15 + + +def _capital_gains(rng: np.random.Generator, can_be_large: bool) -> tuple: + """Long- and short-term gains for one member.""" + draw = rng.random() + if draw < 0.3: + return 0.0, 0.0 + if draw < 0.55: + # A net loss beyond the $1,500 member limit, some beyond federal AGI's + # $3,000 per-return limit too. + return ( + round(rng.uniform(-9_000, -1_600), 2), + round(rng.uniform(-1_000, 500), 2), + ) + # Some large gains, so federal taxable income is positive. + large_gain = ( + round(rng.uniform(20_000, 80_000), 2) + if can_be_large and rng.random() < 0.15 + else 0 + ) + return ( + round(rng.uniform(-1_000, 6_000), 2) + large_gain, + round(rng.uniform(-500, 2_000), 2), + ) + + +def _carryover(rng: np.random.Generator) -> float: + """A prior-year long-term capital loss carryover, for some members.""" + return round(rng.uniform(0, 4_000), 2) if rng.random() < 0.15 else 0.0 + + +def _distributions(rng: np.random.Generator) -> float: + """Capital gain distributions reported without Schedule D, for some members.""" + return round(rng.uniform(0, 800), 2) if rng.random() < 0.25 else 0.0 + + +def _rental_income(rng: np.random.Generator) -> float: + """Net rent and royalty income or loss (Form 140PTC line F), for some members.""" + return round(rng.uniform(-3_000, 3_000), 2) if rng.random() < 0.2 else 0.0 + + +def _sample_units(rng: np.random.Generator) -> list: + units = [] + for _ in range(N): + married = rng.random() < 0.4 + members = [] + for role in ["head", "spouse"] if married else ["head"]: + long_term, short_term = _capital_gains(rng, can_be_large=True) + carryover = _carryover(rng) + members.append( + { + "role": role, + "age": int(rng.integers(65, 86)), + "employment_income": float( + rng.choice([0, round(rng.uniform(0, 6_000), 2)]) + ), + "taxable_interest_income": round(rng.uniform(0, 800), 2), + "tax_exempt_interest_income": round(rng.uniform(0, 500), 2), + "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), + # long_term_capital_gains is net of the carryover, as + # Schedule D line 15 is. + "long_term_capital_gains": round(long_term - carryover, 2), + "long_term_capital_loss_carryover": carryover, + "short_term_capital_gains": short_term, + "non_sch_d_capital_gains": _distributions(rng), + "rental_income": _rental_income(rng), + "taxable_private_pension_income": float( + rng.choice([0, round(rng.uniform(0, 3_000), 2)]) + ), + "tax_exempt_public_pension_income": float( + rng.choice([0, round(rng.uniform(0, 3_000), 2)]) + ), + "social_security_retirement": float( + rng.choice([0, round(rng.uniform(0, 30_000), 2)]) + ), + } + ) + if rng.random() < 0.35: + # A child dependent with income of their own, including net + # capital losses, which count up to the dependent's own limit. + long_term, short_term = _capital_gains(rng, can_be_large=False) + carryover = _carryover(rng) + members.append( + { + "role": "dependent", + "age": int(rng.integers(5, 18)), + "is_tax_unit_dependent": True, + "employment_income": float( + rng.choice([0, round(rng.uniform(0, 4_000), 2)]) + ), + "taxable_interest_income": round(rng.uniform(0, 300), 2), + "tax_exempt_interest_income": round(rng.uniform(0, 200), 2), + "qualified_dividend_income": round(rng.uniform(0, 1_500), 2), + # long_term_capital_gains is net of the carryover, as + # Schedule D line 15 is. + "long_term_capital_gains": round(long_term - carryover, 2), + "long_term_capital_loss_carryover": carryover, + "short_term_capital_gains": short_term, + "non_sch_d_capital_gains": _distributions(rng), + "rental_income": _rental_income(rng), + "taxable_private_pension_income": 0.0, + "tax_exempt_public_pension_income": 0.0, + "social_security_survivors": float( + rng.choice([0, round(rng.uniform(0, 8_000), 2)]) + ), + } + ) + if rng.random() < 0.15: + # Little income besides a capital loss and a small tax-exempt + # pension, so line J can be negative (2023-2025 instructions, + # page 5, line J note). + for member in members: + for key in [ + "employment_income", + "taxable_interest_income", + "qualified_dividend_income", + "non_sch_d_capital_gains", + "rental_income", + "taxable_private_pension_income", + ]: + member[key] = 0.0 + member["tax_exempt_interest_income"] = round(rng.uniform(0, 200), 2) + members[0]["long_term_capital_gains"] = round( + rng.uniform(-6_000, -1_000), 2 + ) + members[0]["tax_exempt_public_pension_income"] = round( + rng.uniform(0, 1_500), 2 + ) + units.append( + { + "members": members, + "rent": round(rng.uniform(1_000, 12_000), 2), + "gain_change": round(rng.uniform(1, 5_000), 2), + } + ) + return units + + +def _member(unit: dict, role: str = "head") -> dict: + return next(member for member in unit["members"] if member["role"] == role) + + +def _with_gains_changed(units: list, sign: int, role: str = "head") -> list: + changed = [] + for unit in units: + members = [dict(m) for m in unit["members"]] + changed_unit = {**unit, "members": members} + member = _member(changed_unit, role) + member["long_term_capital_gains"] = round( + member["long_term_capital_gains"] + sign * unit["gain_change"], 2 + ) + changed.append(changed_unit) + return changed + + +def _situation(units: list) -> dict: + people, tax_units, households = {}, {}, {} + year = str(YEAR) + for i, unit in enumerate(units): + names = [] + for member in unit["members"]: + name = f"{member['role']}_{i}" + names.append(name) + person = { + key: {year: value} for key, value in member.items() if key != "role" + } + if member["role"] == "head": + person["rent"] = {year: unit["rent"]} + people[name] = person + tax_units[f"tax_unit_{i}"] = {"members": names} + households[f"household_{i}"] = { + "members": names, + "state_code": {year: "AZ"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def _net_gain(member: dict) -> float: + """The member's own net gain or loss for the year (Form 140PTC line D). + + Capital gain distributions are long-term capital gains (26 U.S.C. + 852(b)(3)(B)), combined with the member's other gains and losses. A + prior-year carryover cannot reduce them (ITR 12-1 item (7)), and the + long_term_capital_gains input is net of it, so it is added back. + """ + return ( + member["long_term_capital_gains"] + + member["long_term_capital_loss_carryover"] + + member["short_term_capital_gains"] + + member["non_sch_d_capital_gains"] + ) + + +def _line_d(member: dict) -> float: + """Form 140PTC line D for one member: a net loss is limited to (1,500).""" + return max(_net_gain(member), -MEMBER_LOSS_LIMIT) + + +def _form_140ptc_line_j(units: list) -> np.ndarray: + """Form 140PTC Part 1 line J from the line A to I instructions.""" + totals = [] + for unit in units: + total = 0.0 + for m in unit["members"]: + line_a = m["employment_income"] + line_b = ( + m["taxable_interest_income"] + + m["tax_exempt_interest_income"] + + m["qualified_dividend_income"] + ) + line_e = ( + m["taxable_private_pension_income"] + + m["tax_exempt_public_pension_income"] + ) + # A rental loss counts in full (A.A.C. R15-2C-502(C)(2)). + line_f = m["rental_income"] + # Social Security benefits are not income for the credit. Every + # member counts, dependent or not (A.A.C. R15-2C-502(A)(2), (B)). + total += line_a + line_b + _line_d(m) + line_e + line_f + totals.append(total) + return np.array(totals) + + +UNITS = _sample_units(np.random.default_rng(SEED)) +LINE_J = _form_140ptc_line_j(UNITS) +# Keep every original head case, then repeat households with dependents to +# change the dependent's gains in the same vectorized simulations. +DEPENDENT_UNITS = [ + unit for unit in UNITS if any(m["role"] == "dependent" for m in unit["members"]) +] +GAIN_CHANGE_UNITS = UNITS + DEPENDENT_UNITS +GAIN_CHANGE_ROLES = ["head"] * len(UNITS) + ["dependent"] * len(DEPENDENT_UNITS) +RAISED = _with_gains_changed(UNITS, sign=1) + _with_gains_changed( + DEPENDENT_UNITS, sign=1, role="dependent" +) +LOWERED = _with_gains_changed(UNITS, sign=-1) + _with_gains_changed( + DEPENDENT_UNITS, sign=-1, role="dependent" +) + + +def test_sample_covers_the_loss_limit_and_negative_line_j(): + members = [m for unit in UNITS for m in unit["members"]] + beyond_limit = [m for m in members if _net_gain(m) < -MEMBER_LOSS_LIMIT] + assert len(beyond_limit) >= 20 + assert any(m["role"] == "dependent" for m in beyond_limit) + + # Units where federal AGI's capital gains and losses differ from every + # member's line D. Federal AGI adds non-dependents' positive gains and + # distributions, then deducts their losses up to those gains plus a net + # loss of up to $3,000 per return. + def federal_capital(unit: dict) -> float: + filers = [m for m in unit["members"] if m["role"] != "dependent"] + gains = sum( + max(0, m["long_term_capital_gains"] + m["short_term_capital_gains"]) + + m["non_sch_d_capital_gains"] + for m in filers + ) + losses = sum( + max(0, -(m["long_term_capital_gains"] + m["short_term_capital_gains"])) + for m in filers + ) + allowed_against_gains = min(losses, gains) + return ( + gains - allowed_against_gains - min(3_000, losses - allowed_against_gains) + ) + + federal = np.array([federal_capital(unit) for unit in UNITS]) + line_d = np.array([sum(_line_d(m) for m in unit["members"]) for unit in UNITS]) + assert (np.abs(federal - line_d) > 0.01).sum() >= 20 + assert (LINE_J < 0).sum() >= 3 + # Distributions alongside a net capital loss, and rental losses, including + # dependents'. + assert any( + m["non_sch_d_capital_gains"] > 0 + and m["long_term_capital_gains"] + m["short_term_capital_gains"] < 0 + for m in members + ) + assert any(m["rental_income"] < 0 for m in members if m["role"] == "dependent") + # Carryovers, including some that make the input a loss beyond the limit + # while the year's own net gain is not. + assert sum(m["long_term_capital_loss_carryover"] > 0 for m in members) >= 10 + assert any( + m["long_term_capital_gains"] + m["short_term_capital_gains"] + < -MEMBER_LOSS_LIMIT + <= _net_gain(m) + for m in members + ) + + +def test_household_income_matches_form_140ptc_lines(): + sim = Simulation(situation=_situation(UNITS)) + income = sim.calculate("az_property_tax_credit_income", YEAR) + np.testing.assert_allclose(income, LINE_J, atol=0.01) + + +def test_gains_count_once_up_to_the_loss_limit(): + base = Simulation(situation=_situation(GAIN_CHANGE_UNITS)) + raised = Simulation(situation=_situation(RAISED)) + expected = np.array( + [ + _line_d(_member(r, role)) - _line_d(_member(u, role)) + for u, r, role in zip(GAIN_CHANGE_UNITS, RAISED, GAIN_CHANGE_ROLES) + ] + ) + income_change = raised.calculate( + "az_property_tax_credit_income", YEAR + ) - base.calculate("az_property_tax_credit_income", YEAR) + np.testing.assert_allclose(income_change, expected, atol=0.01) + # Where the changed member's net gain stays at or above the limit, + # the change is d, for heads and dependents alike. + stays_above = np.array( + [ + _net_gain(_member(unit, role)) >= -MEMBER_LOSS_LIMIT + for unit, role in zip(GAIN_CHANGE_UNITS, GAIN_CHANGE_ROLES) + ] + ) + increase = np.array([unit["gain_change"] for unit in GAIN_CHANGE_UNITS]) + assert stays_above[:N].sum() >= 20 + assert stays_above[N:].any() + np.testing.assert_allclose( + income_change[stays_above], increase[stays_above], atol=0.01 + ) + credit_change = raised.calculate("az_property_tax_credit", YEAR) - base.calculate( + "az_property_tax_credit", YEAR + ) + assert (credit_change <= 0.01).all() + + +def test_member_loss_limit(): + base = Simulation(situation=_situation(GAIN_CHANGE_UNITS)) + lowered = Simulation(situation=_situation(LOWERED)) + income_change = lowered.calculate( + "az_property_tax_credit_income", YEAR + ) - base.calculate("az_property_tax_credit_income", YEAR) + expected = np.array( + [ + _line_d(_member(w, role)) - _line_d(_member(u, role)) + for u, w, role in zip(GAIN_CHANGE_UNITS, LOWERED, GAIN_CHANGE_ROLES) + ] + ) + np.testing.assert_allclose(income_change, expected, atol=0.01) + # Once a member's net gain is at or below the limit, more loss is ignored. + at_limit = np.array( + [ + _net_gain(_member(unit, role)) <= -MEMBER_LOSS_LIMIT + for unit, role in zip(GAIN_CHANGE_UNITS, GAIN_CHANGE_ROLES) + ] + ) + assert at_limit[:N].sum() >= 10 + assert at_limit[N:].any() + np.testing.assert_allclose(income_change[at_limit], 0, atol=0.01) + # Each member's line D is bounded below by the limit. + line_d = base.calculate("az_property_tax_credit_capital_gains", YEAR) + assert (line_d >= -MEMBER_LOSS_LIMIT - 0.01).all() + + +def test_credit_uses_zero_for_negative_household_income(): + sim = Simulation(situation=_situation(UNITS)) + credit = sim.calculate("az_property_tax_credit", YEAR) + floored = Simulation(situation=_situation(UNITS)) + floored.set_input("az_property_tax_credit_income", YEAR, np.maximum(LINE_J, 0)) + np.testing.assert_allclose( + credit, floored.calculate("az_property_tax_credit", YEAR), atol=0.01 + ) + # A negative line J gets the zero-income schedule amount ($502 on both + # schedules), limited by the renter's property taxes. + negative = LINE_J < 0 + rent = np.array([unit["rent"] for unit in UNITS]) + np.testing.assert_allclose( + credit[negative], + np.minimum(502, RENT_PROPERTY_TAX_RATE * rent[negative]), + atol=0.01, + ) + + +def test_income_does_not_read_preferential_rate_amount(): + base = Simulation(situation=_situation(UNITS)) + sim = Simulation(situation=_situation(UNITS)) + rng = np.random.default_rng(SEED + 1) + sim.set_input( + "capital_gains_excluded_from_taxable_income", + YEAR, + rng.uniform(0, 50_000, len(UNITS)), + ) + np.testing.assert_allclose( + sim.calculate("az_property_tax_credit_income", YEAR), + base.calculate("az_property_tax_credit_income", YEAR), + atol=0.01, + ) + + +def test_line_d_survives_a_loss_deduction_without_capital_losses(): + """A reform that drops the federal capital loss deduction leaves line J whole. + + az_property_tax_credit_agi adds back only the capital part of loss_ald + (loss_ald less limited_business_loss), so a federal loss deduction with no + capital loss in it is not reversed as one. The reform is applied to an + existing simulation, which replaces the variable without rebuilding the + country model. + """ + from policyengine_core.reforms import Reform + from policyengine_us.model_api import YEAR as ANNUAL + from policyengine_us.model_api import TaxUnit, USD, Variable + + class business_losses_only(Reform): + def apply(self): + class loss_ald(Variable): + value_type = float + entity = TaxUnit + label = "Business losses only" + unit = USD + definition_period = ANNUAL + + def formula(tax_unit, period, parameters): + return tax_unit("limited_business_loss", period) + + self.update_variable(loss_ald) + + year = str(YEAR) + situation = { + "people": { + "head": { + "age": {year: 70}, + "employment_income": {year: 4_000}, + "long_term_capital_gains": {year: -5_000}, + "rental_income": {year: -1_000}, + "rent": {year: 5_000}, + } + }, + "tax_units": {"tax_unit": {"members": ["head"]}}, + "households": {"household": {"members": ["head"], "state_code": {year: "AZ"}}}, + } + sim = Simulation(situation=situation) + sim.apply_reform(business_losses_only) + # Federal AGI takes the rental loss only: 4,000 - 1,000. + assert sim.calculate("adjusted_gross_income", YEAR)[0] == 3_000 + # Form 140PTC: line A 4,000 + line D (1,500) + line F (1,000) = 1,500. + np.testing.assert_allclose( + sim.calculate("az_property_tax_credit_income", YEAR), [1_500], atol=0.01 + ) + # Schedule 1, 0 - 1,750 -> 502; property taxes 0.15 x 5,000 = 750. + np.testing.assert_allclose( + sim.calculate("az_property_tax_credit", YEAR), [502], atol=0.01 + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py new file mode 100644 index 00000000000..153c74d4982 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_business_loss.py @@ -0,0 +1,96 @@ +from policyengine_us.model_api import * + + +class limited_business_loss(Variable): + value_type = float + entity = TaxUnit + label = "Business loss deduction allowed under section 461(l)" + unit = USD + documentation = ( + "Business, farm, rental, estate and partnership/S corporation losses of " + "members who are not dependents, deducted above the line up to the " + "section 461(l) limit: business income plus the threshold amount. " + "loss_ald is this plus its two capital loss parts." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/165", + "https://www.law.cornell.edu/uscode/text/26/461#l", + ) + + def formula(tax_unit, period, parameters): + filing_status = tax_unit("filing_status", period) + # Section 461(l) excess business loss limitation + threshold_amount = parameters(period).gov.irs.ald.loss.max[filing_status] + person = tax_unit.members + # A tax-unit dependent's business, rental and estate items belong on + # the dependent's own return, as in irs_gross_income. They neither + # add to this return's losses nor raise its Section 461(l) limit. + not_dependent = ~person("is_tax_unit_dependent", period) + + # Section 461(l) compares business deductions to business gross + # income/gains plus the threshold amount. In this model, positive + # business amounts flow into gross income and losses are deducted here, + # so allowable business losses are capped at business income plus + # the threshold. + indiv_se_income = max_(0, person("total_self_employment_income", period)) + indiv_se_loss = max_(0, -person("total_self_employment_income", period)) + self_employment_income = tax_unit.sum(not_dependent * indiv_se_income) + self_employment_loss = tax_unit.sum(not_dependent * indiv_se_loss) + + # Schedule F farm business income/losses. + indiv_farm_operations_income = max_(0, person("farm_operations_income", period)) + indiv_farm_loss = max_(0, -person("farm_operations_income", period)) + farm_operations_income = tax_unit.sum( + not_dependent * indiv_farm_operations_income + ) + farm_loss = tax_unit.sum(not_dependent * indiv_farm_loss) + + # Schedule E rental, farm-rent, and estate/trust items. + indiv_rental_income = max_(0, person("rental_income", period)) + indiv_rental_loss = max_(0, -person("rental_income", period)) + rental_income = tax_unit.sum(not_dependent * indiv_rental_income) + rental_loss = tax_unit.sum(not_dependent * indiv_rental_loss) + + indiv_farm_rent_income = max_(0, person("farm_rent_income", period)) + indiv_farm_rent_loss = max_(0, -person("farm_rent_income", period)) + farm_rent_income = tax_unit.sum(not_dependent * indiv_farm_rent_income) + farm_rent_loss = tax_unit.sum(not_dependent * indiv_farm_rent_loss) + + indiv_estate_income = max_(0, person("estate_income", period)) + indiv_estate_loss = max_(0, -person("estate_income", period)) + estate_income = tax_unit.sum(not_dependent * indiv_estate_income) + estate_loss = tax_unit.sum(not_dependent * indiv_estate_loss) + + # Partnership/S-corp losses (Schedule E) + indiv_scorp_income = max_(0, person("partnership_s_corp_income", period)) + indiv_scorp_loss = max_(0, -person("partnership_s_corp_income", period)) + partnership_s_corp_income = tax_unit.sum(not_dependent * indiv_scorp_income) + partnership_s_corp_loss = tax_unit.sum(not_dependent * indiv_scorp_loss) + + other_net_gain = tax_unit("other_net_gain", period) + other_business_gain = max_(0, other_net_gain) + other_business_loss = max_(0, -other_net_gain) + + # Total business losses subject to Section 461(l) limitation + total_business_income = ( + self_employment_income + + farm_operations_income + + rental_income + + farm_rent_income + + estate_income + + partnership_s_corp_income + + other_business_gain + ) + total_business_loss = ( + self_employment_loss + + farm_loss + + rental_loss + + farm_rent_loss + + estate_loss + + partnership_s_corp_loss + + other_business_loss + ) + max_deductible_business_loss = total_business_income + threshold_amount + limited_business_loss = min_(total_business_loss, max_deductible_business_loss) + return limited_business_loss diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py index d51b44af919..cc5a463265e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py @@ -16,91 +16,11 @@ class loss_ald(Variable): "https://www.law.cornell.edu/uscode/text/26/1211#b", ) - def formula(tax_unit, period, parameters): - filing_status = tax_unit("filing_status", period) - # Section 461(l) excess business loss limitation - threshold_amount = parameters(period).gov.irs.ald.loss.max[filing_status] - person = tax_unit.members - # A tax-unit dependent's business, rental and estate items belong on - # the dependent's own return, as in irs_gross_income. They neither - # add to this return's losses nor raise its Section 461(l) limit. - not_dependent = ~person("is_tax_unit_dependent", period) - - # Section 461(l) compares business deductions to business gross - # income/gains plus the threshold amount. In this model, positive - # business amounts flow into gross income and losses are deducted here, - # so allowable business losses are capped at business income plus - # the threshold. - indiv_se_income = max_(0, person("total_self_employment_income", period)) - indiv_se_loss = max_(0, -person("total_self_employment_income", period)) - self_employment_income = tax_unit.sum(not_dependent * indiv_se_income) - self_employment_loss = tax_unit.sum(not_dependent * indiv_se_loss) - - # Schedule F farm business income/losses. - indiv_farm_operations_income = max_(0, person("farm_operations_income", period)) - indiv_farm_loss = max_(0, -person("farm_operations_income", period)) - farm_operations_income = tax_unit.sum( - not_dependent * indiv_farm_operations_income - ) - farm_loss = tax_unit.sum(not_dependent * indiv_farm_loss) - - # Schedule E rental, farm-rent, and estate/trust items. - indiv_rental_income = max_(0, person("rental_income", period)) - indiv_rental_loss = max_(0, -person("rental_income", period)) - rental_income = tax_unit.sum(not_dependent * indiv_rental_income) - rental_loss = tax_unit.sum(not_dependent * indiv_rental_loss) - - indiv_farm_rent_income = max_(0, person("farm_rent_income", period)) - indiv_farm_rent_loss = max_(0, -person("farm_rent_income", period)) - farm_rent_income = tax_unit.sum(not_dependent * indiv_farm_rent_income) - farm_rent_loss = tax_unit.sum(not_dependent * indiv_farm_rent_loss) - - indiv_estate_income = max_(0, person("estate_income", period)) - indiv_estate_loss = max_(0, -person("estate_income", period)) - estate_income = tax_unit.sum(not_dependent * indiv_estate_income) - estate_loss = tax_unit.sum(not_dependent * indiv_estate_loss) - - # Partnership/S-corp losses (Schedule E) - indiv_scorp_income = max_(0, person("partnership_s_corp_income", period)) - indiv_scorp_loss = max_(0, -person("partnership_s_corp_income", period)) - partnership_s_corp_income = tax_unit.sum(not_dependent * indiv_scorp_income) - partnership_s_corp_loss = tax_unit.sum(not_dependent * indiv_scorp_loss) - - other_net_gain = tax_unit("other_net_gain", period) - other_business_gain = max_(0, other_net_gain) - other_business_loss = max_(0, -other_net_gain) - - # Total business losses subject to Section 461(l) limitation - total_business_income = ( - self_employment_income - + farm_operations_income - + rental_income - + farm_rent_income - + estate_income - + partnership_s_corp_income - + other_business_gain - ) - total_business_loss = ( - self_employment_loss - + farm_loss - + rental_loss - + farm_rent_loss - + estate_loss - + partnership_s_corp_loss - + other_business_loss - ) - max_deductible_business_loss = total_business_income + threshold_amount - limited_business_loss = min_(total_business_loss, max_deductible_business_loss) - - # Section 1211(b) allows capital losses to the extent of capital - # gains, plus a net loss of up to $3,000. - capital_losses_allowed_against_gains = tax_unit( - "capital_losses_allowed_against_gains", period - ) - limited_capital_loss = tax_unit("limited_capital_loss", period) - - return ( - limited_business_loss - + capital_losses_allowed_against_gains - + limited_capital_loss - ) + # Business losses allowed under section 461(l), plus capital losses + # allowed under section 1211(b): to the extent of capital gains, and a net + # loss of up to $3,000. + adds = [ + "limited_business_loss", + "capital_losses_allowed_against_gains", + "limited_capital_loss", + ] diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py index da5ead3a06e..6ffe7f57b75 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit.py @@ -7,6 +7,10 @@ class az_property_tax_credit(Variable): label = "Arizona Property Tax Credit" unit = USD definition_period = YEAR + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(B) + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=5", + ] defined_for = "az_property_tax_credit_eligible" def formula(tax_unit, period, parameters): @@ -27,10 +31,18 @@ def formula(tax_unit, period, parameters): | (household_size > 1) ) + # Household income (Form 140PTC line J) can be negative, for example + # after a line D capital loss. The 2023-2025 instructions (page 5, line + # J note) then consider household income to be zero for the Schedule 1 + # and 2 lookup. The 2021 and 2022 instructions have no such note; the + # schedules start at 0, so the same floor is applied in every year. A + # single-amount scale returns 0 below its first threshold, so a + # negative income must be floored here rather than passed to calc. + schedule_income = max_(income, 0) cap = where( lives_with_others, - p.amount.cohabitating.calc(income), - p.amount.living_alone.calc(income), + p.amount.cohabitating.calc(schedule_income), + p.amount.living_alone.calc(schedule_income), ) # ARS 43-1072(B): the credit is the lesser of the table amount and diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_agi.py index 90e6818719f..febb7a11b74 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 @@ -8,16 +8,24 @@ class az_property_tax_credit_agi(Variable): unit = USD definition_period = YEAR documentation = ( - "Adjusted gross income as defined for Arizona property tax credit purposes. " - "Per ARS 43-1072(H)(6) and ITR 12-1, this starts with Federal AGI and only " - "excludes items specifically listed in ARS 43-1072(I): Social Security, " - "railroad retirement, workers compensation, Arizona unemployment, veterans " - "disability pensions, welfare, and gifts. Unlike regular Arizona income tax, " - "this does NOT exclude pension income, capital gains, or Arizona exemptions." + "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 " + "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 " + "loss deduction that federal AGI holds. Dependents' income, including their " + "line D amounts, is in az_property_tax_credit_dependent_income. Can be " + "negative." ) reference = [ "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072 "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf", # ITR 12-1 + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", ] defined_for = StateCode.AZ @@ -48,4 +56,59 @@ def formula(tax_unit, period, parameters): # - US Government interest should be INCLUDED per ITR 12-1 item (2) # - Arizona exemptions (aged, blind) should NOT be subtracted - return max_(0, federal_agi - taxable_social_security) + # Capital gains and losses (Form 140PTC Part 1 line D). A.A.C. + # R15-2C-502(C)(3) and ITR 12-1 item (7) count each member's net + # capital gain or loss, with a net loss limited to $1,500 for each + # member. Federal AGI instead adds each non-dependent's positive + # capital gains (irs_gross_income) and, within loss_ald, deducts the + # return's capital losses up to its gains + # (capital_losses_allowed_against_gains) plus a net loss limited per + # return (limited_capital_loss). Those federal amounts are taken out + # and each non-dependent's line D amount is put in. Only what federal AGI + # actually holds is taken out: a reform that drops a source from + # gross income or the loss deduction from the above-the-line list + # leaves nothing to reverse. Line D still counts gains left out of + # federal AGI, as ARS 43-1072(H)(6)(b) adds them back. + p = parameters(period).gov.irs + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + gross_income_sources = p.gross_income.sources + capital_gains_in_agi = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in gross_income_sources: + capital_gains_in_agi += max_(0, person(source, period)) + federal_capital_gains = tax_unit.sum(not_dependent * capital_gains_in_agi) + if "loss_ald" in p.ald.deductions: + # loss_ald adds limited_business_loss (section 461(l)) and the two + # capital parts. Its capital part is what remains after the + # business part, at most the two capital parts, so a supplied + # loss_ald, or a replacement formula built on + # limited_business_loss, that holds business losses only is not + # read as a capital deduction. A replacement that computes its + # own business losses should replace limited_business_loss too. + federal_capital_loss = clip( + tax_unit("loss_ald", period) + - tax_unit("limited_business_loss", period), + 0, + add( + tax_unit, + period, + ["capital_losses_allowed_against_gains", "limited_capital_loss"], + ), + ) + else: + federal_capital_loss = 0 + line_d = tax_unit.sum( + not_dependent * person("az_property_tax_credit_capital_gains", period) + ) + + # Not floored at zero: household income (Form 140PTC line J) can be + # negative, and az_property_tax_credit treats it as zero only for the + # Schedule 1 and 2 lookup. + return ( + federal_agi + - taxable_social_security + - federal_capital_gains + + federal_capital_loss + + line_d + ) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py new file mode 100644 index 00000000000..f0d2ca0e313 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_capital_gains.py @@ -0,0 +1,52 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_capital_gains(Variable): + value_type = float + entity = Person + label = "Arizona property tax credit capital gains and losses" + unit = USD + definition_period = YEAR + documentation = ( + "A household member's gains and losses from the sale or exchange of " + "property for the Arizona property tax credit (Form 140PTC Part 1 line D): " + "the year's gains and losses combined, including capital gain " + "distributions, with a net loss counted only up to the per-member limit. " + "No prior-year capital loss carryover is used: the long-term carryover " + "that long_term_capital_gains nets is added back, including unused " + "losses transferred by a terminating estate or trust. Short-term inputs " + "must contain only the current year's net gains and losses, without " + "Schedule D line 6 carryovers; no short-term carryover memo is modeled." + ) + reference = [ + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + "https://www.law.cornell.edu/uscode/text/26/852#b_3_B", + "https://www.law.cornell.edu/uscode/text/26/1212#b_1_B", + "https://www.law.cornell.edu/cfr/text/26/1.642(h)-1", + ] + defined_for = StateCode.AZ + + def formula(person, period, parameters): + p = parameters(period).gov.states.az.tax.income.credits.property_tax + # A.A.C. R15-2C-502(C)(3): "Income from capital gains is the net capital + # gains and losses for each member of the household. Net losses are + # limited to $1,500 for each household member." The limit applies to + # each member separately, not to the return as the federal limit does. + # Capital gain distributions reported without Schedule D are long-term + # capital gains (26 U.S.C. 852(b)(3)(B)), so they are combined with the + # member's other gains and losses before the limit. + # ITR 12-1 item (7): a prior-year capital loss carryover cannot reduce + # the year's gains. long_term_capital_gains includes the long-term + # carryover (26 U.S.C. 1212(b)(1)(B)) as a loss, as Schedule D line 15 + # does, so it is added back to give the year's own net gain or loss. + # Reg. 1.642(h)-1(a) transfers a terminating estate/trust's unused + # capital loss as a carryover. Max's d1107 ruling (2026-10-09) keeps + # final Form 1041 K-1 box 11 code D losses in this addback. + carryover = max_(0, person("long_term_capital_loss_carryover", period)) + net_capital_gain = ( + add(person, period, ["capital_gains", "non_sch_d_capital_gains"]) + + carryover + ) + return max_(net_capital_gain, -p.capital_loss_limit) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py new file mode 100644 index 00000000000..45b20a29896 --- /dev/null +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_dependent_income.py @@ -0,0 +1,60 @@ +from policyengine_us.model_api import * + + +class az_property_tax_credit_dependent_income(Variable): + value_type = float + entity = TaxUnit + label = "Arizona property tax credit income of dependents" + unit = USD + definition_period = YEAR + documentation = ( + "Income of the tax unit's dependents for the Arizona property tax credit. " + "Federal AGI, which az_property_tax_credit_agi starts from, holds only the " + "income of members who are not dependents; Arizona combines the income of " + "every member of the household, whether or not the member is a dependent. " + "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." + ) + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(4)-(6), (I) + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/2023-03/RULINGS_INDV_2012_itr12-1.pdf#page=2", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] + defined_for = StateCode.AZ + + def formula(tax_unit, period, parameters): + person = tax_unit.members + # A.A.C. R15-2C-502(A)(2) and (B): household income combines the + # separately determined income of each member, "whether or not the + # person is related to, or a dependent of, the claimant". irs_gross_income + # counts the same federal sources for members who are not dependents. + # A dependent's losses are not in federal AGI (loss_ald and + # limited_capital_loss cover members who are not dependents), so each + # 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. + 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. + if source in ( + "taxable_social_security", + "capital_gains", + "non_sch_d_capital_gains", + ): + continue + income += add(person, period, [source]) + # Form 140PTC line D: a dependent is a household member like any + # other, so a net capital loss counts, limited to $1,500 for the + # dependent (A.A.C. R15-2C-502(C)(3)), as it is for the claimant. + income += person("az_property_tax_credit_capital_gains", period) + is_dependent = person("is_tax_unit_dependent", period) + return tax_unit.sum(is_dependent * income) diff --git a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py index fb47ebd3bf1..dbcca77417f 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py +++ b/policyengine_us/variables/gov/states/az/tax/income/credits/property_tax_credit/az_property_tax_credit_income.py @@ -4,9 +4,19 @@ class az_property_tax_credit_income(Variable): value_type = float entity = TaxUnit - label = "Income for the Arizona property tax the credit" + label = "Income for the Arizona property tax credit" unit = USD definition_period = YEAR + documentation = ( + "Household income for the Arizona property tax credit, Form 140PTC Part 1 " + "line J. It can be negative after a capital loss; the credit schedules " + "then use zero." + ) + reference = [ + "https://www.azleg.gov/ars/43/01072.htm", # ARS 43-1072(H)(6) + "https://www.law.cornell.edu/regulations/arizona/Ariz-Admin-Code-SS-R15-2C-502", + "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140PTCi.pdf#page=4", + ] defined_for = StateCode.AZ adds = "gov.states.az.tax.income.credits.property_tax.income_sources"