diff --git a/AGENTS.md b/AGENTS.md index 4cb191a92ba..17e09636af7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,23 +6,24 @@ Follow the repository guidance in `CLAUDE.md` for commands, style, changelog ent Follow [Test design and CI cost](CLAUDE.md#test-design-and-ci-cost) before adding or expanding tests. Use variable-named YAML tests for policy calculations; reserve Python tests for behavior YAML cannot exercise. Preserve Core/data compatibility and isolation coverage, avoid repeated full-model construction, and do not add CI runners or parallel heavy processes without explicit authorization. Document the purpose and measured cost of new simulation-heavy Python tests in the PR. -## Partner API Contract Tests +## Partner API contract tests Files under `policyengine_us/tests/policy/baseline/partners/**` are API partner contract tests. Do not rewrite these expected outputs merely to match changed model behavior or make CI pass. If a model change causes one of these tests to fail, treat that as a possible partner-facing API change. -Subagents must not edit partner test files. If a subagent finds that an edit is needed, it must stop and report back; the top-level agent runs the three-question gate with the user before any edit is made. +Ziming Hua (@hua7450) approves every edit to these files (Max Ghenis, 2026-10-09). Subagents and agent teammates must not edit partner test files. If a subagent or agent teammate finds that an edit is needed, it must stop and report back; the top-level agent takes the three-question gate to Ziming before any edit is made. Before changing expected outputs in this folder: - Flag the partner-facing risk to the user. -- Use the `AskUserQuestion` tool to ask these three questions in a single call (per CLAUDE.md): +- Put these three questions to Ziming on the PR that causes the change (per CLAUDE.md): request his review with `gh pr edit --add-reviewer hua7450`, and post a comment that names each changed case, its old and new expected values, and the model change and law behind it. If Ziming is the person you are working with, show him the changed cases and ask him directly with the `AskUserQuestion` tool instead; record his answer in a PR comment, which counts as his approval of those edits. 1. Are you sure you want to edit this test file? 2. Have you notified a team member about this change? 3. Have you notified the API partner about this change? +- Do not edit partner test files until Ziming has answered yes to all three questions, and do not merge a PR that changes them until he has approved a head that contains the edits. - Identify the model change that caused the partner output change. - Preserve the failing behavior as evidence unless the change is intentional. -- Explain the partner-facing impact to the user. +- Explain the partner-facing impact to Ziming and the user. -Changing these tests without explicit user confirmation is unsafe, even if CI passes afterward. +Changing these tests without Ziming's explicit approval is unsafe, even if CI passes afterward. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d97e3160d1..18707ac892a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,185 @@ +## [2.38.23] - 2026-10-11 + +### Fixed + +- Allow non-disaster personal casualty and theft losses in California and Minnesota itemized deductions (and Minnesota alternative minimum taxable income), which do not follow the federal limit to declared disasters (Cal. Rev. & Tax. Code 17204(b); Minn. Stat. 290.0122, subd. 8): add `ca_casualty_loss_deduction` and `mn_casualty_loss_deduction`, each the loss less $100 and 10% of federal adjusted gross income, in place of the federal deduction. + + +## [2.38.22] - 2026-10-11 + +### Fixed + +- New York itemized deductions now allow medical and dental expenses only above 10% of federal AGI, the pre-TCJA 26 U.S.C. 213(a) floor that NY Tax Law § 615(a) keeps and Form IT-196 line 3 applies, through a new ny_medical_expense_deduction used by ny_itemized_deductions_max and the Line 40 limitation. They previously used the federal 7.5% floor, which overstated the deduction by up to 2.5% of federal AGI. + + +## [2.38.21] - 2026-10-11 + +### Changed + +- Uprate employer-sponsored insurance premiums and pre-tax health insurance premiums by CMS's employer-sponsored insurance premium per enrollee (NHE projections Table 17, 2025-2034 release, extended at its 2033-2034 growth rate), replacing CBO employment income per capita and CMS out-of-pocket spending per capita. + + +## [2.38.20] - 2026-10-11 + +### Fixed + +- Count only a person's own Illinois withholding as an Illinois AABD employment expense, instead of giving every tax unit member the whole unit's withholding (89 Ill. Adm. Code 113.125(a)). This also corrects HBWD countable income and premiums and HBIS countable income. + + +## [2.38.19] - 2026-10-11 + +### Fixed + +- New York Drive Clean rebate amounts and the MSRP cap for the flat rebate now change on July 1, 2021, for vehicles purchased after June 30, 2021, replacing the impossible date 2021-06-31 and a June 30 date one day early; a test now rejects any parameter date that is not a calendar day, other than the 0000-01-01 sentinel. + + +## [2.38.18] - 2026-10-11 + +### Fixed + +- Exclude SNAP benefits from California CARE and FERA countable income in all modeled years. + + +## [2.38.17] - 2026-10-11 + +### Fixed + +- Montana elderly homeowner/renter credit household income includes dependents' own allowable non-loss adjustments to federal gross income. +- Count household members' income in the Montana elderly homeowner/renter credit: a tax unit dependent's gross income, and the income of household members who file their own returns. +- Count federal refundable credits, the elderly homeowner/renter credit received during the year, and actual property tax rebate receipts (including in 2023) in Montana's elderly homeowner/renter credit gross household income; date the Montana property tax rebate ($400 for tax year 2024, none after). +- Preserve zero Montana federal income tax deduction allocation when neither filer has positive federal adjusted gross income. +- Montana's federal income tax deduction for spouses filing separately on the same form now attributes federal tax to the spouse who earned the income and caps each spouse at 5,000; the child and dependent care expense deduction phases out on the couple's combined Montana AGI and is halved only for spouses filing separately on the same form; and the elderly homeowner/renter credit counts income without regard to losses. + + +## [2.38.16] - 2026-10-11 + +### Changed + +- Strengthen qualified business income deduction income-limit coverage for net short-term losses, capital gain distributions, and comparison with the former adjusted-capital-gain cap. Cite Treasury's explicit treatment of elected investment income under Section 199A. + + +## [2.38.15] - 2026-10-11 + +### Fixed + +- Correct West Virginia's senior citizen or disability modification to exclude each person's own Schedule M income subtractions and 529 contribution deduction before applying the $8,000 cap. + + +## [2.38.14] - 2026-10-11 + +### Fixed + +- Deem SSI resources from co-resident spouses and parents. A spouse is the other member of a two-person marital unit, or the other head or spouse of one's tax unit within a shared marital unit; when a simulation has a single marital unit (as when marital units are omitted), a tax dependent is never the spouse of the person claiming them, so a simulation holding only a married couple in which one spouse is the other's tax dependent does not treat them as married. The same test decides whether a child is married. Parents are named by optional parent links, or else found by a tax-unit proxy over is_parent that cannot tell a grandparent from a parent (as in microsimulation data without parent links), and each parent's spouse counts wherever they file. The excess over the reformable allowance is deemed to each child and divided equally among the children who share those parents and are not already ineligible on citizenship or on countable income (including income deemed from parents), with redistribution. A spouse's resources count against the couple limit whenever the claim is not already joint, with ineligible-spouse exclusions for a spouse who could not be eligible (not aged, blind or disabled, or failing the citizenship condition); some applicants with a low-resource spouse newly pass while others fail. These eligibility changes also affect SSI-linked state benefits and Medicaid pathways. + + +## [2.38.13] - 2026-10-11 + +### Fixed + +- Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, including modeled Medicare premiums for enrolled filers, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical and itemized deductions. + + +## [2.38.12] - 2026-10-11 + +### Changed + +- Add tests confirming that Schedule A medical expenses exclude health insurance premiums paid through pre-tax payroll deductions. + + +## [2.38.11] - 2026-10-10 + +### Changed + +- Add a code-health test that fails when a person-level variable's adds or subtracts list names a tax unit, SPM unit or other group variable, which gives every member the whole group amount. It records seven known bug pairs in five variables and exempts only the 11 reviewed positive receipt edges in three boolean flags. New group sources and subtraction edges on those flags fail the guard. Regression and property tests cover exact registry membership, stale entries, parameter histories, and Core projection. + +### Fixed + +- Floor Montana's 2021-2023 medical expense deduction and standard deduction on a joint return's own Montana AGI (Form 2, page 1, line 14), so one spouse's loss offsets the other's income, instead of on the sum of each spouse's AGI floored at zero. From 2024, take the federal medical expense deduction (7.5% of federal AGI), because Form 2 line 2 uses federal itemized deductions; the Montana floor had fallen to zero from 2024, so the full medical expense was deducted. + + +## [2.38.10] - 2026-10-10 + +### Fixed + +- Fix the subsampling policy-binding test to support policyengine-core 3.33.1's recorded-input rebuild contract while retaining compatibility with earlier core versions. + + +## [2.38.9] - 2026-10-10 + +### Fixed + +- Regenerated the stale EITC documentation charts, whose two-adult childless curves still used the 2021 joint phase-out bonus, and added a check that every stored chart point matches the EITC parameters. + + +## [2.38.8] - 2026-10-10 + +### Fixed + +- Excluded qualifying pre-tax payroll deductions from earned income used by the federal EITC, refundable CTC, and federal-style state credits; retain California-taxable payroll HSA contributions in CalEITC earnings and preserve the AMT kiddie-tax gross earnings cap. Use California taxable wages for the YCTC zero-earned-income wage ceiling. + + +## [2.38.7] - 2026-10-10 + +### Changed + +- Recognize the intended Michigan dependent-retirement SSI interaction in state tax invariants and add a regression for the resulting heating credit increase. + +### Fixed + +- Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). The filer's Form 1040 line 7a amount, read by Michigan and by federal net investment income, now keeps a net capital gains amount supplied for the tax unit, less only the dependents' own gains and losses. +- Use the filers' shared Schedule D lines for Form 1040 capital gains, preserving supplied aggregate inputs without letting dependents' large gains introduce rounding errors. +- Correct Wisconsin retirement exclusion election reporting, prevent overlapping retirement subtractions, and include directly reported capital gain distributions in the capital gain subtraction. + + +## [2.38.6] - 2026-10-10 + +### Fixed + +- Fix traditional IRA deductions by applying employer-plan phase-outs using each person's own coverage, inferred from positive 401(k), 403(b), or self-employed pension contributions unless explicitly overridden. Defined-benefit coverage requires an explicit input. Limit deductions and combined IRA contributions to compensation with the joint spousal rule, and keep dependent contributions off the filers' return. Add IRS phase-out ranges for 2015–2026 and the 2026 IRA and catch-up dollar limits. + + +## [2.38.5] - 2026-10-10 + +### Fixed + +- Correct California itemized deductions to retain its charitable contribution limits without the federal charitable floor, allow miscellaneous expenses above 2% of federal AGI, and add back the California miscellaneous deduction for alternative minimum tax. Preserve signed California taxable income before AMT add-backs so deductions exceeding income do not inflate alternative minimum taxable income. + + +## [2.38.4] - 2026-10-09 + +### Fixed + +- Include New Jersey employee unemployment insurance and workforce development contributions in employee payroll tax, using annual employee wage bases through 2027. + + +## [2.38.3] - 2026-10-09 + +### Changed + +- Name Ziming Hua as the approver for partner API contract test edits in the agent instructions. + + +## [2.38.2] - 2026-10-09 + +### Fixed + +- Exclude estate and trust income from qualified business income unless its qualification is explicitly supplied. + + +## [2.38.1] - 2026-10-09 + +### Fixed + +- 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. + + +## [2.38.0] - 2026-10-09 + +### Added + +- Add Oregon LIHEAP regular heating assistance for program years 2025 to 2027. + + ## [2.37.2] - 2026-10-09 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index de813eb790d..99e4bbe5c7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,12 +170,14 @@ changelog.d/medicaid-ce-exclusions.md - Files under `policyengine_us/tests/policy/baseline/partners/**` are API partner contract tests - Do not rewrite these expected outputs merely to match changed model behavior or make CI pass - If a model change causes partner tests to fail, treat that as a possible partner-facing API change - - Before editing files in this folder, flag the partner-facing risk to the user and use the `AskUserQuestion` tool to ask these three questions in a single call: + - Ziming Hua (@hua7450) approves every edit to files in this folder (Max Ghenis, 2026-10-09). Before editing them, flag the partner-facing risk to the user and put these three questions to Ziming: 1. Are you sure you want to edit this test file? 2. Have you notified a team member about this change? 3. Have you notified the API partner about this change? - - Subagents and team members must not edit partner test files. If a subagent or team member finds that an edit is needed, it must stop and report back; the top-level agent runs the three-question gate with the user before any edit is made. - - Before changing expected outputs in this folder, identify the underlying model change and explain the partner impact to the user + - Ask on the PR that causes the change. Request his review (`gh pr edit --add-reviewer hua7450`) and post a comment (`gh pr comment --body-file `) that names each changed case, its old and new expected values, and the model change and law behind it. If Ziming is the person you are working with, show him the changed cases and ask him directly with the `AskUserQuestion` tool instead; record his answer in a PR comment, which counts as his approval of those edits. + - Do not edit partner test files until Ziming has answered yes to all three questions, and do not merge a PR that changes them until he has approved a head that contains the edits. + - Subagents and agent teammates must not edit partner test files. If a subagent or agent teammate finds that an edit is needed, it must stop and report back; the top-level agent takes the three-question gate to Ziming before any edit is made. + - Before changing expected outputs in this folder, identify the underlying model change and explain the partner impact to Ziming and the user - **ABSOLUTELY NEVER HARDCODE LOGIC JUST TO PASS SPECIFIC TEST CASES** - NEVER add conditional logic that returns fixed values for specific input combinations diff --git a/changelog.d/claimable-filer-ri-sc-ut-vt-wv-wi.fixed.md b/changelog.d/claimable-filer-ri-sc-ut-vt-wv-wi.fixed.md new file mode 100644 index 00000000000..74f6c4c44c5 --- /dev/null +++ b/changelog.d/claimable-filer-ri-sc-ut-vt-wv-wi.fixed.md @@ -0,0 +1 @@ +Apply the Rhode Island, South Carolina, Utah, Vermont, West Virginia and Wisconsin rules for a filer who can be claimed as a dependent: dependent exemptions, deductions and child credits on such a return, the Wisconsin dependent standard deduction and $250 exemption, the West Virginia exemptions and family credit, the Rhode Island and Vermont claimant tests for property tax relief and the renter credit, and Wisconsin's care subtraction and credit expenses. diff --git a/changelog.d/state-gross-income-estate-income.fixed.md b/changelog.d/state-gross-income-estate-income.fixed.md new file mode 100644 index 00000000000..30ef543488b --- /dev/null +++ b/changelog.d/state-gross-income-estate-income.fixed.md @@ -0,0 +1 @@ +Count a beneficiary's estate and trust income in Alabama AGI, Iowa gross income (which Iowa's tax uses through tax year 2022), Mississippi AGI, New Jersey gross income, New Mexico modified gross income, and Oklahoma gross household income for the sales tax and property tax credits. diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index 40d24f8a664..b8562e63b9c 100644 --- a/docs-quarto/methodology/spm-poverty.qmd +++ b/docs-quarto/methodology/spm-poverty.qmd @@ -50,7 +50,7 @@ The [MOOP decomposition page](moop-decomposition.qmd) records the design and the Population results use the Microcosm US dataset. The current default is Build P: 57,240 Current Population Survey Annual Social and Economic Supplement households with 166,321 people, whose weights are fitted to 5,659 administrative and demographic targets (IRS Statistics of Income by state and by income, Census population estimates by age, sex and state, CMS Medicaid and Marketplace enrollment, USDA SNAP participation and benefits, SSA SSI, TANF, state tax collections and BEA national accounts). Poverty rates are never a target. That is a rule of the calibration, not an accident: a model whose weights were fitted to survey-based poverty rates could not be checked against them. -2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, pre-tax, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Employer-sponsored insurance premiums follow CBO's projection of employment income. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. +2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Employer-sponsored insurance premiums and pre-tax health insurance premiums, the employer's and the employee's shares of employer plans, follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), also a per-person series, which CMS projects to 2034; it grows at its 2033 to 2034 rate from 2035 to 2100 and holds the 2100 value after that. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. ## What the model does not do diff --git a/docs/gov/irs/credits/eitc.ipynb b/docs/gov/irs/credits/eitc.ipynb index 2bab5cc8abc..b9d9214f7c0 100644 --- a/docs/gov/irs/credits/eitc.ipynb +++ b/docs/gov/irs/credits/eitc.ipynb @@ -10596,81 +10596,81 @@ 560, 560, 560, + 560, + 559, 552, - 545, - 537, + 544, + 536, 529, - 522, - 514, + 521, + 513, 506, - 499, - 491, - 484, - 476, - 468, - 461, - 453, - 445, - 438, - 430, + 498, + 490, + 483, + 475, + 467, + 460, + 452, + 444, + 437, + 429, 422, - 415, - 407, + 414, + 406, 399, - 392, - 384, + 391, + 383, 376, - 369, - 361, + 368, + 360, 353, - 346, - 338, + 345, + 337, 330, - 323, - 315, - 308, - 300, - 292, - 285, - 277, + 322, + 314, + 307, + 299, + 291, + 284, + 276, 269, - 262, - 254, + 261, + 253, 246, - 239, - 231, + 238, + 230, 223, - 216, - 208, + 215, + 207, 200, - 193, - 185, - 178, - 170, - 162, - 155, - 147, - 139, - 132, - 124, + 192, + 184, + 177, + 169, + 161, + 154, + 146, + 138, + 131, + 123, 116, - 109, - 101, + 108, + 100, 93, - 86, - 78, + 85, + 77, 70, - 63, - 55, + 62, + 54, 47, - 40, - 32, + 39, + 31, 24, - 17, - 9, - 2, - 0, - 0, + 16, + 8, + 1, 0, 0, 0, @@ -15165,11 +15165,10 @@ ], "scatter": [ { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 }, "type": "scatter" } @@ -15611,7 +15610,7 @@ } }, "title": { - "text": "EITC for single filers by number of qualifying children, 2022" + "text": "Earned income tax credit, 2022" }, "updatemenus": [ { @@ -26369,16 +26368,20 @@ 0, 0, 0, - 0.07650024443864822, + 0, + 0.007650146260857582, 0.07649963349103928, 0.07650024443864822, - 0.07649963349103928, 0.07650024443864822, + 0.07649963349103928, 0.07650024443864822, 0.07649963349103928, 0.07650024443864822, 0.07649993896484375, 0.07649993896484375, + 0.07650024443864822, + 0.07649993896484375, + 0.07649993896484375, 0.07649993896484375, 0.07649993896484375, 0.07650024443864822, @@ -26401,8 +26404,8 @@ 0.07650024443864822, 0.07649993896484375, 0.07649993896484375, - 0.07650024443864822, 0.07649993896484375, + 0.07650024443864822, 0.07649993896484375, 0.07649993896484375, 0.07649993896484375, @@ -26417,6 +26420,7 @@ 0.07649993896484375, 0.07649993896484375, 0.07649993896484375, + 0.07649993896484375, 0.07650024443864822, 0.07649993896484375, 0.07649993896484375, @@ -26432,19 +26436,14 @@ 0.07649993896484375, 0.07649993896484375, 0.07649993896484375, - 0.07649993896484375, - 0.07650024443864822, - 0.07649993896484375, - 0.07649993896484375, 0.07650024443864822, 0.07649963349103928, 0.07650024443864822, - 0.07649963349103928, 0.07650024443864822, + 0.07649963349103928, 0.07650024443864822, - 0.015499877743422985, - 0, - 0, + 0.07649963349103928, + 0.007850341498851776, 0, 0, 0, @@ -30939,11 +30938,10 @@ ], "scatter": [ { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 }, "type": "scatter" } @@ -31385,7 +31383,7 @@ } }, "title": { - "text": "EITC marginal tax rate for a single filer by number of qualifying children" + "text": "Earned income tax credit marginal tax rate, 2022" }, "updatemenus": [ { diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml new file mode 100644 index 00000000000..36951af5b5a --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml @@ -0,0 +1,46 @@ +description: The Centers for Medicare and Medicaid Services (CMS) reports and projects this employer-sponsored insurance spending per enrollee, which is the premium per covered person, employer and employee shares together. +metadata: + unit: currency-USD + label: CMS employer-sponsored insurance premium per enrollee + reference: + # Table17 Health Insurance Enrollment and Enrollment Growth Rates, + # "Spending per Enrollee ($)", "Employer-sponsored Insurance" row: ESI + # spending divided by ESI enrollment. ZIP Last-Modified 2026-09-03, sha256 + # 264f822618a5e1829138aea57cc6c8bf45bbaf072a72898fef5547f7ece6cd70, + # retrieved 2026-10-11 from + # https://www.cms.gov/data-research/statistics-trends-and-reports/national-health-expenditure-data/projected + - title: CMS National Health Expenditure Projections 2025-2034, Table 17 + href: https://www.cms.gov/files/zip/nhe-projections-tables.zip + # ESI "includes premiums paid by employers and/or employees for health + # insurance plans offered by the employer", for active employees, COBRA + # enrollees and retirees. + - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024" + href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf#page=29 +values: + # Historical. + 2013-01-01: 4_901 + 2014-01-01: 4_980 + 2015-01-01: 5_113 + 2016-01-01: 5_354 + 2017-01-01: 5_500 + 2018-01-01: 5_783 + 2019-01-01: 5_955 + 2020-01-01: 6_049 + 2021-01-01: 6_456 + 2022-01-01: 6_877 + 2023-01-01: 7_595 + 2024-01-01: 8_000 + # Projections. + 2025-01-01: 8_616 + 2026-01-01: 9_181 + 2027-01-01: 9_627 + 2028-01-01: 10_093 + 2029-01-01: 10_533 + 2030-01-01: 11_001 + 2031-01-01: 11_469 + 2032-01-01: 11_986 + 2033-01-01: 12_559 + 2034-01-01: 13_124 + # 2035 to 2100 grow at the 2033-2034 rate (13_124 / 12_559, 4.5% a year) + # and later years hold the 2100 value; see set_all_uprating_parameters in + # uprating_extensions.py. diff --git a/policyengine_us/parameters/gov/irs/ald/deductions.yaml b/policyengine_us/parameters/gov/irs/ald/deductions.yaml index 4a0f5b4740b..a2ecf812a21 100644 --- a/policyengine_us/parameters/gov/irs/ald/deductions.yaml +++ b/policyengine_us/parameters/gov/irs/ald/deductions.yaml @@ -12,7 +12,7 @@ values: - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald - - traditional_ira_contributions + - traditional_ira_deduction - qualified_adoption_assistance_expense - us_bonds_for_higher_ed - specified_possession_income @@ -28,7 +28,7 @@ values: - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald - - traditional_ira_contributions + - traditional_ira_deduction - qualified_adoption_assistance_expense - us_bonds_for_higher_ed - specified_possession_income @@ -43,7 +43,7 @@ values: - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald - - traditional_ira_contributions + - traditional_ira_deduction - qualified_adoption_assistance_expense - us_bonds_for_higher_ed - specified_possession_income @@ -58,7 +58,7 @@ values: - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald - - traditional_ira_contributions + - traditional_ira_deduction - qualified_adoption_assistance_expense - us_bonds_for_higher_ed - specified_possession_income diff --git a/policyengine_us/parameters/gov/irs/ald/ira/magi_excluded_deductions.yaml b/policyengine_us/parameters/gov/irs/ald/ira/magi_excluded_deductions.yaml new file mode 100644 index 00000000000..f1b7ca76cf6 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/magi_excluded_deductions.yaml @@ -0,0 +1,23 @@ +description: The IRS disregards these deductions and exclusions when calculating modified adjusted gross income for the traditional IRA deduction phase-out. +values: + 2015-01-01: + - traditional_ira_contributions + - traditional_ira_deduction + - student_loan_interest_ald + - us_bonds_for_higher_ed + - qualified_adoption_assistance_expense + - capped_qualified_tuition_expenses_ald + - domestic_production_ald +metadata: + unit: list + period: year + label: IRA deduction MAGI disregarded deductions + reference: + - title: 26 U.S. Code § 219(g)(3)(A) + href: https://www.law.cornell.edu/uscode/text/26/219#g_3_A + - title: IRS Publication 590-A (2017), Worksheet 1-1 and Appendix B + href: https://www.irs.gov/pub/irs-prior/p590a--2017.pdf + - title: IRS Publication 590-A (2020), Worksheet 1-1 and Appendix B + href: https://www.irs.gov/pub/irs-prior/p590a--2020.pdf + - title: IRS Publication 590-A, Worksheet 1-1 and Appendix B + href: https://www.irs.gov/publications/p590a diff --git a/policyengine_us/parameters/gov/irs/ald/ira/phase_out/minimum.yaml b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/minimum.yaml new file mode 100644 index 00000000000..a5957494eff --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/minimum.yaml @@ -0,0 +1,10 @@ +description: The IRS does not reduce the IRA deductible dollar limit below this amount before the active-participant phase-out is complete. +values: + 2015-01-01: 200 +metadata: + unit: currency-USD + period: year + label: IRA deduction phase-out minimum deductible limit + reference: + - title: 26 U.S. Code § 219(g)(2)(B) + href: https://www.law.cornell.edu/uscode/text/26/219#g_2_B diff --git a/policyengine_us/parameters/gov/irs/ald/ira/phase_out/rounding_interval.yaml b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/rounding_interval.yaml new file mode 100644 index 00000000000..6ef7b4b2b17 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/rounding_interval.yaml @@ -0,0 +1,10 @@ +description: The IRS rounds down the active-participant IRA deduction phase-out reduction to the next multiple of this amount. +values: + 2015-01-01: 10 +metadata: + unit: currency-USD + period: year + label: IRA deduction phase-out reduction rounding interval + reference: + - title: 26 U.S. Code § 219(g)(2)(C) + href: https://www.law.cornell.edu/uscode/text/26/219#g_2_C diff --git a/policyengine_us/parameters/gov/irs/ald/ira/phase_out/spouse_start.yaml b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/spouse_start.yaml new file mode 100644 index 00000000000..c0d0ec6b99a --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/spouse_start.yaml @@ -0,0 +1,50 @@ +description: The IRS begins phasing out the traditional IRA deduction at this modified adjusted gross income for a joint filer who is not an active participant in an employer retirement plan and whose spouse is an active participant. +values: + 2015-01-01: 183_000 + 2016-01-01: 184_000 + 2017-01-01: 186_000 + 2018-01-01: 189_000 + 2019-01-01: 193_000 + 2020-01-01: 196_000 + 2021-01-01: 198_000 + 2022-01-01: 204_000 + 2023-01-01: 218_000 + 2024-01-01: 230_000 + 2025-01-01: 236_000 + 2026-01-01: 242_000 +metadata: + unit: currency-USD + period: year + label: IRA deduction spouse active participant phase-out start + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 1_000 + reference: + - title: 26 U.S. Code § 219(g)(7)(A), (8) + href: https://www.law.cornell.edu/uscode/text/26/219#g_7_A + - title: IRS Notice 2014-70 - 2015 retirement plan limits + href: https://www.irs.gov/irb/2014-48_IRB + - title: IRS Notice 2015-75 - 2016 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-15-75.pdf#page=3 + - title: IRS Notice 2016-62 - 2017 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-16-62.pdf#page=3 + - title: IRS Notice 2017-64 - 2018 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-17-64.pdf#page=3 + - title: IRS Notice 2018-83 - 2019 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-18-83.pdf#page=3 + - title: IRS Notice 2019-59 - 2020 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-19-59.pdf#page=3 + - title: IRS Notice 2020-79 - 2021 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-20-79.pdf#page=3 + - title: IRS Notice 2021-61 - 2022 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-21-61.pdf#page=3 + - title: IRS Notice 2022-55 - 2023 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-22-55.pdf#page=3 + - title: IRS Notice 2023-75 - 2024 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-23-75.pdf#page=4 + - title: IRS Notice 2024-80 - 2025 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-24-80.pdf#page=4 + - title: IRS Notice 2025-67 - 2026 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-25-67.pdf#page=4 diff --git a/policyengine_us/parameters/gov/irs/ald/ira/phase_out/spouse_width.yaml b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/spouse_width.yaml new file mode 100644 index 00000000000..b22f21dcbeb --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/spouse_width.yaml @@ -0,0 +1,10 @@ +description: The IRS phases out the traditional IRA deduction over this modified adjusted gross income range for a joint filer who is not an active participant in an employer retirement plan and whose spouse is an active participant. +values: + 2015-01-01: 10_000 +metadata: + unit: currency-USD + period: year + label: IRA deduction spouse active participant phase-out width + reference: + - title: 26 U.S. Code § 219(g)(7)(B) + href: https://www.law.cornell.edu/uscode/text/26/219#g_7_B diff --git a/policyengine_us/parameters/gov/irs/ald/ira/phase_out/start.yaml b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/start.yaml new file mode 100644 index 00000000000..0ed67c40fb0 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/start.yaml @@ -0,0 +1,117 @@ +description: The IRS begins phasing out the traditional IRA deduction at this modified adjusted gross income for contributors who actively participate in an employer retirement plan, based on filing status. +metadata: + propagate_metadata_to_children: true + unit: currency-USD + period: year + label: IRA deduction active participant phase-out start + breakdown: + - filing_status + reference: + - title: 26 U.S. Code § 219(g)(3)(B), (8) + href: https://www.law.cornell.edu/uscode/text/26/219#g_3_B + - title: IRS Notice 2014-70 - 2015 retirement plan limits + href: https://www.irs.gov/irb/2014-48_IRB + - title: IRS Notice 2015-75 - 2016 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-15-75.pdf#page=3 + - title: IRS Notice 2016-62 - 2017 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-16-62.pdf#page=3 + - title: IRS Notice 2017-64 - 2018 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-17-64.pdf#page=3 + - title: IRS Notice 2018-83 - 2019 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-18-83.pdf#page=3 + - title: IRS Notice 2019-59 - 2020 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-19-59.pdf#page=3 + - title: IRS Notice 2020-79 - 2021 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-20-79.pdf#page=3 + - title: IRS Notice 2021-61 - 2022 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-21-61.pdf#page=3 + - title: IRS Notice 2022-55 - 2023 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-22-55.pdf#page=3 + - title: IRS Notice 2023-75 - 2024 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-23-75.pdf#page=4 + - title: IRS Notice 2024-80 - 2025 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-24-80.pdf#page=4 + - title: IRS Notice 2025-67 - 2026 retirement plan limits + href: https://www.irs.gov/pub/irs-drop/n-25-67.pdf#page=4 +SINGLE: + values: + 2015-01-01: 61_000 + 2016-01-01: 61_000 + 2017-01-01: 62_000 + 2018-01-01: 63_000 + 2019-01-01: 64_000 + 2020-01-01: 65_000 + 2021-01-01: 66_000 + 2022-01-01: 68_000 + 2023-01-01: 73_000 + 2024-01-01: 77_000 + 2025-01-01: 79_000 + 2026-01-01: 81_000 + metadata: + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 1_000 +JOINT: + values: + 2015-01-01: 98_000 + 2016-01-01: 98_000 + 2017-01-01: 99_000 + 2018-01-01: 101_000 + 2019-01-01: 103_000 + 2020-01-01: 104_000 + 2021-01-01: 105_000 + 2022-01-01: 109_000 + 2023-01-01: 116_000 + 2024-01-01: 123_000 + 2025-01-01: 126_000 + 2026-01-01: 129_000 + metadata: + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 1_000 +SEPARATE: + 2015-01-01: 0 +HEAD_OF_HOUSEHOLD: + values: + 2015-01-01: 61_000 + 2016-01-01: 61_000 + 2017-01-01: 62_000 + 2018-01-01: 63_000 + 2019-01-01: 64_000 + 2020-01-01: 65_000 + 2021-01-01: 66_000 + 2022-01-01: 68_000 + 2023-01-01: 73_000 + 2024-01-01: 77_000 + 2025-01-01: 79_000 + 2026-01-01: 81_000 + metadata: + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 1_000 +SURVIVING_SPOUSE: + values: + 2015-01-01: 98_000 + 2016-01-01: 98_000 + 2017-01-01: 99_000 + 2018-01-01: 101_000 + 2019-01-01: 103_000 + 2020-01-01: 104_000 + 2021-01-01: 105_000 + 2022-01-01: 109_000 + 2023-01-01: 116_000 + 2024-01-01: 123_000 + 2025-01-01: 126_000 + 2026-01-01: 129_000 + metadata: + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 1_000 diff --git a/policyengine_us/parameters/gov/irs/ald/ira/phase_out/width.yaml b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/width.yaml new file mode 100644 index 00000000000..320fbb8d397 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/ira/phase_out/width.yaml @@ -0,0 +1,23 @@ +description: The IRS phases out the traditional IRA deduction over this modified adjusted gross income range for contributors who actively participate in an employer retirement plan, based on filing status. +SINGLE: + 2015-01-01: 10_000 +JOINT: + 2015-01-01: 20_000 +SEPARATE: + 2015-01-01: 10_000 +HEAD_OF_HOUSEHOLD: + 2015-01-01: 10_000 +SURVIVING_SPOUSE: + 2015-01-01: 20_000 +metadata: + propagate_metadata_to_children: true + unit: currency-USD + period: year + label: IRA deduction active participant phase-out width + breakdown: + - filing_status + reference: + - title: 26 U.S. Code § 219(g)(2)(A)(ii) + href: https://www.law.cornell.edu/uscode/text/26/219#g_2_A_ii + - title: IRS Publication 590-A, Worksheet 1-2 + href: https://www.irs.gov/publications/p590a diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/casualty/per_casualty_reduction.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/casualty/per_casualty_reduction.yaml new file mode 100644 index 00000000000..b56d5b69462 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/casualty/per_casualty_reduction.yaml @@ -0,0 +1,20 @@ +description: The IRS reduces each casualty or theft loss of personal-use property by this amount before applying the income floor. +values: + 2008-01-01: 100 + 2009-01-01: 500 + 2010-01-01: 100 +metadata: + unit: currency-USD + period: year + label: Casualty loss reduction per casualty + reference: + - title: 26 U.S.C. § 165(h)(1) + href: https://www.law.cornell.edu/uscode/text/26/165#h_1 + - title: 2008 Form 4684, line 11 + href: https://www.irs.gov/pub/irs-prior/f4684--2008.pdf#page=1 + - title: 2009 Form 4684, line 11 + href: https://www.irs.gov/pub/irs-prior/f4684--2009.pdf#page=1 + - title: 2022 Form 4684, line 11 + href: https://www.irs.gov/pub/irs-prior/f4684--2022.pdf#page=1 + - title: IRS Publication 547 (2022), $100 Rule + href: https://www.irs.gov/pub/irs-prior/p547--2022.pdf#page=10 diff --git a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/catch_up/limit/ira.yaml b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/catch_up/limit/ira.yaml index 593a7c54a59..e186cf1c9eb 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/catch_up/limit/ira.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/catch_up/limit/ira.yaml @@ -3,6 +3,7 @@ values: 2018-01-01: 1_000 2024-01-01: 1_000 2025-01-01: 1_000 + 2026-01-01: 1_100 metadata: unit: currency-USD period: year @@ -17,3 +18,5 @@ metadata: href: https://www.law.cornell.edu/uscode/text/26/219 - title: Cost-of-Living Adjustments for Retirement Items href: https://www.irs.gov/pub/irs-tege/cola-table.pdf#page=2 + - title: IRS Notice 2025-67 - 2026 IRA limits + href: https://www.irs.gov/pub/irs-drop/n-25-67.pdf#page=4 diff --git a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/ira.yaml b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/ira.yaml index b9117984543..28563cd6df3 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/ira.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/ira.yaml @@ -4,6 +4,7 @@ values: 2019-01-01: 6_000 2023-01-01: 6_500 2024-01-01: 7_000 + 2026-01-01: 7_500 metadata: unit: currency-USD period: year @@ -19,3 +20,5 @@ metadata: # Inflation adjusted amounts - title: Cost-of-Living Adjustments for Retirement Items href: https://www.irs.gov/pub/irs-tege/cola-table.pdf#page=2 + - title: IRS Notice 2025-67 - 2026 IRA limits + href: https://www.irs.gov/pub/irs-drop/n-25-67.pdf#page=4 diff --git a/policyengine_us/parameters/gov/states/al/tax/income/agi/gross_income_sources.yaml b/policyengine_us/parameters/gov/states/al/tax/income/agi/gross_income_sources.yaml index c21a24968de..4ea1ffc3f3e 100644 --- a/policyengine_us/parameters/gov/states/al/tax/income/agi/gross_income_sources.yaml +++ b/policyengine_us/parameters/gov/states/al/tax/income/agi/gross_income_sources.yaml @@ -19,6 +19,9 @@ values: - partnership_s_corp_income - farm_operations_income - farm_rent_income + # Form 40, Part I, line 5: rents, royalties, partnerships, S corporations, + # trusts and estates (Alabama Schedule E, Part II). + - estate_income metadata: unit: list @@ -35,7 +38,12 @@ metadata: href: https://www.taxformfinder.org/forms/2023/2023-alabama-form-40.pdf#page=2 - title: 2024 Alabama Income Tax Instructions href: https://www.revenue.alabama.gov/wp-content/uploads/2025/01/24f40bk.pdf#page=7 - - title: 2025 Alabama Income Tax Instructions + # Examples of income to report (page 7), Part I line 4, other income + # (page 14), and Part I line 5, rents, royalties, partnerships, estates, + # trusts, etc. (page 15). + - title: "2025 Alabama Income Tax Instructions, Form 40 Booklet #page 7,14-15" href: https://www.revenue.alabama.gov/wp-content/uploads/2026/01/25f40bk.pdf#page=7 - - title: 2025 Alabama Income Tax Instructions Form 40 Booklet, Part 1, Line 4 (Other Income) - href: https://www.revenue.alabama.gov/wp-content/uploads/2026/01/25f40bk.pdf#page=14 + - title: Code of Alabama 1975 Section 40-18-25(b) (income and deductions of beneficiaries of estates and trusts) + href: https://alison.legislature.state.al.us/code-of-alabama?section=40-18-25 + - title: Ala. Admin. Code r. 810-3-25-.11 (Alabama taxable income of beneficiaries for taxable years beginning after December 31, 2004) + href: https://admincode.legislature.state.al.us/administrative-code/810-3-25 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/parameters/gov/states/ca/cpuc/income_sources.yaml b/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml index 066d595d891..3a8a29b4b35 100644 --- a/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml @@ -11,7 +11,13 @@ description: >- # employment-related, non-cash income. The same footnote appears on the # CPUC-filed tariff sheet referenced below. # -# Housing subsidies are the one exception. D.14-08-030, issued August 14, +# SNAP benefits are excluded in both dated lists: 7 U.S.C. 2017(b) prohibits +# treating their value as income or resources under any federal, state or +# local law, including California's CARE and FERA programs. +# The same exclusion appears in the 2010 statute, before the housing +# decision; this corrects all modeled years rather than adding a new date. +# +# Separately, D.14-08-030, issued August 14, # 2014, holds in Section 6.2 that the income definition remains unchanged # except that housing subsidies will not be considered income, and Ordering # Paragraph 40(3) adopts "Housing subsidies are not counted as income". The @@ -67,7 +73,6 @@ values: # Public assistance payments - tanf - ssi - - snap - wic # Social Security - social_security @@ -110,7 +115,6 @@ values: # Public assistance payments - tanf - ssi - - snap - wic # Social Security - social_security @@ -137,6 +141,10 @@ metadata: period: year label: CA CPUC CARE/FERA income sources reference: + - title: 7 U.S.C. 2017(b), exclusion of SNAP benefits from income and resources + href: https://www.govinfo.gov/content/pkg/USCODE-2024-title7/html/USCODE-2024-title7-chap51-sec2017.htm + - title: 7 U.S.C. 2017(b), 2010 edition + href: https://www.govinfo.gov/content/pkg/USCODE-2010-title7/pdf/USCODE-2010-title7-chap51-sec2017.pdf#page=1 - title: PG&E Form 01-9077, CARE/FERA Program Application (Rev. 6.26) href: https://www.pge.com/assets/pge/localized/en/docs/account/billing-and-assistance/care-fera-application.pdf - title: >- diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/amt/amti/sources.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/amt/amti/sources.yaml index 620c19bce9b..f3d9852fe67 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/amt/amti/sources.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/amt/amti/sources.yaml @@ -7,7 +7,7 @@ values: # (home-equity) mortgage interest not used to buy, build, or improve the home, # mirroring federal Form 6251. PolicyEngine's deductible_mortgage_interest is # entirely acquisition interest, so there is no line 4 add-back. - - misc_deduction # line 5 Miscellaneous itemized deductions + - ca_misc_deduction # line 5: Schedule CA Part II line 25 metadata: @@ -23,3 +23,5 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-p.pdf#page=1 - title: 2025 Schedule P (540) Alternative Minimum Tax and Credit Limitations - Residents href: https://www.ftb.ca.gov/forms/2025/2025-540-p.pdf#page=1 + - title: 2025 Schedule P (540) Instructions, Part I line 5 + href: https://www.ftb.ca.gov/forms/2025/2025-540-p-instructions.html diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/all.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/all.yaml new file mode 100644 index 00000000000..db071531e29 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/all.yaml @@ -0,0 +1,14 @@ +description: California limits total charitable contribution deductions to this fraction of federal adjusted gross income. +values: + 2013-01-01: 0.5 +metadata: + unit: /1 + period: year + label: California charitable contribution deduction ceiling + reference: + - title: California Revenue and Taxation Code Section 17250.1(b) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17250.1 + - title: 2024 Schedule CA (540) Instructions, Part II lines 11-12 + href: https://www.ftb.ca.gov/forms/2024/2024-540-ca-instructions.html + - title: 2025 Schedule CA (540) Instructions, Part II lines 11-12 + href: https://www.ftb.ca.gov/forms/2025/2025-540-ca-instructions.html diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/non_cash.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/non_cash.yaml new file mode 100644 index 00000000000..d86492b3ebe --- /dev/null +++ b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/non_cash.yaml @@ -0,0 +1,12 @@ +description: California limits noncash charitable contributions to fifty-percent-limit organizations to this fraction of federal adjusted gross income, using the existing basis-reduction election category. +values: + 2013-01-01: 0.5 +metadata: + unit: /1 + period: year + label: California noncash charitable contribution deduction ceiling + reference: + - title: California Revenue and Taxation Code Section 17201(a) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17201 + - title: 26 U.S. Code Section 170(b)(1)(A), (C)(iii) + href: https://www.law.cornell.edu/uscode/text/26/170 diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml new file mode 100644 index 00000000000..9c244242504 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml @@ -0,0 +1,12 @@ +description: California limits noncash charitable contributions to organizations other than fifty-percent-limit organizations to this fraction of federal adjusted gross income. The stricter capital-gain-property category is not modeled separately. +values: + 2013-01-01: 0.3 +metadata: + unit: /1 + period: year + label: California noncash charitable deduction ceiling for other organizations + reference: + - title: California Revenue and Taxation Code Section 17201(a) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17201 + - title: 26 U.S. Code Section 170(b)(1)(B), (D) + href: https://www.law.cornell.edu/uscode/text/26/170 diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/limit/excluded_deductions.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/limit/excluded_deductions.yaml index d3d5fd934b8..f0fc3bc9320 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/limit/excluded_deductions.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/limit/excluded_deductions.yaml @@ -2,7 +2,9 @@ description: Itemized deductions excluded from CA high-AGI limitation. values: 2021-01-01: - medical_expense_deduction - - casualty_loss_deduction + # California's own casualty and theft loss (Schedule CA line 15), which + # replaces the federal one in the pre-limitation total. + - ca_casualty_loss_deduction - non_mortgage_interest metadata: @@ -18,5 +20,12 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-ca-instructions.html - title: 2025 Form 540 Schedule CA instructions href: https://www.ftb.ca.gov/forms/2025/2025-540-ca-instructions.html + - title: 2024 Schedule CA (540) instructions, line 29 itemized deductions worksheet, line 2 + href: https://www.ftb.ca.gov/forms/2024/2024-540-booklet.pdf#page=68 - title: Cal. Rev. & Tax. Code § 17077 href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17077 + # R&TC 17077 adopts 26 U.S.C. 68, whose subsection (c)(3) (before P.L. 119-21) + # leaves casualty and theft losses described in 165(c)(2) or (3) out of the + # deductions the limitation reduces. + - title: 26 U.S.C. § 68 (amendment notes for subsection (c)(3)) + href: https://www.law.cornell.edu/uscode/text/26/68 diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/misc/floor.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/misc/floor.yaml new file mode 100644 index 00000000000..8ed7b49a5df --- /dev/null +++ b/policyengine_us/parameters/gov/states/ca/tax/income/deductions/itemized/misc/floor.yaml @@ -0,0 +1,12 @@ +description: California deducts miscellaneous itemized expenses exceeding this fraction of nonnegative federal adjusted gross income. +values: + 2013-01-01: 0.02 +metadata: + unit: /1 + period: year + label: California miscellaneous itemized deduction floor + reference: + - title: California Revenue and Taxation Code Section 17076(a), (c) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17076 + - title: 2025 Schedule CA (540), Part II lines 23-25 + href: https://www.ftb.ca.gov/forms/2025/2025-540-ca.pdf#page=6 diff --git a/policyengine_us/parameters/gov/states/ia/tax/income/gross_income/sources.yaml b/policyengine_us/parameters/gov/states/ia/tax/income/gross_income/sources.yaml index 1abc47597d6..d63e38ad3d7 100644 --- a/policyengine_us/parameters/gov/states/ia/tax/income/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ia/tax/income/gross_income/sources.yaml @@ -13,6 +13,8 @@ values: - taxable_roth_conversions - partnership_s_corp_income - rental_income + # Line 10: rents, royalties, partnerships, estates, etc. (federal Schedule E) + - estate_income - farm_operations_income - taxable_unemployment_compensation - miscellaneous_income @@ -23,9 +25,10 @@ metadata: reference: - title: 2021 IA1040 Iowa Income Tax Return, Lines 1-14 href: https://revenue.iowa.gov/sites/default/files/2022-01/IA1040%2841-001%29.pdf#page=1 - - title: 2021 IA1040 Income Tax Return instructions + # Line 10, rents, royalties, partnerships, estates, etc., is on page 19. + - title: "2021 IA1040 Income Tax Return instructions #page 12,19" href: https://revenue.iowa.gov/media/2650/download?inline#page=12 - title: 2022 IA1040 Iowa Income Tax Return, Lines 1-14 href: https://revenue.iowa.gov/sites/default/files/2023-01/2022IA1040%2841001%29.pdf#page=1 - - title: 2022 IA1040 Income Tax Return instructions + - title: "2022 IA1040 Income Tax Return instructions #page 11,19" href: https://revenue.iowa.gov/media/2721/download?inline#page=11 diff --git a/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml b/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml index ee01efbc66d..b2e90d71d34 100644 --- a/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml +++ b/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml @@ -11,6 +11,7 @@ values: - workers_compensation # (13) - id_retirement_benefits_deduction # (15) - id_529_plan_subtraction # Idaho Code 63-3022(n) + - id_health_insurance_premiums_subtraction # Idaho Code 63-3022P 2025-01-01: - salt_refund_income # (2) - us_govt_interest # (3)(a) @@ -23,6 +24,7 @@ values: - id_retirement_benefits_deduction # (15) - id_military_retirement_deduction # (15) per HB 40 - id_529_plan_subtraction # Idaho Code 63-3022(n) + - id_health_insurance_premiums_subtraction # Idaho Code 63-3022P metadata: @@ -34,7 +36,9 @@ metadata: href: https://law.justia.com/codes/idaho/2022/title-63/chapter-30/section-63-3022d/ - title: Individual Income Tax Forms and Instructions 2021 href: https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_11-15-2021.pdf#page=30 - - title: Idaho Individual Income Tax Instructions Packet 2025 + - title: "Idaho Individual Income Tax Instructions Packet 2025 #page 33,48-49" href: https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=33 + - title: Idaho Code Section 63-3022P - Health insurance costs + href: https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/ - title: Section 35.01.01.254 - (5) / (3) (a) href: https://regulations.justia.com/states/idaho/35/35-01-01/section-35-01-01-254/ diff --git a/policyengine_us/parameters/gov/states/il/dhs/aabd/income/exemption/sources.yaml b/policyengine_us/parameters/gov/states/il/dhs/aabd/income/exemption/sources.yaml index b3025c906c5..95183690a58 100644 --- a/policyengine_us/parameters/gov/states/il/dhs/aabd/income/exemption/sources.yaml +++ b/policyengine_us/parameters/gov/states/il/dhs/aabd/income/exemption/sources.yaml @@ -1,9 +1,14 @@ description: Illinois counts the following expense sources as exempted from earned income under the Aid to the Aged, Blind or Disabled program. values: 2021-01-01: - - state_withheld_income_tax + # Each source must be a Person-level amount for the person's own + # employment. A tax-unit variable here would give every member the + # whole unit's amount. il_withheld_income_tax is the person's own + # Illinois withholding estimate, computed on their own AGI. + - il_withheld_income_tax - employee_social_security_tax - il_aabd_child_care_expense_exemption + # Federal and city withholding taxes # transportation cost # lunch supplementation # Special tools and uniforms required by employment @@ -12,7 +17,7 @@ values: # Group health insurance premiums # Retirement plan withholdings # The reasonable cost of items and services which are needed and used to enable a disabled person to work - + metadata: unit: list period: year @@ -20,3 +25,9 @@ metadata: reference: - title: Ill. Admin. Code tit. 89, § 113.125 - Recognized Employment Expenses href: https://www.law.cornell.edu/regulations/illinois/Ill-Admin-Code-tit-89-SS-113.125 + # Health Benefits for Workers with Disabilities deducts these expenses + # through 89 Ill. Adm. Code 120.510(i). + - title: Ill. Admin. Code tit. 89, § 120.370(a) - Recognized Employment Expenses + href: https://www.law.cornell.edu/regulations/illinois/Ill-Admin-Code-tit-89-SS-120.370 + - title: Illinois Department of Human Services Policy Manual, PM 08-02-03-b - Employment Expenses + href: https://www.dhs.state.il.us/page.aspx?item=15385 diff --git a/policyengine_us/parameters/gov/states/ma/tax/income/ald/disallowed.yaml b/policyengine_us/parameters/gov/states/ma/tax/income/ald/disallowed.yaml index 464fb220b76..7016ba2a218 100644 --- a/policyengine_us/parameters/gov/states/ma/tax/income/ald/disallowed.yaml +++ b/policyengine_us/parameters/gov/states/ma/tax/income/ald/disallowed.yaml @@ -2,18 +2,18 @@ description: Above-the-line deductions disallowed in Massachusetts income tax. values: 2001-01-01: - loss_ald # (c) - - traditional_ira_contributions # (f) + - traditional_ira_deduction # (f) - early_withdrawal_penalty # (h) - self_employment_tax_ald 2018-01-01: - loss_ald # (c) - - traditional_ira_contributions # (f) + - traditional_ira_deduction # (f) - early_withdrawal_penalty # (h) - self_employment_tax_ald - alimony_income # is a US ALD only in 2018 through 2025 2026-01-01: - loss_ald # (c) - - traditional_ira_contributions # (f) + - traditional_ira_deduction # (f) - early_withdrawal_penalty # (h) - self_employment_tax_ald metadata: diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index d92aee43d73..df06e1c1e64 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -1,8 +1,12 @@ description: Michigan accounts for the following income sources as the household resources. values: 2021-01-01: + # Each source counts for the claimant and spouse only (MCL 206.508(3)), + # except those in household_resources_all_members (lines 21, 22 and 27). + # MI-1040CR line 14, wages, including strike pay. - irs_employment_income - strike_benefits + # MI-1040CR line 15, interest and dividends. - dividend_income - interest_income # MI-1040CR line 16, net business income: U.S. Schedule C, Form 4797 @@ -18,22 +22,35 @@ values: # as a total. - rental_income - farm_rent_income + # MI-1040CR line 18, pensions and annuities. - pension_income - retirement_benefits_from_ss_exempt_employment - - loss_limited_net_capital_gains + # MI-1040CR line 19, net capital gains and losses from the filer's U.S. + # Schedule D, a loss limited to $3,000 ($1,500 married filing separately). + - filer_loss_limited_net_capital_gains + # MI-1040CR line 20, alimony and other taxable income. - alimony_income - gambling_winnings + # MI-1040CR line 21, Social Security, SSI and railroad retirement + # benefits, including amounts received for dependents. - social_security - - ssi + - ssi - railroad_benefits + # MI-1040CR line 22, child support. - child_support_received + # MI-1040CR line 23, unemployment compensation. - unemployment_compensation - # Gifts received or expenses paid on your behalf + # MI-1040CR line 24, gifts received or expenses paid on your behalf. - miscellaneous_income + # MI-1040CR line 26, veterans' benefits and workers' compensation. - veterans_benefits - workers_compensation + # MI-1040CR line 14, long-term disability benefits. - disability_benefits + # Guaranteed income pilot payments, the recipient's own. - gi_cash_assistance + # MI-1040CR line 27, payments to the household by MDHHS and other public + # assistance. - tanf - general_assistance metadata: diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml index 70e6dfd9fe4..94637b89d28 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_adjustments.yaml @@ -15,7 +15,7 @@ values: - self_employed_health_insurance_ald # line 17 - early_withdrawal_penalty # line 18 - alimony_expense_ald # line 19a - - traditional_ira_contributions # line 20 + - traditional_ira_deduction # line 20, the deductible amount - student_loan_interest_ald # line 21 metadata: unit: list diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml new file mode 100644 index 00000000000..c08195a2bcf --- /dev/null +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml @@ -0,0 +1,30 @@ +description: Michigan counts these household resources sources for every tax unit member, not only the claimant and spouse, as amounts the claimant receives for others in the household. +values: + 2021-01-01: + # MI-1040CR line 21: Social Security, SSI and railroad retirement + # benefits, including "amounts received for minor children or other + # dependent adults who live with you". + - social_security + - ssi + - railroad_benefits + # MI-1040CR line 22: "child support and all payments received as a foster + # parent". + - child_support_received + # MI-1040CR line 27: "the total payments made to your household by MDHHS + # and all other public assistance payments". A Family Independence + # Program grant covers the children in the case. + - tanf + - general_assistance +metadata: + unit: list + period: year + label: Michigan household resources sources counted for every member + reference: + - title: MCL 206.508(3) and (4), household and total household resources + href: https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508 + - title: Michigan 2025 Individual Income Tax Instructions, MI-1040CR lines 21, 22 and 27 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 + - title: Michigan 2024 Home Heating Credit Instructions, MI-1040CR-7 lines 26, 27 and 32 + href: https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf#page=8 + - title: Michigan 2025 Home Heating Credit Instructions, MI-1040CR-7 lines 24, 25 and 30 + href: https://web.archive.org/web/20260218211009/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040CR-7-Book.pdf?rev=e4ba4fedc63942e48442037517312d4a&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=9 diff --git a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml index f07f5cabd30..c98bcbadf19 100644 --- a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml +++ b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml @@ -1,7 +1,7 @@ description: Mississippi subtracts these adjustments from federal adjusted gross income when computing state adjusted gross income. values: 2020-01-01: - - traditional_ira_contributions # Line 50 + - traditional_ira_deduction # Line 50 - ms_self_employment_adjustment # Line 61 - ms_national_guard_or_reserve_pay_adjustment # Line 55 - alimony_expense # Line 53 diff --git a/policyengine_us/parameters/gov/states/ms/tax/income/income_sources.yaml b/policyengine_us/parameters/gov/states/ms/tax/income/income_sources.yaml index 4fda82326a1..c89f46af683 100644 --- a/policyengine_us/parameters/gov/states/ms/tax/income/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/ms/tax/income/income_sources.yaml @@ -7,6 +7,9 @@ values: - partnership_s_corp_income - loss_limited_net_capital_gains_person - rental_income + # Line 41: rental real estates, royalties, partnerships, S corporations, + # trusts, etc. (income or loss from federal Schedule E) + - estate_income - farm_operations_income - interest_income - qualified_dividend_income @@ -31,5 +34,5 @@ metadata: href: https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100231.pdf#page=11 # Line 38 - 48 - title: Mississippi Income Tax Instructions 2024 href: https://www.dor.ms.gov/sites/default/files/Forms/Individual/80100241.pdf#page=10 - - title: Mississippi Income Tax Instructions 2025 + - title: "Mississippi Income Tax Instructions 2025 #page 10-11" href: https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=10 diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml index c949e5c54fe..7c3c2184cdf 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml @@ -4,12 +4,28 @@ metadata: period: year label: Montana elderly homeowner or renter credit gross income sources reference: + - title: HB191 (2021), section 1(9), all nontaxable income + href: https://archive.legmt.gov/bills/2021/HB0199/HB0191_X.pdf#page=2 - title: Montana 2022 Form2 Individual Income Tax Instructions, Page47, Gross Household Income href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=47 - title: 2022 Montana Code Annotated, Title 15, Chapter 30, Part 23, 15-30-2337 (9) href: https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-23/section-15-30-2337/ - title: 2024 Montana Individual Income Tax Return href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=46 + - title: ARM 42.4.301(2) as in effect 8/20/2004 to 11/9/2017 ("elderly homeowner/renter credit refunds received") + href: https://rules.mt.gov/api/policy-library-public/collections/aec52c46-128e-4279-9068-8af5d5432d74/policies/c90ea93a-03a2-4605-a0f8-f2f3760198bf/document/788508cc-8418-41c8-926d-3dfb11af3fd6 + - title: ARM 42.4.301(2) as in effect 11/10/2017 to 9/6/2024 ("federal refundable credits received" and "any state refundable credits received") + href: https://rules.mt.gov/api/policy-library-public/collections/aec52c46-128e-4279-9068-8af5d5432d74/policies/c90ea93a-03a2-4605-a0f8-f2f3760198bf/document/297c7a8d-f6f4-4b70-a7b7-fe1cedee53e7 + - title: ARM 42.4.301(2) as in effect from 9/7/2024 ("federal refundable tax credits received" and "any state refundable tax credits received") + href: https://rules.mt.gov/api/policy-library-public/collections/aec52c46-128e-4279-9068-8af5d5432d74/policies/c90ea93a-03a2-4605-a0f8-f2f3760198bf/document/768c8344-24ee-4950-9d5e-9d8fc97ec0f7 + - title: 2017 Montana Form 2EC instructions, Gross Household Income Worksheet, item 4 ("Any federal or state refundable credits including the 2EC credit") + href: https://taxsim.nber.org/historical_state_tax_forms/MT/2017/Form%202%202017%20Booklet.pdf#page=65 + - title: 2023 Montana Form 2 instructions, Elderly Homeowner/Renter Credit Schedule, line 7, refundable credits received + href: https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=51 + - title: 2024 Montana Form 2 instructions, Schedule 2EC, line 8, refundable credits received + href: https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=47 + - title: 2025 Montana Form 2 instructions, Schedule 2EC, line 8, refundable credits received + href: https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2025_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=45 values: 2009-01-01: @@ -27,8 +43,45 @@ values: - child_support_received # § 15-30-2337(9)(a)(iv) – support money - strike_benefits # § 15-30-2337(9)(a)(v) – nontaxable strike benefits - general_assistance # § 15-30-2337(9)(a)(vi) – cash public assistance - - mt_refundable_credits_before_renter_credit # Line 7: Refundable credits + # Refundable credits received (2023 schedule line 7, Schedule 2EC line 8 + # from 2024): Montana's refundable credits other than this credit, + # and this credit as received during the year (ARM 42.4.301(2)(c)). + - mt_refundable_credits_before_renter_credit + - mt_elderly_homeowner_or_renter_credit_received + # ARM 42.4.301(2)(b), amended effective 11/10/2017, counts "federal + # refundable credits received"; the 2017 Form 2EC worksheet counts "any + # federal or state refundable credits". + 2017-01-01: + - adjusted_gross_income_person + - tax_exempt_interest_income + - tax_exempt_ira_distributions + - tax_exempt_pension_income + - veterans_benefits + - child_support_received + - strike_benefits + - general_assistance + - mt_refundable_credits_before_renter_credit + - mt_elderly_homeowner_or_renter_credit_received + - mt_elderly_homeowner_or_renter_credit_federal_refundable_credits + # Actual rebate receipts count under the statutory nontaxable-income + # definition and ARM 42.4.301(2)(c). The 2023 form's illustrative list + # establishes no exemption for 2022 rebates received in 2023. The + # 2024/2025 schedules explicitly name the prior property-year rebates. + 2023-01-01: + - adjusted_gross_income_person + - tax_exempt_interest_income + - tax_exempt_ira_distributions + - tax_exempt_pension_income + - veterans_benefits + - child_support_received + - strike_benefits + - general_assistance + - mt_refundable_credits_before_renter_credit + - mt_elderly_homeowner_or_renter_credit_received + - mt_elderly_homeowner_or_renter_credit_federal_refundable_credits + - mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received # Social Security under § 15-30-2337(9)(a)(viii) is added separately in # mt_elderly_homeowner_or_renter_credit_gross_household_income.py because the # untaxed portion is computed as social_security - taxable_social_security. -# The renter credit itself is excluded to avoid a circular reference. +# This year's elderly homeowner/renter credit is excluded to avoid a circular +# reference; the credit received during the year counts instead. diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/credits/rebate/property/amount.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/credits/rebate/property/amount.yaml index 4ec487b6cb1..f7c074a2c6d 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/credits/rebate/property/amount.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/credits/rebate/property/amount.yaml @@ -1,7 +1,13 @@ description: Montana provides the following property tax rebate amount. values: + # Tax years 2022 and 2023: $500 (HB 222, Ch. 47, L. 2023) plus the $175 + # bonus (HB 816, Ch. 764, L. 2023). 2022-01-01: 675 - # The property tax rebate was only offered in the 2022 and 2023 tax years + # Tax year 2024: $400 (SB 542, Ch. 767, L. 2025, sec. 3). + 2024-01-01: 400 + # No rebate has been enacted for tax year 2025 or later; the tax year 2024 + # rebate terminates June 30, 2026 (Ch. 767, L. 2025, sec. 29(2)). + 2025-01-01: 0 metadata: period: year unit: currency-USD @@ -16,3 +22,9 @@ metadata: href: https://docs.legmt.gov/download-ticket?ticketId=ff6faf83-515f-4472-a079-c41a4a75e827#page=1 - title: 2024 Montana Form 2 Instructions href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=3 + - title: Montana Department of Revenue, Income tax and property tax rebate report, May 2024 (maximum rebate $675 in each of tax years 2022 and 2023) + href: https://archive.legmt.gov/content/Committees/Interim/2023-2024/Revenue/Meetings/May-2024/5.1-DOR-rebate-report.pdf#page=13 + - title: 2025 Montana Session Laws, Chapter 767 (SB 542), sec. 3(1), property tax rebate for tax year 2024 + href: https://archive.legmt.gov/content/Sessions/69th/Contractor_index/CH0767.pdf#page=2 + - title: 2025 Montana Session Laws, Chapter 767 (SB 542), sec. 29(2), termination of the tax year 2024 rebate + href: https://archive.legmt.gov/content/Sessions/69th/Contractor_index/CH0767.pdf#page=44 diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml index 1eeb0351b1a..c4744269768 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml @@ -1,8 +1,11 @@ description: Montana counts these sources as miscellaneous deductions. +# The child and dependent care expense deduction is also a miscellaneous +# deduction (Form 2 Itemized Deductions Schedule, line 14). It is added +# separately because its income limit uses the return's own Montana AGI, which +# differs between a joint return and separate columns (Form 2441-M line 3). values: 2021-01-01: - - mt_child_dependent_care_expense_deduction - - casualty_loss_deduction + - casualty_loss_deduction 2024-01-01: [] metadata: diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml new file mode 100644 index 00000000000..8b6d63360ab --- /dev/null +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml @@ -0,0 +1,16 @@ +description: Montana computes itemized deductions on its own Itemized Deductions Schedule, in lieu of adjusted federal itemized deductions, if this is true. +metadata: + label: Montana state specific itemized deductions apply + period: year + unit: bool + reference: + - title: Montana Individual Income Tax Return (Form 2) 2023 | Itemized Deductions Schedule + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 + - title: 2024 Montana Form 2 Instructions | Discontinued Worksheets and Schedules + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=8 + - title: 2024 Montana Form 2 Instructions | Line 2 and Worksheet A, Adjusted Federal Itemized Deductions + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15 + +values: + 2021-01-01: true + 2024-01-01: false diff --git a/policyengine_us/parameters/gov/states/nj/tax/income/gross_income/loss_eligible_categories/category_h.yaml b/policyengine_us/parameters/gov/states/nj/tax/income/gross_income/loss_eligible_categories/category_h.yaml new file mode 100644 index 00000000000..a836e9b50c3 --- /dev/null +++ b/policyengine_us/parameters/gov/states/nj/tax/income/gross_income/loss_eligible_categories/category_h.yaml @@ -0,0 +1,15 @@ +description: New Jersey classifies net gains or income derived through estates or trusts as Category (h) and disregards the net if negative under the same-category rule. +values: + 2021-01-01: + - estate_income # Line 26: Income from estates and trusts (Schedule NJK-1) +metadata: + unit: list + period: year + label: NJ Category (h) - Estates and trusts + reference: + - title: N.J.S. 54A:5-1(h) + href: https://law.justia.com/codes/new-jersey/title-54a/section-54a-5-1/ + # Net losses by category of income (page 9) and line 26, income from + # estates and trusts (page 21). + - title: "2025 NJ-1040 Instructions #page 9,21" + href: https://www.nj.gov/treasury/taxation/pdf/current/1040i.pdf#page=9 diff --git a/policyengine_us/parameters/gov/states/nj/tax/payroll/unemployment/employee_rate.yaml b/policyengine_us/parameters/gov/states/nj/tax/payroll/unemployment/employee_rate.yaml new file mode 100644 index 00000000000..03efc08e67b --- /dev/null +++ b/policyengine_us/parameters/gov/states/nj/tax/payroll/unemployment/employee_rate.yaml @@ -0,0 +1,12 @@ +description: New Jersey requires workers to contribute this share of covered wages to unemployment insurance, including contributions held in governmental employers' benefit trust funds. +values: + 2015-01-01: 0.003825 +metadata: + period: year + unit: /1 + label: New Jersey employee unemployment insurance contribution rate + reference: + - title: "N.J.S.A. 43:21-7(d)(1)(D)-(F) and 43:21-7.3(h) - worker contributions #page 60-61,81" + href: https://www.nj.gov/labor/myunemployment/assets/pdfs/UI_statute.pdf#page=60 + - title: New Jersey Department of Labor - annual worker contribution rates + href: https://www.nj.gov/labor/ea/employer-services/rate-info/ diff --git a/policyengine_us/parameters/gov/states/nj/tax/payroll/unemployment/employee_taxable_wage_base.yaml b/policyengine_us/parameters/gov/states/nj/tax/payroll/unemployment/employee_taxable_wage_base.yaml new file mode 100644 index 00000000000..ce91dcf35a3 --- /dev/null +++ b/policyengine_us/parameters/gov/states/nj/tax/payroll/unemployment/employee_taxable_wage_base.yaml @@ -0,0 +1,24 @@ +description: New Jersey limits employee unemployment insurance and workforce fund contributions to this amount of annual covered wages. +values: + 2015-01-01: 32_000 + 2016-01-01: 32_600 + 2017-01-01: 33_500 + 2018-01-01: 33_700 + 2019-01-01: 34_400 + 2020-01-01: 35_300 + 2021-01-01: 36_200 + 2022-01-01: 39_800 + 2023-01-01: 41_100 + 2024-01-01: 42_300 + 2025-01-01: 43_300 + 2026-01-01: 44_800 + 2027-01-01: 46_400 +metadata: + period: year + unit: currency-USD + label: New Jersey employee unemployment and workforce taxable wage base + reference: + - title: N.J.S.A. 43:21-7(b)(3) - annual unemployment insurance wage base + href: https://www.nj.gov/labor/myunemployment/assets/pdfs/UI_statute.pdf#page=47 + - title: New Jersey Department of Labor - annual taxable wage bases + href: https://www.nj.gov/labor/ea/employer-services/rate-info/ diff --git a/policyengine_us/parameters/gov/states/nj/tax/payroll/workforce_development/employee_rate.yaml b/policyengine_us/parameters/gov/states/nj/tax/payroll/workforce_development/employee_rate.yaml new file mode 100644 index 00000000000..b1c8df66b9e --- /dev/null +++ b/policyengine_us/parameters/gov/states/nj/tax/payroll/workforce_development/employee_rate.yaml @@ -0,0 +1,12 @@ +description: New Jersey requires workers to contribute this combined share of covered wages to the Workforce Development Partnership and Supplemental Workforce Funds. +values: + 2015-01-01: 0.000425 +metadata: + period: year + unit: /1 + label: New Jersey employee workforce fund contribution rate + reference: + - title: New Jersey Department of Labor - worker WF/SWF contribution rates + href: https://www.nj.gov/labor/ea/employer-services/rate-info/ + - title: P.L. 2001, c.152, section 2 (N.J.S.A. 34:15D-22) - supplemental workforce contribution + href: https://pub.njleg.gov/bills/2000/PL01/152_.PDF#page=2 diff --git a/policyengine_us/parameters/gov/states/nm/tax/income/modified_gross_income.yaml b/policyengine_us/parameters/gov/states/nm/tax/income/modified_gross_income.yaml index aeb990f7858..d5c5915bf83 100644 --- a/policyengine_us/parameters/gov/states/nm/tax/income/modified_gross_income.yaml +++ b/policyengine_us/parameters/gov/states/nm/tax/income/modified_gross_income.yaml @@ -17,11 +17,15 @@ values: - self_employment_income - sstb_self_employment_income - veterans_benefits + # NMSA 1978 § 7-2-2(L)(15): income from an interest in an estate or a trust + - estate_income # income such as gambling winnings, insurance settlements, scholarships, - # grants, trust income and inheritance, + # grants and inheritance, metadata: unit: list label: New Mexico modified gross income elements reference: - title: New Mexico Income Tax Act, Chapter 7 - Taxation, 7-2-2. Definitions, L href: https://nmonesource.com/nmos/nmsa/en/item/4340/index.do#!fragment/zoupio-_Toc140503656/BQCwhgziBcwMYgK4DsDWszIQewE4BUBTADwBdoAvbRABwEtsBaAfX2zgEYAWABgFYeAZgBsfYQEoANMmylCEAIqJCuAJ7QA5BskRCYXAiUr1WnXoMgAynlIAhdQCUAogBknANQCCAOQDCTyVIwACNoUnZxcSA + - title: 2025 PIT-RC instructions, line 11 (income from an estate or trust) + href: https://taxsim.nber.org/historical_state_tax_forms/NM/2025/NM_PIT_RC_INST.PDF#page=4 diff --git a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml index 1d5a253cf08..53eb9a81e95 100644 --- a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml +++ b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml @@ -1,4 +1,5 @@ description: New York provides the following Drive Clean point-of-sale rebate amount, based on the all-electric range of the vehicle. +# The lower amounts apply to vehicles purchased after June 30, 2021. brackets: # 0 - 19 miles - threshold: @@ -10,19 +11,19 @@ brackets: 2017-03-21: 20 amount: 2017-03-21: 1_100 - 2021-06-30: 500 + 2021-07-01: 500 # 40 - 119 miles - threshold: 2017-03-21: 40 amount: 2017-03-21: 1_700 - 2021-06-31: 1_000 + 2021-07-01: 1_000 # 120 - 199 miles - threshold: 2017-03-21: 119 amount: 2017-03-21: 2_000 - 2021-06-31: 1_000 + 2021-07-01: 1_000 # 200 miles and above - threshold: 2017-03-21: 200 diff --git a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml index 91080cdd90e..7d2c90c7b99 100644 --- a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml +++ b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml @@ -9,4 +9,5 @@ metadata: values: 2017-03-21: 60_000 - 2021-06-30: 42_000 + # Vehicles purchased after June 30, 2021. + 2021-07-01: 42_000 diff --git a/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml new file mode 100644 index 00000000000..d17693a2977 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml @@ -0,0 +1,31 @@ +description: New York allows an itemized deduction for medical and dental expenses exceeding this share of federal adjusted gross income. +values: + # Through 2017, Form IT-201-D line 1 took the medical deduction from federal + # Schedule A line 4, after the federal floor: 10% from 2013 (7.5% for filers + # 65 and over through 2016, not modeled), then 7.5% for 2017 under Public + # Law 115-97 § 11027. + 2013-01-01: 0.1 + 2017-01-01: 0.075 + # From 2018, NY Tax Law § 615(a) computes NY itemized deductions as federal + # deductions existed immediately prior to Public Law 115-97, so NY keeps the + # pre-TCJA 26 U.S.C. 213(a) 10% floor (Form IT-196 line 3). + 2018-01-01: 0.1 +metadata: + unit: /1 + period: year + label: New York itemized medical expense deduction floor + reference: + - title: NY Tax Law § 615(a) + href: https://www.nysenate.gov/legislation/laws/TAX/615 + - title: 26 U.S. Code § 213(a) (2016 edition, before Public Law 115-97) + href: https://www.govinfo.gov/content/pkg/USCODE-2016-title26/html/USCODE-2016-title26-subtitleA-chap1-subchapB-partVII-sec213.htm + - title: 2016 NY Form IT-201-D, line 1 (federal Schedule A, line 4) + href: https://www.tax.ny.gov/pdf/2016/inc/it201d_2016_fill_in.pdf#page=1 + - title: 2017 NY Form IT-201-D, line 1 (federal Schedule A, line 4) + href: https://www.tax.ny.gov/pdf/2017/inc/it201d_2017_fill_in.pdf#page=1 + - title: 2017 federal Schedule A, line 3 (multiply line 2 by 7.5%) + href: https://www.irs.gov/pub/irs-prior/f1040sa--2017.pdf#page=1 + - title: 2018 NY Form IT-196, line 3 (multiply line 2 by 10%) + href: https://www.tax.ny.gov/pdf/2018/inc/it196_2018_fill_in.pdf#page=1 + - title: 2025 NY Form IT-196, line 3 (multiply line 2 by 10%) + href: https://www.tax.ny.gov/pdf/2025/inc/it196_2025_fill_in.pdf#page=1 diff --git a/policyengine_us/parameters/gov/states/ok/tax/income/credits/gross_income_sources.yaml b/policyengine_us/parameters/gov/states/ok/tax/income/credits/gross_income_sources.yaml index 562c3eb6094..856f98bac24 100644 --- a/policyengine_us/parameters/gov/states/ok/tax/income/credits/gross_income_sources.yaml +++ b/policyengine_us/parameters/gov/states/ok/tax/income/credits/gross_income_sources.yaml @@ -11,6 +11,9 @@ values: - long_term_capital_gains - interest_income - rental_income + # Forms 538-S and 538-H, line 11: gross (positive) income from rentals, + # royalties, partnerships, estates and trusts. + - estate_income - dividend_income - pension_income - debt_relief @@ -37,5 +40,9 @@ metadata: href: https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/past-year/2023/511-Pkt.pdf - title: 2024 Form 511 instructions href: https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/past-year/2024/511-Pkt-2024.pdf - - title: 2025 Form 511 instructions - href: https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/current/511-Pkt.pdf + - title: 2025 Form 511 packet, Form 538-S, Part 3 (gross income lines 9 to 14) + href: https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/current/511-Pkt.pdf#page=51 + - title: 2025 Form 538-H, line 11 (gross positive income from rentals, royalties, partnerships, estates and trusts) + href: https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/current/538-H.pdf#page=1 + - title: Okla. Admin. Code 710:50-15-96(c) (gross household income is the gross income of every type) + href: https://www.law.cornell.edu/regulations/oklahoma/OAC-710-50-15-96 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/eligibility/smi_rate.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/eligibility/smi_rate.yaml new file mode 100644 index 00000000000..43c90e95d04 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/eligibility/smi_rate.yaml @@ -0,0 +1,14 @@ +description: Oregon limits countable income to this share of state median income under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: 0.6 +metadata: + unit: /1 + period: year + label: Oregon LIHEAP SMI eligibility rate + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=57 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=77 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=71 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/earned_income_min_age.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/earned_income_min_age.yaml new file mode 100644 index 00000000000..9711b8f0cf8 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/earned_income_min_age.yaml @@ -0,0 +1,14 @@ +description: Oregon excludes earnings of people below this age under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: 18 +metadata: + unit: year + period: year + label: Oregon LIHEAP minimum age for counted earnings + reference: + - title: "PY2025 Energy Assistance Intake Operations and Policy Manual #page 33,41" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=33 + - title: "PY2026 Energy Assistance Intake Operations and Policy Manual #page 41,53" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=41 + - title: "PY2027 Energy Assistance Intake Operations and Policy Manual #page 42,54" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=42 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/interest_threshold.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/interest_threshold.yaml new file mode 100644 index 00000000000..ab475dc975e --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/interest_threshold.yaml @@ -0,0 +1,14 @@ +description: Oregon disregards annual withdrawn interest at or below this amount under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: 200 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP interest threshold + reference: + - title: "PY2025 Energy Assistance Intake Operations and Policy Manual #page 34,40" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=34 + - title: "PY2026 Energy Assistance Intake Operations and Policy Manual #page 43,50" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=43 + - title: "PY2027 Energy Assistance Intake Operations and Policy Manual #page 43,51" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=43 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/sources/earned.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/sources/earned.yaml new file mode 100644 index 00000000000..3df712f8d49 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/sources/earned.yaml @@ -0,0 +1,21 @@ +description: Oregon counts these sources as earned income under the Low Income Home Energy Assistance Program. +# Existing net business inputs are used without a further self-employment +# expense deduction. +values: + 2024-10-01: + - employment_income + - self_employment_income + - sstb_self_employment_income + - partnership_s_corp_income + - farm_operations_income +metadata: + unit: list + period: year + label: Oregon LIHEAP earned income sources + reference: + - title: "PY2025 Energy Assistance Intake Operations and Policy Manual #page 29,32-33,36,41" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=29 + - title: "PY2026 Energy Assistance Intake Operations and Policy Manual #page 35,39,41,45,53" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=35 + - title: "PY2027 Energy Assistance Intake Operations and Policy Manual #page 36,40,42,46,54" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=36 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/sources/unearned.yaml new file mode 100644 index 00000000000..18698f13f7f --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/income/sources/unearned.yaml @@ -0,0 +1,36 @@ +description: Oregon counts these person-level unearned income sources under the Low Income Home Energy Assistance Program. +# Person-level sources, each floored at zero per member. TANF, a unit-level +# amount, is added in the formula. +values: + 2024-10-01: + - rental_income + - farm_rent_income + - social_security + - railroad_benefits + - ssi + - unemployment_compensation + - workers_compensation + - veterans_benefits + - pension_income + # Retirement pay counts as a pension. military_retirement_pay includes + # survivor payments, so report a survivor annuity in only one of the two. + - military_retirement_pay + - survivor_benefits + - disability_benefits + - alimony_income + - child_support_received + - ordinary_dividend_income + - strike_benefits + - general_assistance + - estate_income +metadata: + unit: list + period: year + label: Oregon LIHEAP unearned income sources + reference: + - title: "PY2025 Energy Assistance Intake Operations and Policy Manual #page 31-41" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=31 + - title: "PY2026 Energy Assistance Intake Operations and Policy Manual #page 37-52" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=37 + - title: "PY2027 Energy Assistance Intake Operations and Policy Manual #page 38-53" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=38 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/electricity.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/electricity.yaml new file mode 100644 index 00000000000..6e61f0605db --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/electricity.yaml @@ -0,0 +1,197 @@ +description: Oregon provides this annual regular heating payment for electricity by region, household size and income band under the Low Income Home Energy Assistance Program. +1: + 1: + 1: + 2024-10-01: 380 + 2025-10-09: 465 + 2026-10-01: 475 + 2: + 2024-10-01: 295 + 2025-10-09: 360 + 2026-10-01: 370 + 3: + 2024-10-01: 250 + 2025-10-09: 300 + 2026-10-01: 310 + 4: + 2024-10-01: 250 + 2: + 1: + 2024-10-01: 595 + 2026-10-01: 600 + 2: + 2024-10-01: 465 + 3: + 2024-10-01: 385 + 2026-10-01: 390 + 4: + 2024-10-01: 280 + 2025-10-09: 275 + 2026-10-01: 280 + 3: + 1: + 2024-10-01: 610 + 2025-10-09: 650 + 2026-10-01: 655 + 2: + 2024-10-01: 475 + 2025-10-09: 505 + 2026-10-01: 510 + 3: + 2024-10-01: 395 + 2025-10-09: 420 + 2026-10-01: 425 + 4: + 2024-10-01: 285 + 2025-10-09: 300 + 2026-10-01: 305 + 4: + 1: + 2024-10-01: 675 + 2025-10-09: 690 + 2026-10-01: 695 + 2: + 2024-10-01: 525 + 2025-10-09: 540 + 3: + 2024-10-01: 440 + 2025-10-09: 450 + 4: + 2024-10-01: 315 + 2025-10-09: 320 + 5: + 1: + 2024-10-01: 710 + 2025-10-09: 725 + 2: + 2024-10-01: 555 + 2025-10-09: 565 + 3: + 2024-10-01: 460 + 2025-10-09: 470 + 4: + 2024-10-01: 330 + 2025-10-09: 340 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 595 + 2025-10-09: 590 + 3: + 2024-10-01: 495 + 2025-10-09: 490 + 4: + 2024-10-01: 355 + 2025-10-09: 350 +2: + 1: + 1: + 2024-10-01: 415 + 2025-10-09: 515 + 2026-10-01: 510 + 2: + 2024-10-01: 320 + 2025-10-09: 400 + 3: + 2024-10-01: 270 + 2025-10-09: 335 + 2026-10-01: 330 + 4: + 2024-10-01: 250 + 2: + 1: + 2024-10-01: 650 + 2025-10-09: 645 + 2026-10-01: 635 + 2: + 2024-10-01: 505 + 2026-10-01: 495 + 3: + 2024-10-01: 420 + 2026-10-01: 415 + 4: + 2024-10-01: 305 + 2025-10-09: 300 + 2026-10-01: 295 + 3: + 1: + 2024-10-01: 665 + 2025-10-09: 700 + 2026-10-01: 690 + 2: + 2024-10-01: 515 + 2025-10-09: 545 + 2026-10-01: 535 + 3: + 2024-10-01: 430 + 2025-10-09: 455 + 2026-10-01: 445 + 4: + 2024-10-01: 310 + 2025-10-09: 325 + 2026-10-01: 320 + 4: + 1: + 2024-10-01: 740 + 2026-10-01: 730 + 2: + 2024-10-01: 575 + 2025-10-09: 580 + 2026-10-01: 570 + 3: + 2024-10-01: 480 + 2026-10-01: 475 + 4: + 2024-10-01: 345 + 2026-10-01: 340 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 605 + 2026-10-01: 595 + 3: + 2024-10-01: 505 + 2026-10-01: 495 + 4: + 2024-10-01: 360 + 2026-10-01: 355 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 650 + 2025-10-09: 630 + 2026-10-01: 615 + 3: + 2024-10-01: 540 + 2025-10-09: 525 + 2026-10-01: 515 + 4: + 2024-10-01: 390 + 2025-10-09: 375 + 2026-10-01: 370 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP electricity heating payment + breakdown: + - range(1, 3) + - range(1, 7) + - range(1, 5) + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80 + - title: OHCS 44-2025 temporary order correcting the PY2026 payment matrices, effective October 9, 2025 + href: https://secure.sos.state.or.us/oard/viewReceiptPDF.action?filingRsn=60695 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/fuel_oil.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/fuel_oil.yaml new file mode 100644 index 00000000000..f20dd8ea19d --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/fuel_oil.yaml @@ -0,0 +1,160 @@ +description: Oregon provides this annual regular heating payment for fuel oil by region, household size and income band under the Low Income Home Energy Assistance Program. +1: + 1: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 675 + 3: + 2024-10-01: 680 + 2025-10-09: 560 + 4: + 2024-10-01: 490 + 2025-10-09: 405 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 640 + 4: + 2024-10-01: 750 + 2025-10-09: 460 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 675 + 4: + 2024-10-01: 750 + 2025-10-09: 485 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 700 + 4: + 2024-10-01: 750 + 2025-10-09: 505 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 720 + 4: + 2024-10-01: 750 + 2025-10-09: 520 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 740 + 4: + 2024-10-01: 750 + 2025-10-09: 535 +2: + 1: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 675 + 3: + 2024-10-01: 745 + 2025-10-09: 560 + 4: + 2024-10-01: 535 + 2025-10-09: 405 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 640 + 4: + 2024-10-01: 750 + 2025-10-09: 460 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 675 + 4: + 2024-10-01: 750 + 2025-10-09: 485 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 700 + 4: + 2024-10-01: 750 + 2025-10-09: 505 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 720 + 4: + 2024-10-01: 750 + 2025-10-09: 520 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 740 + 4: + 2024-10-01: 750 + 2025-10-09: 535 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP fuel oil heating payment + breakdown: + - range(1, 3) + - range(1, 7) + - range(1, 5) + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80 + - title: OHCS 44-2025 temporary order correcting the PY2026 payment matrices, effective October 9, 2025 + href: https://secure.sos.state.or.us/oard/viewReceiptPDF.action?filingRsn=60695 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/natural_gas.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/natural_gas.yaml new file mode 100644 index 00000000000..630391256fe --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/natural_gas.yaml @@ -0,0 +1,202 @@ +description: Oregon provides this annual regular heating payment for natural gas by region, household size and income band under the Low Income Home Energy Assistance Program. +1: + 1: + 1: + 2024-10-01: 500 + 2025-10-09: 670 + 2026-10-01: 660 + 2: + 2024-10-01: 390 + 2025-10-09: 520 + 2026-10-01: 515 + 3: + 2024-10-01: 325 + 2025-10-09: 435 + 2026-10-01: 430 + 4: + 2024-10-01: 250 + 2025-10-09: 310 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 615 + 2025-10-09: 605 + 2026-10-01: 600 + 3: + 2024-10-01: 510 + 2025-10-09: 505 + 2026-10-01: 500 + 4: + 2024-10-01: 365 + 2025-10-09: 360 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 625 + 2025-10-09: 640 + 2026-10-01: 635 + 3: + 2024-10-01: 520 + 2025-10-09: 535 + 2026-10-01: 530 + 4: + 2024-10-01: 375 + 2025-10-09: 385 + 2026-10-01: 380 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 695 + 2025-10-09: 670 + 2026-10-01: 665 + 3: + 2024-10-01: 580 + 2025-10-09: 555 + 4: + 2024-10-01: 415 + 2025-10-09: 400 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 730 + 2025-10-09: 690 + 3: + 2024-10-01: 610 + 2025-10-09: 575 + 4: + 2024-10-01: 435 + 2025-10-09: 415 + 2026-10-01: 410 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 710 + 3: + 2024-10-01: 650 + 2025-10-09: 590 + 4: + 2024-10-01: 470 + 2025-10-09: 425 +2: + 1: + 1: + 2024-10-01: 545 + 2025-10-09: 560 + 2026-10-01: 565 + 2: + 2024-10-01: 425 + 2025-10-09: 435 + 2026-10-01: 440 + 3: + 2024-10-01: 355 + 2025-10-09: 365 + 4: + 2024-10-01: 255 + 2025-10-09: 260 + 2026-10-01: 265 + 2: + 1: + 2024-10-01: 750 + 2025-10-09: 670 + 2026-10-01: 675 + 2: + 2024-10-01: 670 + 2025-10-09: 520 + 2026-10-01: 525 + 3: + 2024-10-01: 555 + 2025-10-09: 435 + 2026-10-01: 440 + 4: + 2024-10-01: 400 + 2025-10-09: 310 + 2026-10-01: 315 + 3: + 1: + 2024-10-01: 750 + 2025-10-09: 715 + 2026-10-01: 725 + 2: + 2024-10-01: 680 + 2025-10-09: 555 + 2026-10-01: 565 + 3: + 2024-10-01: 570 + 2025-10-09: 465 + 2026-10-01: 470 + 4: + 2024-10-01: 410 + 2025-10-09: 335 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 585 + 2026-10-01: 590 + 3: + 2024-10-01: 630 + 2025-10-09: 485 + 2026-10-01: 490 + 4: + 2024-10-01: 455 + 2025-10-09: 350 + 2026-10-01: 355 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 605 + 2026-10-01: 615 + 3: + 2024-10-01: 665 + 2025-10-09: 505 + 2026-10-01: 510 + 4: + 2024-10-01: 475 + 2025-10-09: 365 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 630 + 2026-10-01: 635 + 3: + 2024-10-01: 710 + 2025-10-09: 525 + 2026-10-01: 530 + 4: + 2024-10-01: 510 + 2025-10-09: 375 + 2026-10-01: 380 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP natural gas heating payment + breakdown: + - range(1, 3) + - range(1, 7) + - range(1, 5) + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80 + - title: OHCS 44-2025 temporary order correcting the PY2026 payment matrices, effective October 9, 2025 + href: https://secure.sos.state.or.us/oard/viewReceiptPDF.action?filingRsn=60695 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/propane.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/propane.yaml new file mode 100644 index 00000000000..9c40b63f18d --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/propane.yaml @@ -0,0 +1,168 @@ +description: Oregon provides this annual regular heating payment for propane by region, household size and income band under the Low Income Home Energy Assistance Program. +1: + 1: + 1: + 2024-10-01: 750 + 2025-10-09: 715 + 2: + 2024-10-01: 715 + 2025-10-09: 560 + 3: + 2024-10-01: 595 + 2025-10-09: 465 + 4: + 2024-10-01: 425 + 2025-10-09: 335 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 655 + 3: + 2024-10-01: 750 + 2025-10-09: 545 + 4: + 2024-10-01: 670 + 2025-10-09: 390 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 695 + 3: + 2024-10-01: 750 + 2025-10-09: 580 + 4: + 2024-10-01: 685 + 2025-10-09: 415 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 725 + 3: + 2024-10-01: 750 + 2025-10-09: 605 + 4: + 2024-10-01: 750 + 2025-10-09: 435 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 625 + 4: + 2024-10-01: 750 + 2025-10-09: 450 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 645 + 4: + 2024-10-01: 750 + 2025-10-09: 460 +2: + 1: + 1: + 2024-10-01: 750 + 2025-10-09: 715 + 2: + 2024-10-01: 750 + 2025-10-09: 560 + 3: + 2024-10-01: 650 + 2025-10-09: 465 + 4: + 2024-10-01: 465 + 2025-10-09: 335 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 655 + 3: + 2024-10-01: 750 + 2025-10-09: 545 + 4: + 2024-10-01: 730 + 2025-10-09: 390 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 695 + 3: + 2024-10-01: 750 + 2025-10-09: 580 + 4: + 2024-10-01: 745 + 2025-10-09: 415 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 725 + 3: + 2024-10-01: 750 + 2025-10-09: 605 + 4: + 2024-10-01: 750 + 2025-10-09: 435 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 625 + 4: + 2024-10-01: 750 + 2025-10-09: 450 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 645 + 4: + 2024-10-01: 750 + 2025-10-09: 460 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP propane heating payment + breakdown: + - range(1, 3) + - range(1, 7) + - range(1, 5) + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80 + - title: OHCS 44-2025 temporary order correcting the PY2026 payment matrices, effective October 9, 2025 + href: https://secure.sos.state.or.us/oard/viewReceiptPDF.action?filingRsn=60695 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/wood.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/wood.yaml new file mode 100644 index 00000000000..59de663e73c --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/amount/wood.yaml @@ -0,0 +1,168 @@ +description: Oregon provides this annual regular heating payment for wood by region, household size and income band under the Low Income Home Energy Assistance Program. +1: + 1: + 1: + 2024-10-01: 750 + 2025-10-09: 715 + 2: + 2024-10-01: 685 + 2025-10-09: 560 + 3: + 2024-10-01: 570 + 2025-10-09: 465 + 4: + 2024-10-01: 410 + 2025-10-09: 350 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 655 + 3: + 2024-10-01: 750 + 2025-10-09: 545 + 4: + 2024-10-01: 645 + 2025-10-09: 390 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 695 + 3: + 2024-10-01: 750 + 2025-10-09: 580 + 4: + 2024-10-01: 660 + 2025-10-09: 415 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 725 + 3: + 2024-10-01: 750 + 2025-10-09: 605 + 4: + 2024-10-01: 730 + 2025-10-09: 435 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 625 + 4: + 2024-10-01: 750 + 2025-10-09: 450 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 645 + 4: + 2024-10-01: 750 + 2025-10-09: 460 +2: + 1: + 1: + 2024-10-01: 750 + 2025-10-09: 715 + 2: + 2024-10-01: 750 + 2025-10-09: 560 + 3: + 2024-10-01: 625 + 2025-10-09: 465 + 4: + 2024-10-01: 450 + 2025-10-09: 350 + 2: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 655 + 3: + 2024-10-01: 750 + 2025-10-09: 545 + 4: + 2024-10-01: 705 + 2025-10-09: 390 + 3: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 695 + 3: + 2024-10-01: 750 + 2025-10-09: 580 + 4: + 2024-10-01: 720 + 2025-10-09: 415 + 4: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 2025-10-09: 725 + 3: + 2024-10-01: 750 + 2025-10-09: 605 + 4: + 2024-10-01: 750 + 2025-10-09: 435 + 5: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 625 + 4: + 2024-10-01: 750 + 2025-10-09: 450 + 6: + 1: + 2024-10-01: 750 + 2: + 2024-10-01: 750 + 3: + 2024-10-01: 750 + 2025-10-09: 645 + 4: + 2024-10-01: 750 + 2025-10-09: 460 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP wood heating payment + breakdown: + - range(1, 3) + - range(1, 7) + - range(1, 5) + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, corrected Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80 + - title: OHCS 44-2025 temporary order correcting the PY2026 payment matrices, effective October 9, 2025 + href: https://secure.sos.state.or.us/oard/viewReceiptPDF.action?filingRsn=60695 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 1 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, Region 2 benefit matrix + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/income_band.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/income_band.yaml new file mode 100644 index 00000000000..578cb5a9d40 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/income_band.yaml @@ -0,0 +1,170 @@ +# Printed bounds do not follow one reproducible rounding rule. The last band +# extends through the separate eligibility limit, including its $1 discrepancy. +description: Oregon divides countable income into payment bands with these inclusive annual upper bounds by household size under the Low Income Home Energy Assistance Program. +1: + 1: + 2024-10-01: 9_203 + 2025-10-09: 9_597 + 2026-10-01: 10_082 + 2: + 2024-10-01: 12_035 + 2025-10-09: 12_549 + 2026-10-01: 13_184 + 3: + 2024-10-01: 14_866 + 2025-10-09: 15_502 + 2026-10-01: 16_286 + 4: + 2024-10-01: 17_698 + 2025-10-09: 18_454 + 2026-10-01: 19_388 + 5: + 2024-10-01: 20_530 + 2025-10-09: 21_407 + 2026-10-01: 22_490 + 6: + 2024-10-01: 23_361 + 2025-10-09: 24_360 + 2026-10-01: 25_592 + 7: + 2024-10-01: 23_892 + 2025-10-09: 24_913 + 2026-10-01: 26_174 + 8: + 2024-10-01: 24_423 + 2025-10-09: 25_467 + 2026-10-01: 26_755 + 9: + 2024-10-01: 24_954 + 2025-10-09: 26_020 + 2026-10-01: 27_337 + 10: + 2024-10-01: 25_485 + 2025-10-09: 26_574 + 2026-10-01: 27_918 + 11: + 2024-10-01: 26_016 + 2025-10-09: 27_128 + 2026-10-01: 28_500 + 12: + 2024-10-01: 26_547 + 2025-10-09: 27_681 + 2026-10-01: 29_082 +2: + 1: + 2024-10-01: 18_406 + 2025-10-09: 19_193 + 2026-10-01: 20_163 + 2: + 2024-10-01: 24_069 + 2025-10-09: 25_098 + 2026-10-01: 26_367 + 3: + 2024-10-01: 29_732 + 2025-10-09: 31_003 + 2026-10-01: 32_571 + 4: + 2024-10-01: 35_395 + 2025-10-09: 36_908 + 2026-10-01: 38_775 + 5: + 2024-10-01: 41_059 + 2025-10-09: 42_814 + 2026-10-01: 44_979 + 6: + 2024-10-01: 46_722 + 2025-10-09: 48_719 + 2026-10-01: 51_183 + 7: + 2024-10-01: 47_784 + 2025-10-09: 49_826 + 2026-10-01: 52_347 + 8: + 2024-10-01: 48_845 + 2025-10-09: 50_933 + 2026-10-01: 53_510 + 9: + 2024-10-01: 49_907 + 2025-10-09: 52_040 + 2026-10-01: 54_673 + 10: + 2024-10-01: 50_969 + 2025-10-09: 53_148 + 2026-10-01: 55_836 + 11: + 2024-10-01: 52_031 + 2025-10-09: 54_255 + 2026-10-01: 57_000 + 12: + 2024-10-01: 53_093 + 2025-10-09: 55_362 + 2026-10-01: 58_163 +3: + 1: + 2024-10-01: 27_609 + 2025-10-09: 28_789 + 2026-10-01: 30_245 + 2: + 2024-10-01: 36_103 + 2025-10-09: 37_646 + 2026-10-01: 39_551 + 3: + 2024-10-01: 44_598 + 2025-10-09: 46_504 + 2026-10-01: 48_857 + 4: + 2024-10-01: 53_093 + 2025-10-09: 55_362 + 2026-10-01: 58_163 + 5: + 2024-10-01: 61_588 + 2025-10-09: 64_220 + 2026-10-01: 67_469 + 6: + 2024-10-01: 70_082 + 2025-10-09: 73_078 + 2026-10-01: 76_775 + 7: + 2024-10-01: 71_675 + 2025-10-09: 74_739 + 2026-10-01: 78_520 + 8: + 2024-10-01: 73_268 + 2025-10-09: 76_400 + 2026-10-01: 80_265 + 9: + 2024-10-01: 74_861 + 2025-10-09: 78_060 + 2026-10-01: 82_010 + 10: + 2024-10-01: 76_453 + 2025-10-09: 79_721 + 2026-10-01: 83_754 + 11: + 2024-10-01: 78_046 + 2025-10-09: 81_382 + 2026-10-01: 85_499 + 12: + 2024-10-01: 79_639 + 2025-10-09: 83_043 + 2026-10-01: 87_244 +metadata: + unit: currency-USD + period: year + label: Oregon LIHEAP annual income band upper bounds + breakdown: + - range(1, 4) + - range(1, 13) + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual, standard payment income bands + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, standard payment income bands, household sizes 1 to 7 + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual, standard payment income bands, household sizes 7 to 12 + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=79 + - title: OHCS 44-2025 temporary order correcting the PY2026 payment matrices, effective October 9, 2025 + href: https://secure.sos.state.or.us/oard/viewReceiptPDF.action?filingRsn=60695 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, standard payment income bands, household sizes 1 to 9 + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual, standard payment income bands, household sizes 9 to 12 + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=73 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/max_household_size.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/max_household_size.yaml new file mode 100644 index 00000000000..121b160bd66 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/max_household_size.yaml @@ -0,0 +1,14 @@ +description: Oregon limits the published standard payment matrix to this household size under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: 12 +metadata: + unit: person + period: year + label: Oregon LIHEAP largest published household size + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=79 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=73 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/max_payment_size.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/max_payment_size.yaml new file mode 100644 index 00000000000..21897cb64c3 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/max_payment_size.yaml @@ -0,0 +1,14 @@ +description: Oregon provides the same payment amounts to households at or above this size under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: 6 +metadata: + unit: person + period: year + label: Oregon LIHEAP maximum payment size group + reference: + - title: "PY2025 Energy Assistance Intake Operations and Policy Manual #page 58-59" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: "PY2026 Energy Assistance Intake Operations and Policy Manual #page 78-81" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: "PY2027 Energy Assistance Intake Operations and Policy Manual #page 72-75" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/regions/region_1.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/regions/region_1.yaml new file mode 100644 index 00000000000..c8b459a4fc1 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/regions/region_1.yaml @@ -0,0 +1,32 @@ +description: Oregon includes these counties in payment region 1 under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: + - BENTON_COUNTY_OR + - CLACKAMAS_COUNTY_OR + - CLATSOP_COUNTY_OR + - COLUMBIA_COUNTY_OR + - COOS_COUNTY_OR + - CURRY_COUNTY_OR + - DOUGLAS_COUNTY_OR + - JACKSON_COUNTY_OR + - JOSEPHINE_COUNTY_OR + - LANE_COUNTY_OR + - LINCOLN_COUNTY_OR + - LINN_COUNTY_OR + - MARION_COUNTY_OR + - MULTNOMAH_COUNTY_OR + - POLK_COUNTY_OR + - TILLAMOOK_COUNTY_OR + - WASHINGTON_COUNTY_OR + - YAMHILL_COUNTY_OR +metadata: + unit: list + period: year + label: Oregon LIHEAP region 1 counties + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/regions/region_2.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/regions/region_2.yaml new file mode 100644 index 00000000000..e47dbab23d9 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/regions/region_2.yaml @@ -0,0 +1,32 @@ +description: Oregon includes these counties in payment region 2 under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: + - BAKER_COUNTY_OR + - CROOK_COUNTY_OR + - DESCHUTES_COUNTY_OR + - GILLIAM_COUNTY_OR + - GRANT_COUNTY_OR + - HARNEY_COUNTY_OR + - HOOD_RIVER_COUNTY_OR + - JEFFERSON_COUNTY_OR + - KLAMATH_COUNTY_OR + - LAKE_COUNTY_OR + - MALHEUR_COUNTY_OR + - MORROW_COUNTY_OR + - SHERMAN_COUNTY_OR + - UMATILLA_COUNTY_OR + - UNION_COUNTY_OR + - WALLOWA_COUNTY_OR + - WASCO_COUNTY_OR + - WHEELER_COUNTY_OR +metadata: + unit: list + period: year + label: Oregon LIHEAP region 2 counties + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59 + - title: PY2026 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74 diff --git a/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/roomer_rate.yaml b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/roomer_rate.yaml new file mode 100644 index 00000000000..082c8a2ae54 --- /dev/null +++ b/policyengine_us/parameters/gov/states/or/ohcs/liheap/payment/roomer_rate.yaml @@ -0,0 +1,14 @@ +description: Oregon provides this share of the standard payment to a roomer, boarder or owner who applies separately under the Low Income Home Energy Assistance Program. +values: + 2024-10-01: 0.5 +metadata: + unit: /1 + period: year + label: Oregon LIHEAP roomer and boarder payment share + reference: + - title: PY2025 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=47 + - title: "PY2026 Energy Assistance Intake Operations and Policy Manual #page 61-62" + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=62 + - title: PY2027 Energy Assistance Intake Operations and Policy Manual + href: https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=59 diff --git a/policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml b/policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml new file mode 100644 index 00000000000..baeccb64c31 --- /dev/null +++ b/policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml @@ -0,0 +1,20 @@ +description: Wisconsin allows these refundable credits when the retirement income exclusion is elected. +metadata: + unit: list + period: year + label: Wisconsin refundable credits allowed with the retirement income exclusion + reference: + - title: 2025 Wisconsin Schedule SB instructions + href: https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7 + - title: 2025 Wisconsin Act 174, section 7 + href: https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2 + - title: Wis. Stat. 71.05(6)(b)54m.d + href: https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/d + - title: Wis. Stat. 71.54 + href: https://docs.legis.wisconsin.gov/statutes/statutes/71/viii/54 +values: + 2021-01-01: [] + # Act 174, effective April 4, 2026, limits the prohibition to section 71.07 + # credits. Homestead is under section 71.54; earned income is under 71.07. + 2026-01-01: + - wi_homestead_credit diff --git a/policyengine_us/parameters/gov/states/wv/tax/income/subtractions/senior_citizen_disability_deduction/excluded_income_sources.yaml b/policyengine_us/parameters/gov/states/wv/tax/income/subtractions/senior_citizen_disability_deduction/excluded_income_sources.yaml new file mode 100644 index 00000000000..63c9c52dc00 --- /dev/null +++ b/policyengine_us/parameters/gov/states/wv/tax/income/subtractions/senior_citizen_disability_deduction/excluded_income_sources.yaml @@ -0,0 +1,28 @@ +description: West Virginia excludes these person-level Schedule M amounts from the income base for the senior citizen or disability modification before applying the cap. + +values: + # The equivalent income exclusion is verified from the 2014 form. + # Preserve unverified earlier direct-variable calculations. + 1986-01-01: [] + 2014-01-01: + # Line numbers below are from the 2025 form. + - military_service_income # Line 36 + - salt_refund_income # Line 38 + # Line 39 is a contribution deduction, not an income receipt. Its amount + # removes income within the form's excluded range. Use the contributor's + # own amount rather than projecting the tax-unit 529 deduction to spouses. + - investment_in_529_plan_indv # Line 39 + +metadata: + unit: list + period: year + label: West Virginia senior citizen or disability modification income exclusions + reference: + - title: "West Virginia Personal Income Tax Forms and Instructions 2025, Schedule M line 47 #page 5-6,29-30" + href: https://tax.wv.gov/Documents/PIT/2025/it140.PersonalIncomeTaxFormsAndInstructions.2025.pdf#page=5 + - title: "West Virginia Personal Income Tax Forms and Instructions 2024, Schedule M line 49 #page 5-6,29" + href: https://tax.wv.gov/Documents/PIT/2024/it140.PersonalIncomeTaxFormsAndInstructions.2024.pdf#page=5 + - title: West Virginia Personal Income Tax Forms and Instructions 2018, Schedule M lines 46-47 + href: https://tax.wv.gov/Documents/TaxForms/2018/it140.booklet.pdf#page=5 + - title: West Virginia Personal Income Tax Forms and Instructions 2014, Schedule M line 52 + href: https://tax.wv.gov/Documents/TaxForms/2014/it140.booklet.pdf#page=9 diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 037fa7898d2..23ec45e9904 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -849,6 +849,17 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # CMS employer-sponsored insurance premium per enrollee (January values, + # last projection year 2034). Employer and pre-tax employee premium + # inputs use this as their uprater. + extend_parameter_values( + parameters.calibration.gov.hhs.cms.esi_premium_per_enrollee, + last_projected_year=2034, + end_year=END_YEAR, + period_month=1, + period_day=1, + ) + # Gross Social Security benefit inputs should age with gross Social # Security benefits, not taxable Social Security income. extend_parameter_values( diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 69eaca33ef4..05cb1d13747 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -808,7 +808,7 @@ programs: category: Benefits agency: HHS status: partial - coverage: DC, Riverside County, MA, IL, IN, KS, KY, MD, MS, NC, NE, NJ, NY, TX + coverage: DC, Riverside County, MA, IL, IN, KS, KY, MD, MS, NC, NE, NJ, NY, OR, TX state_implementations: - state: DC status: complete @@ -900,6 +900,14 @@ programs: parameter_prefix: gov.states.ny.otda.heap verified_years: "2024-2026" notes: FY2024-FY2026 regular heating only, with the fuel base amounts, the Tier I and vulnerable supplements and the heat-included levels. Crisis, cooling, HERR and clean-and-tune assistance are out of scope. + - state: OR + status: partial + name: Oregon LIHEAP + full_name: Oregon Low Income Home Energy Assistance Program + variable: or_liheap + parameter_prefix: gov.states.or.ohcs.liheap + verified_years: "2025-2027" + notes: PY2025-PY2027 regular heating only, with the two county regions, five fuel columns and the roomer half payment. Crisis, cooling, Fuel Payment, equipment and OEAP assistance are out of scope. - state: TX status: partial name: Texas CEAP diff --git a/policyengine_us/reforms/states/mn/walz/mn_walz_hf1938.py b/policyengine_us/reforms/states/mn/walz/mn_walz_hf1938.py index 78aed52cd19..0a2f4903d04 100644 --- a/policyengine_us/reforms/states/mn/walz/mn_walz_hf1938.py +++ b/policyengine_us/reforms/states/mn/walz/mn_walz_hf1938.py @@ -112,13 +112,22 @@ def formula(tax_unit, period, parameters): # ... calculate pre-limitation itemized deductions itm_deds_less_salt = tax_unit("itemized_deductions_less_salt", period) capped_property_taxes = tax_unit("capped_property_taxes", period) - mn_itm_deds = itm_deds_less_salt + capped_property_taxes + # Schedule M1SA line 19 takes Minnesota's own casualty and theft + # loss (Schedule M1CAT) in place of the federal one. + federal_casualty_loss = tax_unit("casualty_loss_deduction", period) + mn_casualty_loss = tax_unit("mn_casualty_loss_deduction", period) + mn_itm_deds = ( + itm_deds_less_salt + - federal_casualty_loss + + mn_casualty_loss + + capped_property_taxes + ) # ... calculate itemized deductions offset p = parameters(period).gov.states.mn.tax.income.deductions.itemized exempt_deds = add( tax_unit, period, - ["medical_expense_deduction", "casualty_loss_deduction"], + ["medical_expense_deduction", "mn_casualty_loss_deduction"], ) net_deds = max_(0, mn_itm_deds - exempt_deds) net_deds_offset = p.reduction.alternate.rate * net_deds diff --git a/policyengine_us/reforms/states/ut/dependent_exemption/ut_dependent_exemption_reform.py b/policyengine_us/reforms/states/ut/dependent_exemption/ut_dependent_exemption_reform.py index 5e4ebc0dccc..da642d21052 100644 --- a/policyengine_us/reforms/states/ut/dependent_exemption/ut_dependent_exemption_reform.py +++ b/policyengine_us/reforms/states/ut/dependent_exemption/ut_dependent_exemption_reform.py @@ -33,7 +33,13 @@ def formula(tax_unit, period, parameters): return p.personal_exemption * total_dependents person = tax_unit.members age = person("age", period) - dependent = person("is_tax_unit_dependent", period) + # A return on which the filer (or, if joint, either spouse) can be + # claimed as a dependent has no dependents (IRC 152(b)(1)) outside + # the claimant filing exception, as in the baseline + # ut_total_dependents. + dependent = person("is_tax_unit_dependent", period) & ~person.tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) # Dependents under the age threshold (all dependents when the # age limit is off) take the contrib amount; older dependents # keep the baseline (uprated) personal-exemption amount. diff --git a/policyengine_us/tests/code_health/test_eitc_notebook_chart.py b/policyengine_us/tests/code_health/test_eitc_notebook_chart.py new file mode 100644 index 00000000000..51ac5b38408 --- /dev/null +++ b/policyengine_us/tests/code_health/test_eitc_notebook_chart.py @@ -0,0 +1,305 @@ +"""The stored EITC documentation charts agree with the parameters they show. + +The docs build does not execute notebooks (`execute_notebooks: off` in +docs/_config.yml), so docs/gov/irs/credits/eitc.ipynb publishes the outputs it +was last saved with. Those outputs kept the 2021 joint phase-out bonus ($5,940) +for childless couples after the parameters moved to 2022 ($6,130), and a check +of the curves' maxima alone could not see it. + +This check reads the notebook and the EITC parameter files without importing +the model. For the year the notebook simulates, it recomputes every stored +point of both charts, in the initial view and in each animation frame: + +- the credit from 26 U.S.C. 32(a): the phase-in rate times earnings, capped at + the maximum credit and at the maximum less the phase-out rate times earnings + above the phase-out start (plus the joint bonus for couples), never below + zero. The notebook's households have no income but the head's wages, so + earnings are also AGI; +- the marginal rate as `IndividualSim.deriv` computes it: forward differences + of the unrounded credit over the wage grid, the last one repeated; +- the wage grid itself, from the notebook's `sim.vary(...)` call. + +It also checks the labels a reader sees: each chart's title year, each +curve's hover labels, and that each slider step plays the frame it names. +""" + +import json +import math +import re +from pathlib import Path + +import pytest +import yaml + +REPO = Path(__file__).resolve().parents[3] +NOTEBOOK = REPO / "docs" / "gov" / "irs" / "credits" / "eitc.ipynb" +EITC = REPO / "policyengine_us" / "parameters" / "gov" / "irs" / "credits" / "eitc" +PLOTLY_MIME = "application/vnd.plotly.v1+json" +CHILD_COUNTS = [0, 1, 2, 3] +# Frames are named by the number of adults; two adults file jointly. +ADULT_FRAMES = ["1", "2"] +# Stored credits are the credit rounded to whole dollars, so they sit within +# half a dollar of the exact amount, plus float32 noise (about 5e-4 at these +# amounts). +CREDIT_TOLERANCE = 0.502 +RATE_TOLERANCE = 1e-4 # stored rates come from float32 credits + + +class _DateStringLoader(yaml.SafeLoader): + """Keeps date keys as strings, so the 0000-01-01 sentinel loads.""" + + +_DateStringLoader.yaml_implicit_resolvers = { + key: [ + (tag, regexp) + for tag, regexp in resolvers + if tag != "tag:yaml.org,2002:timestamp" + ] + for key, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items() +} + + +def _value_in(node, year, uprated): + """A leaf's value on January 1 of the year, as annual simulations read it.""" + values = node.get("values", node) + instant = f"{year}-01-01" + dates = [key for key in values if key != "metadata" and key <= instant] + uprated = uprated or "uprating" in node.get("metadata", {}) + # The check cannot reproduce uprating, so an indexed amount must be + # authored for the year itself. + assert dates and (not uprated or instant in dates), ( + f"no authored value for {instant}" + ) + value = values[max(dates)] + return value["value"] if isinstance(value, dict) else value + + +def _by_child_count(file_name, year): + raw = yaml.load((EITC / file_name).read_text(), Loader=_DateStringLoader) + # Core also applies uprating declared on the whole scale to its leaves. + scale_uprated = any("uprating" in key for key in raw.get("metadata", {})) + + def uprated(bracket): + # ... and uprating declared on a bracket to its components. + return scale_uprated or any( + "uprating" in key for key in bracket.get("metadata", {}) + ) + + rows = sorted( + ( + _value_in(row["threshold"], year, uprated(row)), + _value_in(row["amount"], year, uprated(row)), + ) + for row in raw["brackets"] + ) + + def lookup(children): + return next( + amount for threshold, amount in reversed(rows) if children >= threshold + ) + + return lookup + + +def _schedule(year): + maximum = _by_child_count("max.yaml", year) + phase_in_rate = _by_child_count("phase_in_rate.yaml", year) + phase_out_rate = _by_child_count("phase_out/rate.yaml", year) + phase_out_start = _by_child_count("phase_out/start.yaml", year) + joint_bonus = _by_child_count("phase_out/joint_bonus.yaml", year) + + def credit(adults, children): + cap = maximum(children) + start = phase_out_start(children) + if adults == "2": + start += joint_bonus(children) + + def at(wage): + phased_out = cap - phase_out_rate(children) * max(0, wage - start) + return max(0, min(phase_in_rate(children) * wage, cap, phased_out)) + + return at + + return credit + + +def _notebook(): + notebook = json.loads(NOTEBOOK.read_text()) + sources = ["".join(cell["source"]) for cell in notebook["cells"]] + setup = next(source for source in sources if "IndividualSim(" in source) + year = int(re.search(r"IndividualSim\(year=(\d{4})\)", setup).group(1)) + vary = re.search( + r"sim\.vary\(\"employment_income\", max=([\d_]+), step=(\d+)\)", setup + ) + wages = list(range(0, int(vary.group(1).replace("_", "")) + 1, int(vary.group(2)))) + charts, titles = {}, {} + for cell in notebook["cells"]: + source = "".join(cell["source"]) + if cell["cell_type"] != "code" or "px.line(" not in source: + continue + measure = re.search(r'"employment_income",\s*"(\w+)"', source).group(1) + outputs = [ + output + for output in cell["outputs"] + if PLOTLY_MIME in output.get("data", {}) + ] + assert len(outputs) == 1, f"the {measure} cell has no single stored chart" + charts[measure] = outputs[0]["data"][PLOTLY_MIME] + titles[measure] = re.search(r'title="([^"]+)"', source).group(1) + assert set(charts) == {"eitc", "mtr"} + return year, wages, charts, titles + + +def _views(chart): + """(adults, traces) for the initial view and each animation frame.""" + # The initial view is the first frame, before the slider moves. + return [(ADULT_FRAMES[0], chart["data"])] + [ + (frame["name"], frame["data"]) for frame in chart["frames"] + ] + + +def _label_errors(chart, measure, year, title): + errors = [] + stored_title = chart["layout"]["title"]["text"] + if stored_title != title or str(year) not in stored_title: + errors.append(f"{measure}: title {stored_title!r} is not the cell's {title!r}") + if [frame["name"] for frame in chart["frames"]] != ADULT_FRAMES: + errors.append(f"{measure}: frames are not {ADULT_FRAMES}") + steps = chart["layout"]["sliders"][0]["steps"] + if [step["label"] for step in steps] != ADULT_FRAMES: + errors.append(f"{measure}: slider steps are not {ADULT_FRAMES}") + for step in steps: + if step["args"][0] != [step["label"]]: + errors.append( + f"{measure}: slider step {step['label']} plays {step['args'][0]}" + ) + return errors + + +def _chart_errors(chart, measure, year, title, wages, credit): + errors = _label_errors(chart, measure, year, title) + for adults, traces in _views(chart): + # Plotly matches a frame's curves to the initial view's by position. + names = [int(trace["name"]) for trace in traces] + if names != CHILD_COUNTS: + errors.append(f"{measure}, {adults} adult(s): curves are {names}") + continue + for trace in traces: + children = int(trace["name"]) + where = f"{measure}, {adults} adult(s), {children} children" + hover = trace["hovertemplate"] + if f"Adults={adults}<" not in hover or f"Children={children}<" not in hover: + errors.append(f"{where}: hover labels read {hover!r}") + if trace["x"] != wages or len(trace["y"]) != len(wages): + errors.append(f"{where}: points are not on the vary() grid") + continue + amounts = [credit(adults, children)(wage) for wage in wages] + if measure == "eitc": + expected = amounts + tolerance = CREDIT_TOLERANCE + else: + steps = [ + -(after - before) / (high - low) + for before, after, low, high in zip( + amounts, amounts[1:], wages, wages[1:] + ) + ] + expected = steps + steps[-1:] + tolerance = RATE_TOLERANCE + for wage, stored, wanted in zip(wages, trace["y"], expected, strict=True): + if not math.isclose(stored, wanted, rel_tol=0, abs_tol=tolerance): + errors.append( + f"{where}, wages {wage:,}: stored {stored}, parameters {wanted}" + ) + return errors + + +@pytest.fixture(scope="module") +def stored(): + year, wages, charts, titles = _notebook() + return year, wages, charts, titles, _schedule(year) + + +@pytest.mark.parametrize("measure", ["eitc", "mtr"]) +def test_stored_eitc_chart_matches_parameters(stored, measure): + year, wages, charts, titles, credit = stored + errors = _chart_errors( + charts[measure], measure, year, titles[measure], wages, credit + ) + + assert not errors, f"{len(errors)} stored points differ:\n" + "\n".join(errors[:20]) + + +def _mutated(chart, change): + chart = json.loads(json.dumps(chart)) + change(chart) + return chart + + +def _set_point(trace, index, value): + trace["y"][index] = value + + +def _swap_slider_frames(chart): + steps = chart["layout"]["sliders"][0]["steps"] + steps[0]["args"][0], steps[1]["args"][0] = steps[1]["args"][0], steps[0]["args"][0] + + +@pytest.mark.parametrize( + "measure, change", + [ + # A curve stored in reverse order. + ("eitc", lambda chart: chart["frames"][1]["data"][2]["y"].reverse()), + ("mtr", lambda chart: chart["data"][1]["y"].reverse()), + # A wage axis that no longer matches the simulated grid. + ("eitc", lambda chart: chart["frames"][0]["data"][0]["x"].__setitem__(0, 1e6)), + ("eitc", lambda chart: chart["data"][3]["x"].pop()), + # Credits missing from a curve. + ("eitc", lambda chart: chart["data"][2].__setitem__("y", [])), + ("mtr", lambda chart: chart["frames"][1]["data"][0]["y"].pop()), + # One point off by a few dollars, as the stale joint-bonus frames + # were, or by a single dollar. + ("eitc", lambda chart: _set_point(chart["frames"][1]["data"][0], 152, 552)), + ( + "eitc", + lambda chart: _set_point( + chart["data"][1], 300, chart["data"][1]["y"][300] + 1 + ), + ), + # A duplicated curve in place of another child count. + ( + "eitc", + lambda chart: chart["frames"][1]["data"].__setitem__( + 3, chart["frames"][1]["data"][2] + ), + ), + # Curves out of order, which plotly would pair with the wrong colour. + ("mtr", lambda chart: chart["frames"][0]["data"].reverse()), + # Labels that misdescribe the curves. + ("eitc", _swap_slider_frames), + ("eitc", lambda chart: chart["layout"]["sliders"][0]["steps"].pop()), + ( + "mtr", + lambda chart: chart["frames"][1]["data"][0].__setitem__( + "hovertemplate", "Children=0
Adults=1
%{y}" + ), + ), + ( + "eitc", + lambda chart: chart["layout"]["title"].__setitem__( + "text", "Earned income tax credit, 2021" + ), + ), + ( + "eitc", + lambda chart: chart["layout"]["title"].__setitem__( + "text", "EITC for single filers by number of qualifying children, 2022" + ), + ), + ], +) +def test_chart_check_rejects_corrupted_charts(stored, measure, change): + year, wages, charts, titles, credit = stored + chart = _mutated(charts[measure], change) + + assert _chart_errors(chart, measure, year, titles[measure], wages, credit) diff --git a/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py new file mode 100644 index 00000000000..cc36269eca5 --- /dev/null +++ b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py @@ -0,0 +1,563 @@ +"""Person variables must not add or subtract group variables. + +For a variable with no formula for the period, policyengine-core sums its +`adds` list and takes away its `subtracts` list, calling +`simulation.calculate(listed, period, map_to=)` for +each listed variable (`Simulation._run_formula`). `Simulation.map_result` +maps a group variable to persons with `population.project`, so every member +of the tax unit, SPM unit, family, marital unit or household receives the +whole group amount. A group total of the person variable then counts it once +per member. The `add()` formula helper raises for the same request, while +these two attributes automatically reach the projection. + +A group variable listed by a different group is split evenly over the members +and then summed, and a person variable listed by a group is summed; both keep +totals, so only person-level variables are checked. + +A list given as a parameter path is checked at every date in the parameter's +history, because a simulation of an earlier year uses the list in force then. + +ALLOWED holds specific reviewed boolean receipt edges, preserving the baseline +interpretation that each member inherits program receipt. A new source or a +subtraction edge still requires review. KNOWN_VIOLATIONS holds open bugs. +Fixing one makes this file fail until its entry is deleted. +""" + +from collections import defaultdict +from types import SimpleNamespace + +import pytest +from hypothesis import example, given, settings, strategies as st +from policyengine_core.commons.formulas import add +from policyengine_core.country_template import ( + CountryTaxBenefitSystem as TemplateCountrySystem, +) +from policyengine_core.country_template.entities import ( + Household as TemplateHousehold, + Person as TemplatePerson, +) +from policyengine_core.parameters import Parameter, ParameterNode, get_parameter +from policyengine_core.periods import YEAR +from policyengine_core.simulations import SimulationBuilder +from policyengine_core.variables import Variable + +from policyengine_us.system import system + + +ATTRIBUTES = ("adds", "subtracts") +GROUP_ENTITIES = ("tax_unit", "spm_unit", "family", "marital_unit", "household") + +# Reviewed positive receipt edges in boolean Person variables. Core casts the +# sum to bool. Exact triples preserve the baseline participation interpretation +# without approving future sources or subtraction edges on the same variable. +ALLOWED = { + ( + "ks_dcf_csfp_categorically_eligible", + "adds", + "snap", + ): "baseline categorical eligibility from positive SPM-unit SNAP benefits", + ( + "ks_dcf_csfp_categorically_eligible", + "adds", + "receives_snap", + ): "baseline categorical eligibility from reported SPM-unit SNAP receipt", + ( + "ks_dcf_csfp_categorically_eligible", + "adds", + "fdpir", + ): "baseline categorical eligibility from positive SPM-unit FDPIR benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "ma_eaedc", + ): "baseline enrollment from positive SPM-unit EAEDC benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "ma_tafdc", + ): "baseline enrollment from positive SPM-unit TAFDC benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "snap", + ): "baseline enrollment from positive SPM-unit SNAP benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "receives_snap", + ): "baseline enrollment from reported SPM-unit SNAP receipt", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "snap", + ): "baseline enrollment from positive SPM-unit SNAP benefits", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "receives_snap", + ): "baseline enrollment from reported SPM-unit SNAP receipt", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "tanf", + ): "baseline enrollment from positive SPM-unit TANF benefits", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "receives_tanf", + ): "baseline enrollment from reported SPM-unit TANF receipt", +} + +# (person variable, attribute, group variable): each member gets the whole +# group amount. Each was confirmed as a bug on 2026-10-06. +KNOWN_VIOLATIONS = { + # PolicyEngine/policyengine-us#9888. + ("mt_misc_deductions", "adds", "casualty_loss_deduction"), + ("il_aabd_expense_exemption_person", "adds", "state_withheld_income_tax"), + ( + "ca_riv_general_relief_countable_property_value", + "adds", + "ca_riv_general_relief_countable_vehicle_value", + ), + ( + "ca_riv_general_relief_countable_property_value", + "adds", + "spm_unit_cash_assets", + ), + ( + "ca_riv_general_relief_earned_income_deductions", + "adds", + "additional_medicare_tax", + ), + ( + "ca_riv_general_relief_earned_income_deductions", + "adds", + "state_withheld_income_tax", + ), + ("la_general_relief_gross_income", "adds", "tanf"), +} + + +def listed_names(variable, attribute: str, parameters) -> dict[str, list[str]]: + """Map each name in `variable.` to the dates it is listed. + + A list written on the variable applies at every date ("always"). A + parameter path is read at each value in the parameter's history.""" + listed = getattr(variable, attribute, None) + if not listed: + return {} + if not isinstance(listed, str): + return {name: ["always"] for name in listed} + parameter = get_parameter(parameters, listed) + if not isinstance(parameter, Parameter): + raise TypeError( + f"{variable.name}.{attribute} names {listed}, which is not a " + "parameter holding a list" + ) + dates = defaultdict(list) + for value in parameter.values_list: + for name in value.value or (): + dates[name].append(value.instant_str) + return {name: sorted(set(found)) for name, found in dates.items()} + + +def group_variables_in_person_lists(variables, parameters) -> dict: + """{(person variable, attribute, group variable): (group entity, dates)}. + + Names that are not variables are parameters, which core adds as values.""" + found = {} + for name, variable in variables.items(): + if not variable.entity.is_person: + continue + for attribute in ATTRIBUTES: + for listed, dates in listed_names(variable, attribute, parameters).items(): + source = variables.get(listed) + if source is not None and not source.entity.is_person: + found[(name, attribute, listed)] = (source.entity.key, dates) + return found + + +FOUND = group_variables_in_person_lists(system.variables, system.parameters) + + +def unexpected_group_variables(found, allowed, known) -> dict: + """Only exact reviewed or known triples exempt a projected group edge.""" + return { + key: value + for key, value in found.items() + if key not in allowed and key not in known + } + + +def assert_known_violations_live(found, allowed, known): + """Recorded bugs must remain live and disjoint from reviewed receipt edges.""" + fixed = sorted(set(known) - found.keys()) + assert not fixed, ( + "No longer projected; delete these from KNOWN_VIOLATIONS:\n" + + "\n".join(f"{entry}" for entry in fixed) + ) + overlap = sorted(set(known) & set(allowed)) + assert not overlap, f"Listed as both allowed and a bug: {overlap}" + + +def assert_allowed_variables_live(found, variables, allowed): + """Every reviewed edge must remain live, with a boolean Person adds target.""" + stale = sorted(set(allowed) - found.keys()) + assert not stale, "No longer projected; delete these from ALLOWED:\n" + "\n".join( + f"{entry}" for entry in stale + ) + not_boolean = sorted( + {name for name, _, _ in allowed if variables[name].value_type is not bool} + ) + assert not not_boolean, ( + "ALLOWED covers booleans only, where projection means every member " + f"inherits the group's value: {not_boolean}" + ) + not_person = sorted( + {name for name, _, _ in allowed if not variables[name].entity.is_person} + ) + assert not not_person, f"ALLOWED covers Person variables only: {not_person}" + not_adds = sorted(key for key in allowed if key[1] != "adds") + assert not not_adds, f"ALLOWED covers positive adds edges only: {not_adds}" + + +def test_person_variables_do_not_add_group_variables(): + new = unexpected_group_variables(FOUND, ALLOWED, KNOWN_VIOLATIONS) + assert not new, ( + "These person variables list group variables, so core gives every " + "member the whole group amount. Make the variable group-level, or list " + "a person-level share. For a reviewed boolean receipt edge that every " + "member should inherit, add its exact triple to ALLOWED with the reason.\n" + + "\n".join( + f"{name}.{attribute} lists {listed} ({entity}; {', '.join(dates)})" + for (name, attribute, listed), (entity, dates) in sorted(new.items()) + ) + ) + + +def test_known_violations_only_shrink(): + assert_known_violations_live(FOUND, ALLOWED, KNOWN_VIOLATIONS) + + +def test_allowed_variables_are_booleans_that_still_list_group_variables(): + assert_allowed_variables_live(FOUND, system.variables, ALLOWED) + + +def test_boolean_exception_does_not_allow_snap_subtraction(monkeypatch): + target = system.variables["ks_dcf_csfp_categorically_eligible"] + monkeypatch.setattr(target, "subtracts", ["snap"]) + monkeypatch.setitem( + globals(), + "FOUND", + group_variables_in_person_lists(system.variables, system.parameters), + ) + # Core attaches each simulation to its system; restore the previous reference. + monkeypatch.setattr( + system, "simulation", getattr(system, "simulation", None), raising=False + ) + simulation = SimulationBuilder().build_from_entities( + system, + { + "people": { + "recipient": { + "ssi": {"2025-05": 0}, + "receives_ssi": {"2025-05": False}, + "msp": {"2025-05": 0}, + } + }, + "households": { + "household": { + "members": ["recipient"], + "state_code": {"2025": "KS"}, + } + }, + "spm_units": { + "spm_unit": { + "members": ["recipient"], + "snap": {"2025-05": 100}, + "receives_snap": {"2025-05": False}, + "fdpir": {"2025": 0}, + } + }, + }, + ) + # With reported receipt false and only computed SNAP positive, the sum + # becomes 0 + False + 100 + False + 0 / 12 + 0 - 100 = 0 (false). + assert simulation.calculate(target.name, "2025-05").tolist() == [False] + # The existing positive SNAP edge must not exempt this subtraction. + with pytest.raises( + AssertionError, + match=r"ks_dcf_csfp_categorically_eligible\.subtracts lists snap", + ): + test_person_variables_do_not_add_group_variables() + + +@settings(max_examples=50, deadline=None) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + edges=st.sets( + st.tuples( + st.sampled_from(("flag", "other_flag")), + st.sampled_from(ATTRIBUTES), + st.sampled_from(("snap", "fdpir", "tanf")), + ), + max_size=12, + ), + reviewed_sources=st.sets(st.sampled_from(("snap", "fdpir", "tanf"))), +) +def test_exact_registries_accept_safe_partitions_and_reject_new_edges( + group_entity, edges, reviewed_sources +): + reviewed_edge = ("flag", "adds", "snap") + subtract_snap = ("flag", "subtracts", "snap") + edges = (edges | {reviewed_edge}) - {subtract_snap} + found = {key: (group_entity, ["always"]) for key in edges} + allowed = { + key: "reviewed positive receipt" + for key in edges + if key[1] == "adds" and (key[2] in reviewed_sources or key == reviewed_edge) + } + known = edges - allowed.keys() + variables = { + name: SimpleNamespace( + value_type=bool, entity=SimpleNamespace(key="person", is_person=True) + ) + for name, _, _ in edges + } + assert unexpected_group_variables(found, allowed, known) == {} + assert_known_violations_live(found, allowed, known) + assert_allowed_variables_live(found, variables, allowed) + + # An allowed name does not approve another source or the opposite attribute. + for new_edge in ( + ("flag", "adds", "unreviewed_source"), + ("flag", "subtracts", "unreviewed_source"), + subtract_snap, + ): + changed = {**found, new_edge: (group_entity, ["always"])} + assert unexpected_group_variables(changed, allowed, known) == { + new_edge: (group_entity, ["always"]) + } + + +@settings(max_examples=50, deadline=None) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + source_index=st.integers(min_value=0, max_value=100), +) +def test_exact_registries_require_live_disjoint_entries(group_entity, source_index): + first = ("flag", "adds", f"source_{source_index}") + sibling = ("flag", "adds", f"sibling_{source_index}") + bug = ("known_bug", "subtracts", f"source_{source_index}") + found = {key: (group_entity, ["always"]) for key in (first, sibling, bug)} + allowed = {first: "reviewed receipt", sibling: "another reviewed receipt"} + known = {bug} + variables = { + "flag": SimpleNamespace( + value_type=bool, entity=SimpleNamespace(key="person", is_person=True) + ) + } + assert_known_violations_live(found, allowed, known) + assert_allowed_variables_live(found, variables, allowed) + + # The variable still lists a group source: only the precise edge went stale. + without_first = {key: value for key, value in found.items() if key != first} + with pytest.raises(AssertionError, match="delete these from ALLOWED"): + assert_allowed_variables_live(without_first, variables, allowed) + without_bug = {key: value for key, value in found.items() if key != bug} + with pytest.raises(AssertionError, match="delete these from KNOWN_VIOLATIONS"): + assert_known_violations_live(without_bug, allowed, known) + with pytest.raises(AssertionError, match="both allowed and a bug"): + assert_known_violations_live(found, allowed, known | {first}) + + +@settings(max_examples=50, deadline=None) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + invalid_schema=st.sampled_from(("integer", "float", "group", "subtracts")), +) +def test_allowed_edges_require_boolean_person_adds(group_entity, invalid_schema): + attribute = "subtracts" if invalid_schema == "subtracts" else "adds" + key = ("flag", attribute, "group_amount") + found = {key: (group_entity, ["always"])} + target = SimpleNamespace( + value_type={"integer": int, "float": float}.get(invalid_schema, bool), + entity=SimpleNamespace( + key=group_entity if invalid_schema == "group" else "person", + is_person=invalid_schema != "group", + ), + ) + message = { + "integer": "booleans only", + "float": "booleans only", + "group": "Person variables only", + "subtracts": "positive adds edges only", + }[invalid_schema] + with pytest.raises(AssertionError, match=message): + assert_allowed_variables_live(found, {"flag": target}, {key: "receipt"}) + + +@settings(max_examples=50, deadline=None) +@example( + group_entity="tax_unit", + attribute="adds", + history=[["group_a", "rate"], ["person_amount"]], +) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + attribute=st.sampled_from(ATTRIBUTES), + history=st.lists( + st.lists( + st.sampled_from(("group_a", "group_b", "person_amount", "rate")), + max_size=6, + ), + min_size=1, + max_size=5, + ), +) +def test_parameter_history_preserves_every_group_source( + group_entity, attribute, history +): + dates = [f"{2015 + index}-01-01" for index in range(len(history))] + parameters = ParameterNode( + "", + data={"lists": {"sources": {"values": dict(zip(dates, history))}}}, + ) + person = SimpleNamespace(key="person", is_person=True) + group = SimpleNamespace(key=group_entity, is_person=False) + target = SimpleNamespace( + name="from_parameter", entity=person, **{attribute: "lists.sources"} + ) + variables = { + "from_parameter": target, + "person_amount": SimpleNamespace(name="person_amount", entity=person), + "group_a": SimpleNamespace(name="group_a", entity=group), + "group_b": SimpleNamespace(name="group_b", entity=group), + "group_total": SimpleNamespace( + name="group_total", entity=group, **{attribute: "lists.sources"} + ), + } + expected_dates = { + name: sorted({date for date, names in zip(dates, history) if name in names}) + for name in {name for names in history for name in names} + } + assert listed_names(target, attribute, parameters) == expected_dates + assert group_variables_in_person_lists(variables, parameters) == { + ("from_parameter", attribute, name): (group_entity, listed_dates) + for name, listed_dates in expected_dates.items() + if name in ("group_a", "group_b") + } + + +def test_parameter_lists_are_checked_across_their_history(): + parameters = ParameterNode( + "", + data={ + "lists": { + "sources": { + "values": { + "2015-01-01": ["group_amount", "rate"], + "2020-01-01": ["person_amount"], + } + } + } + }, + ) + person = SimpleNamespace(key="person", is_person=True) + group = SimpleNamespace(key="tax_unit", is_person=False) + variables = { + "person_amount": SimpleNamespace(name="person_amount", entity=person), + "group_amount": SimpleNamespace(name="group_amount", entity=group), + "from_parameter": SimpleNamespace( + name="from_parameter", entity=person, adds="lists.sources" + ), + "from_list": SimpleNamespace( + name="from_list", + entity=person, + adds=["person_amount"], + subtracts=["group_amount"], + ), + "group_total": SimpleNamespace( + name="group_total", entity=group, adds=["group_amount"] + ), + } + # The 2015 list still counts after 2020 drops it; "rate" is a parameter. + assert group_variables_in_person_lists(variables, parameters) == { + ("from_parameter", "adds", "group_amount"): ("tax_unit", ["2015-01-01"]), + ("from_list", "subtracts", "group_amount"): ("tax_unit", ["always"]), + } + # A path to a parameter folder fails rather than checking nothing. + with pytest.raises(TypeError, match="not a parameter holding a list"): + listed_names(SimpleNamespace(name="x", adds="lists"), "adds", parameters) + + +@settings(max_examples=50, deadline=None) +@example(member_count=3, amount=100) +@given( + member_count=st.integers(min_value=1, max_value=6), + amount=st.integers(min_value=-1000, max_value=1000), +) +def test_core_projects_group_values_listed_in_person_adds(member_count, amount): + """Projection repeats a whole group amount; aggregation multiplies it by size.""" + template = TemplateCountrySystem() + + class household_amount(Variable): + value_type = float + entity = TemplateHousehold + definition_period = YEAR + label = "Household amount" + + class person_adds_household_amount(Variable): + value_type = float + entity = TemplatePerson + definition_period = YEAR + label = "Person variable adding a household variable" + adds = ["household_amount"] + + class household_sum_of_person_amounts(Variable): + value_type = float + entity = TemplateHousehold + definition_period = YEAR + label = "Household sum of the person variable" + adds = ["person_adds_household_amount"] + + class person_add_helper(Variable): + value_type = float + entity = TemplatePerson + definition_period = YEAR + label = "Person variable calling add() on a household variable" + + def formula(person, period, parameters): + return add(person, period, ["household_amount"]) + + for variable in ( + household_amount, + person_adds_household_amount, + household_sum_of_person_amounts, + person_add_helper, + ): + template.add_variable(variable) + members = [f"person_{index}" for index in range(member_count)] + simulation = SimulationBuilder().build_from_entities( + template, + { + "persons": {member: {} for member in members}, + "households": { + "h": { + "parents": members[:2], + "children": members[2:], + "household_amount": {"2024": amount}, + } + }, + }, + ) + assert ( + simulation.calculate("person_adds_household_amount", 2024).tolist() + == [amount] * member_count + ) + # The explicit example repeats 100 three times: 3 * 100 = 300. + assert simulation.calculate("household_sum_of_person_amounts", 2024).tolist() == [ + member_count * amount + ] + with pytest.raises(ValueError, match="not yet implemented"): + simulation.calculate("person_add_helper", 2024) diff --git a/policyengine_us/tests/core/test_claimable_filer_ri_sc_ut_vt_wv_wi_invariants.py b/policyengine_us/tests/core/test_claimable_filer_ri_sc_ut_vt_wv_wi_invariants.py new file mode 100644 index 00000000000..b8dc2d6d410 --- /dev/null +++ b/policyengine_us/tests/core/test_claimable_filer_ri_sc_ut_vt_wv_wi_invariants.py @@ -0,0 +1,136 @@ +"""Rhode Island, South Carolina, Utah, Vermont, West Virginia and Wisconsin +rules for a filer who can be claimed as a dependent. + +A return on which the filer (or, if joint, either spouse) can be claimed has +no dependents (IRC 152(b)(1)) unless the would-be claimant meets the filing +exception, so the dependent-based amounts below are zero on it. Claimant +rules that turn on an actual claim (RI property tax relief, VT renter credit) +and per-filer exemptions (WV, WI $250) test each spouse separately. + +For couples drawn by Hypothesis (claimable, actually claimed and filing +exception drawn per spouse) and a seeded population in these states: + +1. Swap invariance: each output is the same under either head/spouse + labelling. +2. Monotonicity: marking another filer as claimed never raises the amounts + in MONOTONE. Wisconsin homestead income and the West Virginia family + credit are left out by design: they rise when a dependent deduction or + exemption goes away. +3. Identities: the dependent-based amounts are zero on a return where the + Dependent Taxpayer Test applies; a return whose every filer is actually + claimed gets no RI relief or VT renter credit; and a West Virginia return + with no exemptions gets the single $500. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us.tests.core.test_claimable_filer_federal_invariants import ( + check_monotone, + check_swap, +) +from policyengine_us.tests.core.test_dependent_elsewhere_head_spouse_swap_invariance import ( + CLAIM_PATTERNS, + _calc, + _crafted_cases, + _seeded_couples, + adults, + dependents, +) + +STATES = ["RI", "SC", "UT", "VT", "WV", "WI"] +YEARS = [2021, 2023, 2025, 2026] +# Zero on a return where either filer can be claimed (IRC 152(b)(1)). +DEPENDENT_BASED = [ + "ri_child_tax_rebate", + "sc_dependent_exemption", + "sc_young_child_deduction", + "ut_total_dependents", + "ut_personal_exemption", + "ut_at_home_parent_credit_potential", + "vt_ctc", +] +MONOTONE = DEPENDENT_BASED + [ + "ri_property_tax_credit", + "vt_personal_exemptions", + "vt_renter_credit", + "wv_personal_exemption", + "wv_low_income_family_tax_credit_eligible", + "wv_low_income_family_tax_credit_fpg", + "wi_standard_deduction", + "wi_additional_exemption", + "wi_homestead_credit", + "wi_childcare_expense_subtraction", + "wi_childcare_expense_credit_potential", +] +OUTPUTS = MONOTONE + [ + "wv_low_income_family_tax_credit", + "wi_homestead_income", + "wi_retirement_income_exclusion_tax", + "state_income_tax", +] + + +@st.composite +def couples(draw): + claimable = draw(st.sampled_from(CLAIM_PATTERNS)) + claimed = tuple(can and draw(st.booleans()) for can in claimable) + return { + "state": draw(st.sampled_from(STATES)), + "adults": [draw(adults()), draw(adults())], + "dependents": draw(st.lists(dependents(), max_size=2)), + "claimed": claimed, + "claimable": claimable, + "exception": tuple(not c and draw(st.booleans()) for c in claimed), + } + + +def _identities(sim, year): + no_dependents = ( + _calc( + sim, "head_or_spouse_is_dependent_elsewhere_without_filing_exception", year + ) + > 0 + ) + for name in DEPENDENT_BASED: + values = _calc(sim, name, year) + assert not values[no_dependents].any(), ( + f"{name} in {year} is positive on a return where the Dependent " + "Taxpayer Test applies" + ) + person_claimed = np.asarray( + sim.calculate("claimed_as_dependent_on_another_return", year, map_to="tax_unit") + ) + filers = np.asarray( + sim.calculate("is_tax_unit_head_or_spouse", year, map_to="tax_unit") + ) + every_filer_claimed = (person_claimed >= filers) & (filers > 0) + for name in ["ri_property_tax_credit", "vt_renter_credit"]: + assert not _calc(sim, name, year)[every_filer_claimed].any(), ( + f"{name} in {year} is positive when every filer is claimed" + ) + state = np.asarray(sim.calculate("state_code_str", year, map_to="tax_unit")) + wv_none = (state == "WV") & (_calc(sim, "exemptions_count", year) == 0) + np.testing.assert_array_equal( + _calc(sim, "wv_personal_exemption", year)[wv_none], 500 + ) + + +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.sampled_from(YEARS), st.lists(couples(), min_size=6, max_size=12)) +def test_ri_sc_ut_vt_wv_wi_claimable_filer_rules_are_label_free(year, units): + check_swap(units, year, OUTPUTS, _identities) + + +@pytest.mark.parametrize("year", YEARS) +def test_ri_sc_ut_vt_wv_wi_claimable_filer_rules_seeded_population(year): + units = _seeded_couples(STATES, per_state=2) + _crafted_cases(STATES) + check_swap(units, year, OUTPUTS, _identities) + check_monotone(units, year, MONOTONE, _identities) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index 70b816980e6..ffb79cdbfc6 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -70,7 +70,7 @@ "student_loan_interest_ald", "early_withdrawal_penalty", "educator_expense_ald_person", - "traditional_ira_contributions", + "traditional_ira_deduction", ] # Person-level amounts that are the filer's wherever they are recorded. FILER_AMOUNTS = [ diff --git a/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py b/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py index 6d484de82fb..49783dcfb0f 100644 --- a/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py +++ b/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py @@ -28,12 +28,15 @@ 1. `tax_unit_non_dep_add` equals a numpy reference: the head's and spouse's amounts of person-level variables plus tax-unit-level variables. For tax units without dependents it equals `add`. -2. In every state with an income tax, giving the dependents retirement income - never lowers the filer's state income tax. Outside +2. Outside STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES, giving the + dependents retirement income never lowers the filer's state income tax. + Michigan is an intended exception: a dependent adult's pension can lower + their SSI and increase the claimant's refundable credit. Outside STATES_COUNTING_DEPENDENT_INCOME it changes neither the state AGI nor the state taxable income. (State income tax itself may still rise: credits keyed to household income, such as Oklahoma's sales tax relief credit, - count every household member's income.) + count every household member's income.) Michigan's pension benefits, + subtractions and tax before refundable credits must remain unchanged. 3. A unit whose dependents have no income has the same inputs in both populations, so its state income tax, AGI and taxable income are the same in both: no unit's results depend on another unit's inputs. @@ -368,6 +371,14 @@ def test_tax_unit_non_dep_add_edge_cases(): # subtracting income that never entered AGI; property 2 checks only that it # never lowers tax there. STATES_COUNTING_DEPENDENT_INCOME = {"AL", "AR", "IA", "MS", "NJ"} +# Intended exception per ruling d1143 (2026-10-09): follow MI-1040CR lines +# 18 and 21 (2025 MI-1040 booklet, PDF page 28 / printed page 32): +# https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=28 +# Line 18 counts pensions only for the claimant and spouse; line 21 counts +# SSI received for dependent adults who live with them. A dependent adult's +# pension can reduce SSI, lower household resources and raise refundable +# credits, while the claimant's pension subtractions and tax base stay fixed. +STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES = {"MI"} YEARS = [2025, 2026] @@ -402,11 +413,32 @@ def test_dependents_retirement_income_never_reduces_state_income_tax(year): assert not leaked.any(), sorted(set(states[leaked])) lowered = tax_with < tax_without - TOLERANCE - assert not lowered.any(), sorted(set(states[lowered])) + intended_exception = np.isin( + states, list(STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES) + ) + unexpectedly_lowered = lowered & ~intended_exception + assert not unexpectedly_lowered.any(), sorted(set(states[unexpectedly_lowered])) counts_dependents = np.isin(states, list(STATES_COUNTING_DEPENDENT_INCOME)) changed &= ~counts_dependents assert not changed.any(), sorted(set(states[changed])) + + # Michigan's intended exception concerns refundable credits only; a + # dependent's pension must never enter the claimant's pension subtraction. + mi = states == "MI" + for variable in [ + "mi_pension_benefit", + "mi_subtractions", + "mi_income_tax_before_refundable_credits", + ]: + np.testing.assert_allclose( + with_income.calculate(variable, year)[mi], + without_income.calculate(variable, year)[mi], + rtol=0, + atol=TOLERANCE, + err_msg=f"Michigan {variable} changed with dependent retirement income", + ) + # The population must exercise the property, including the paths gated on # the filers' Social Security and on ages of 73 or more. assert has_dependent_income.sum() >= len(INCOME_TAX_STATES) diff --git a/policyengine_us/tests/core/test_eitc_elective_deferral_invariants.py b/policyengine_us/tests/core/test_eitc_elective_deferral_invariants.py new file mode 100644 index 00000000000..2e560ecbd55 --- /dev/null +++ b/policyengine_us/tests/core/test_eitc_elective_deferral_invariants.py @@ -0,0 +1,224 @@ +"""Differential coverage of payroll exclusions across vectorized tax units. + +Replacing gross wages and pre-tax deductions with the same W-2 box 1 wages +must leave federal EITC and ACTC earnings/credits unchanged. California must +retain taxable payroll HSA amounts, while gross earned income and the AMT +earnings cap must remain independent of payroll deductions. The paired runs +also remove dependents' wages to check that they cannot enter filer earnings. +Additional California pairs retain payroll HSA deductions when substituting +California taxable wages, so YCTC must agree even with business losses. +""" + +from copy import deepcopy + +import numpy as np + +from policyengine_us import Simulation + + +YEAR = 2026 +RANDOM_TAX_UNITS = 24 + + +def _situations(): + rng = np.random.default_rng(20261009) + people, tax_units, households, marital_units = {}, {}, {}, {} + for i in range(RANDOM_TAX_UNITS): + members = [f"head_{i}", f"child_{i}"] + if i % 2: + members.insert(1, f"spouse_{i}") + marital_units[f"filers_{i}"] = {"members": members[:-1]} + marital_units[f"dependent_{i}"] = {"members": members[-1:]} + for role, name in zip( + ["head", "spouse", "child"] if i % 2 else ["head", "child"], + members, + ): + wages = int(rng.integers(10_000, 60_000)) + traditional = int(rng.integers(0, 4_000)) + values = { + "age": 10 if role == "child" else 35, + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "child", + "employment_income": wages, + "self_employment_income": int(rng.integers(0, 5_000)), + "traditional_401k_contributions_desired": traditional if i % 3 else 0, + "traditional_403b_contributions_desired": traditional + if not i % 3 + else 0, + "roth_401k_contributions_desired": 1_000, + "pre_tax_health_insurance_premiums": 500, + "health_savings_account_payroll_contributions": 1_000, + } + people[name] = {key: {YEAR: value} for key, value in values.items()} + tax_units[f"unit_{i}"] = { + "members": members, + "takes_up_eitc": {YEAR: True}, + # Force the existing cap to isolate its gross earnings dependency. + "amt_kiddie_tax_applies": {YEAR: True}, + "amt_income": {YEAR: 200_000}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "CA" if i % 3 == 0 else "VA"}, + } + california_wage_pairs = set() + for i, (wages, traditional, hsa, business_income) in enumerate( + ( + # Gross wages exceed the YCTC ceiling but taxable wages do not. + # The business loss leaves zero earned income, invoking Step 8. + (40_000, 10_000, 0, -30_000), + (40_000, 10_000, 1_000, -40_000), + # Also exercise losses leaving positive earnings and SE profits. + (35_000, 10_000, 1_000, -5_000), + (25_000, 5_000, 1_000, 2_500), + ), + start=RANDOM_TAX_UNITS, + ): + members = [f"head_{i}", f"child_{i}"] + california_wage_pairs.update(members) + marital_units[f"filers_{i}"] = {"members": members[:1]} + marital_units[f"dependent_{i}"] = {"members": members[1:]} + for role, name in zip(("head", "child"), members): + values = { + "age": 35 if role == "head" else 4, + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": role == "child", + "employment_income": wages if role == "head" else 0, + "self_employment_income": business_income if role == "head" else 0, + "traditional_401k_contributions_desired": traditional + if role == "head" + else 0, + "traditional_403b_contributions_desired": 0, + "roth_401k_contributions_desired": 0, + "pre_tax_health_insurance_premiums": 0, + "health_savings_account_payroll_contributions": hsa + if role == "head" + else 0, + } + people[name] = {key: {YEAR: value} for key, value in values.items()} + tax_units[f"unit_{i}"] = { + "members": members, + "takes_up_eitc": {YEAR: True}, + "amt_kiddie_tax_applies": {YEAR: True}, + "amt_income": {YEAR: 200_000}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "CA"}, + } + gross = { + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + } + taxable = deepcopy(gross) + without = deepcopy(gross) + deductions = ( + "traditional_401k_contributions_desired", + "traditional_403b_contributions_desired", + "pre_tax_health_insurance_premiums", + "health_savings_account_payroll_contributions", + ) + for name, values in people.items(): + wages = values["employment_income"][YEAR] + excluded = sum(values[key][YEAR] for key in deductions) + if name in california_wage_pairs: + # California W-2 box 16 retains payroll HSA contributions. + # Keeping that deduction in the paired run also preserves the + # same federal W-2 box 1 wages. + excluded -= values["health_savings_account_payroll_contributions"][YEAR] + taxable["people"][name]["employment_income"][YEAR] = wages - excluded + for key in deductions: + taxable["people"][name][key][YEAR] = 0 + without["people"][name][key][YEAR] = 0 + if name in california_wage_pairs: + taxable["people"][name]["health_savings_account_payroll_contributions"][ + YEAR + ] = values["health_savings_account_payroll_contributions"][YEAR] + if values["is_tax_unit_dependent"][YEAR]: + # A child's pay belongs on their own return, not the filer's. + for key in ("employment_income", "self_employment_income"): + taxable["people"][name][key][YEAR] = 0 + return gross, taxable, without + + +def _run(situation): + simulation = Simulation(situation=situation) + outputs = ( + "eitc_earned_income", + "eitc", + "ctc_phase_in_relevant_earnings", + "ctc_phase_in", + "filer_adjusted_earnings", + "ca_eitc_earned_income", + "ca_yctc", + "earned_income", + "adjusted_earnings", + "amt_exemption", + ) + return {name: simulation.calculate(name, YEAR).copy() for name in outputs} + + +def test_payroll_exclusions_match_equivalent_taxable_compensation(): + gross, taxable, without = _situations() + with_deductions = _run(gross) + equivalent_wages = _run(taxable) + no_deductions = _run(without) + + # Full credit calculations must agree, not just a reimplemented formula. + for name in ( + "eitc_earned_income", + "eitc", + "ctc_phase_in_relevant_earnings", + "ctc_phase_in", + "filer_adjusted_earnings", + ): + np.testing.assert_allclose( + with_deductions[name], + equivalent_wages[name], + atol=0.01, + rtol=0, + err_msg=name, + ) + + # HSA remains California taxable pay, once per nondependent filer. + california = np.arange(RANDOM_TAX_UNITS) % 3 == 0 + filer_count = 1 + np.arange(RANDOM_TAX_UNITS) % 2 + np.testing.assert_allclose( + with_deductions["ca_eitc_earned_income"][:RANDOM_TAX_UNITS][california] + - equivalent_wages["ca_eitc_earned_income"][:RANDOM_TAX_UNITS][california], + 1_000 * filer_count[california], + atol=0.01, + rtol=0, + ) + + # Equivalent California compensation must preserve both earned income + # and YCTC, including its zero-earned-income eligibility branch. + yctc_pairs = slice(RANDOM_TAX_UNITS, None) + for name in ("ca_eitc_earned_income", "ca_yctc"): + np.testing.assert_allclose( + with_deductions[name][yctc_pairs], + equivalent_wages[name][yctc_pairs], + atol=0.01, + rtol=0, + err_msg=name, + ) + np.testing.assert_array_equal( + with_deductions["ca_eitc_earned_income"][ + RANDOM_TAX_UNITS : RANDOM_TAX_UNITS + 2 + ], + 0, + ) + assert np.all(with_deductions["ca_yctc"][yctc_pairs] > 0) + + for name in ("earned_income", "adjusted_earnings", "amt_exemption"): + np.testing.assert_allclose( + with_deductions[name], + no_deductions[name], + atol=0.01, + rtol=0, + err_msg=name, + ) diff --git a/policyengine_us/tests/core/test_esi_premium_uprating.py b/policyengine_us/tests/core/test_esi_premium_uprating.py new file mode 100644 index 00000000000..88db168c410 --- /dev/null +++ b/policyengine_us/tests/core/test_esi_premium_uprating.py @@ -0,0 +1,141 @@ +"""The CMS employer-sponsored insurance (ESI) premium per enrollee series that +employer plan premium inputs uprate by. + +CMS's National Health Expenditure projections (Table 17, 2025-2034 release) +report ESI spending, ESI enrollment and ESI spending per enrollee for 2013 to +2034. ESI spending is premiums paid by employers and employees (NHEA +methodology paper, file page 29), so spending per enrollee is the premium per +covered person. The parameter holds that row; 2035 to 2100 grow at its +2033-2034 rate. + +These tests read only the parameter tree. Engine uprating of the inputs (a +household simulation and dataset extension) is covered in +``tests/microsimulation/test_esi_premium_microsim_uprating.py``. + +Invariants: + +1. Transcription: every value is Table 17's published dollar figure, and is + consistent with the same table's ESI spending over ESI enrollment within + rounding. +2. Employer share: CMS projects employer contributions (Table 16) as a flat + share of ESI spending, so employer contributions per ESI enrollee grow with + the index. That is why one index serves the employer and employee shares. +3. Bounds and order: the index is positive in every year and strictly + increasing, as CMS's series is in every year from 2013 to 2034; the + extension keeps both through 2100. +4. Extension rule: each year from 2035 to 2100 grows by 13,124 / 12,559. +""" + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us.system import system + +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +PREMIUM_INPUTS = ( + "employer_sponsored_insurance_premiums", + "pre_tax_health_insurance_premiums", +) +FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2013, 2034, 2100 +YEARS = range(FIRST_YEAR, LAST_PROJECTED_YEAR + 1) + +# CMS NHE projections tables, Table 17, "Employer-sponsored Insurance" rows +# (https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified +# 2026-09-03, sha256 264f8226...cd70). +ESI_SPENDING_PER_ENROLLEE = dict( + zip( + YEARS, + ( + *(4_901, 4_980, 5_113, 5_354, 5_500, 5_783, 5_955, 6_049, 6_456), + *(6_877, 7_595, 8_000, 8_616, 9_181, 9_627, 10_093, 10_533), + *(11_001, 11_469, 11_986, 12_559, 13_124), + ), + ) +) +ESI_SPENDING_BILLIONS = dict( + zip( + YEARS, + ( + *(829.1, 845.4, 880.5, 926.8, 965.7, 1013.1, 1050.3, 1052.7), + *(1120.9, 1203.9, 1333.5, 1429.1, 1549.8, 1650.5, 1732.9), + *(1820.4, 1896.9, 1978.0, 2058.6, 2148.4, 2246.7, 2343.1), + ), + ) +) +ESI_ENROLLMENT_MILLIONS = dict( + zip( + YEARS, + ( + *(169.2, 169.8, 172.2, 173.1, 175.6, 175.2, 176.4, 174.0), + *(173.6, 175.1, 175.6, 178.6, 179.9, 179.8, 180.0, 180.4), + *(180.1, 179.8, 179.5, 179.2, 178.9, 178.5), + ), + ) +) +# Table 16, employer contributions to private health insurance premiums by +# private business, the federal government and state and local governments. +EMPLOYER_CONTRIBUTIONS_BILLIONS = { + 2024: 753 + 47 + 247, + 2026: 865 + 56 + 288, + 2034: 1_207 + 82 + 429, +} + + +def _index(year): + return get_parameter(system.parameters, ESI)(f"{year}-01-01") + + +@pytest.mark.parametrize("year", YEARS) +def test_index_is_table_17_spending_per_enrollee(year): + assert _index(year) == ESI_SPENDING_PER_ENROLLEE[year] + + +@pytest.mark.parametrize("year", YEARS) +def test_published_values_match_spending_over_enrollment(year): + """Checks the transcribed rows against each other, so one mistyped + spending, enrollment or per-enrollee figure fails.""" + spending = ESI_SPENDING_BILLIONS[year] + enrollment = ESI_ENROLLMENT_MILLIONS[year] + # Both components are rounded to one decimal and the published value to + # the dollar. + low = 1_000 * (spending - 0.05) / (enrollment + 0.05) - 0.5 + high = 1_000 * (spending + 0.05) / (enrollment - 0.05) + 0.5 + assert low <= ESI_SPENDING_PER_ENROLLEE[year] <= high + + +@pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) +def test_employer_contributions_per_enrollee_grow_with_the_index(start, end): + def per_enrollee(year): + return EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_ENROLLMENT_MILLIONS[year] + + years = end - start + employer_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) + index_growth = (_index(end) / _index(start)) ** (1 / years) + assert employer_growth == pytest.approx(index_growth, abs=0.001) + # The employer share of ESI premiums stays within 73.2% to 73.4%. + for year in (start, end): + share = EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_SPENDING_BILLIONS[year] + assert 0.732 <= share <= 0.734 + + +@given( + st.integers(FIRST_YEAR, END_YEAR - 1), + st.integers(1, END_YEAR - FIRST_YEAR), +) +def test_index_is_positive_and_strictly_increasing(year, gap): + later = min(year + gap, END_YEAR) + assert 0 < _index(year) < _index(later) + + +@given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR)) +def test_extension_holds_the_last_projected_growth_rate(year): + last_growth = _index(LAST_PROJECTED_YEAR) / _index(LAST_PROJECTED_YEAR - 1) + assert last_growth == pytest.approx(13_124 / 12_559, rel=1e-12) + assert _index(year) / _index(year - 1) == pytest.approx(last_growth, rel=1e-12) + + +@pytest.mark.parametrize("variable", PREMIUM_INPUTS) +def test_premium_inputs_uprate_by_the_esi_index(variable): + assert system.variables[variable].uprating == ESI diff --git a/policyengine_us/tests/core/test_estate_income_invariants.py b/policyengine_us/tests/core/test_estate_income_invariants.py index b6e8b2fa404..d17c6959ef1 100644 --- a/policyengine_us/tests/core/test_estate_income_invariants.py +++ b/policyengine_us/tests/core/test_estate_income_invariants.py @@ -425,24 +425,40 @@ def _all_member_estate(units): return estate -def test_estate_income_raises_household_net_income_by_its_after_tax_amount(): +@pytest.mark.parametrize( + "qualified, tax_rise", + [ + # Unreported QBI is presumed zero (Treas. Reg. 1.199A-6(b)(3)(iii), + # applied to beneficiaries by 1.199A-6(d)(1)), so taxable estate + # income alone earns no QBI deduction: 12% of $10,000. + (None, 1_200), + # Estate income reported as the beneficiary's allocated qualified + # business items (Form 1041 Schedule K-1, box 14, code I) earns the + # 20% QBI deduction: 12% of $10,000 less $2,000. + (True, 960), + ], +) +def test_estate_income_raises_household_net_income_by_its_after_tax_amount( + qualified, tax_rise +): """Taxing estate income must not make a household look poorer. A single Texas filer with $50,000 of wages in 2024 receives $10,000 of - estate income. Federal income tax rises by 12% of $10,000 less the $2,000 - QBI deduction, or $960; Texas has no income tax and estate income carries - no payroll tax. So household net income rises by $9,040. + estate income, which stays in the 12% bracket. Texas has no income tax + and estate income carries no payroll tax, so household net income rises + by the $10,000 less the federal income tax increase. """ def net_income(estate): + head = { + "age": {2024: 40}, + "employment_income": {2024: 50_000}, + "estate_income": {2024: estate}, + } + if qualified is not None: + head["estate_income_would_be_qualified"] = {2024: qualified} situation = { - "people": { - "head": { - "age": {2024: 40}, - "employment_income": {2024: 50_000}, - "estate_income": {2024: estate}, - } - }, + "people": {"head": head}, "tax_units": {"tax_unit": {"members": ["head"]}}, "households": { "household": {"members": ["head"], "state_code": {2024: "TX"}} @@ -456,5 +472,5 @@ def net_income(estate): net_without, tax_without = net_income(0) net_with, tax_with = net_income(10_000) - assert abs((tax_with - tax_without) - 960) < TOLERANCE - assert abs((net_with - net_without) - 9_040) < TOLERANCE + assert abs((tax_with - tax_without) - tax_rise) < TOLERANCE + assert abs((net_with - net_without) - (10_000 - tax_rise)) < TOLERANCE diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py new file mode 100644 index 00000000000..b27b2be36ac --- /dev/null +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -0,0 +1,281 @@ +"""Compare Idaho premium election with independently enumerated legal routes.""" + +from importlib.metadata import version + +from hypothesis import example, given, settings, strategies as st +import numpy as np +from packaging.version import Version +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system as SYSTEM + + +PERIOD = "2026" +AMOUNT = st.integers(min_value=0, max_value=50_000) +ROW = st.fixed_dictionaries( + { + "premiums": st.tuples(AMOUNT, AMOUNT, AMOUNT), + "pre_tax": st.tuples(AMOUNT, AMOUNT, AMOUNT), + "above_line": AMOUNT, + "dependent_above_line": AMOUNT, + "medical": AMOUNT, + "standard": AMOUNT, + "itemized": AMOUNT, + "mandatory": st.booleans(), + } +) + + +@settings(max_examples=20, deadline=None) +@given(rows=st.lists(ROW, min_size=2, max_size=5)) +@example( + rows=[ + { + "premiums": (1_000, 500, 1_000), + "pre_tax": (0, 0, 0), + "above_line": 0, + "dependent_above_line": 900, + "medical": 500, + "standard": 10_000, + "itemized": 10_500, + "mandatory": False, + }, + { + "premiums": (0, 0, 500), + "pre_tax": (0, 0, 0), + "above_line": 0, + "dependent_above_line": 0, + "medical": 0, + "standard": 10_000, + "itemized": 1_000, + "mandatory": True, + }, + ] +) +@example( + rows=[ + { + "premiums": (1_000, 0, 500), + "pre_tax": (0, 0, 0), + "above_line": 0, + "dependent_above_line": 0, + "medical": 0, + "standard": 16_100, + "itemized": 0, + "mandatory": False, + }, + { + "premiums": (1_000, 0, 500), + "pre_tax": (0, 0, 0), + "above_line": 0, + "dependent_above_line": 500, + "medical": 0, + "standard": 16_100, + "itemized": 0, + "mandatory": False, + }, + { + "premiums": (1_000, 400, 500), + "pre_tax": (100, 50, 500), + "above_line": 150, + "dependent_above_line": 500, + "medical": 0, + "standard": 16_100, + "itemized": 0, + "mandatory": False, + }, + ] +) +def test_premium_subtraction_and_election_match_best_legal_route(rows): + people = {} + tax_units = {} + households = {} + expected_itemizes = [] + expected_subtractions = [] + expected_reductions = [] + available_premiums = [] + for index, row in enumerate(rows): + members = [f"person_{index}_{member}" for member in range(3)] + for member, person_id in enumerate(members): + people[person_id] = { + "age": {PERIOD: (40, 39, 20)[member]}, + "is_tax_unit_head": {PERIOD: member == 0}, + "is_tax_unit_spouse": {PERIOD: member == 1}, + "is_tax_unit_dependent": {PERIOD: member == 2}, + "health_insurance_premiums": {PERIOD: row["premiums"][member]}, + "pre_tax_health_insurance_premiums": {PERIOD: row["pre_tax"][member]}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + "self_employed_health_insurance_ald": {PERIOD: row["above_line"]}, + "dependents_self_employed_health_insurance_ald": { + PERIOD: row["dependent_above_line"] + }, + "medical_expense_deduction": {PERIOD: row["medical"]}, + "standard_deduction": {PERIOD: row["standard"]}, + "id_itemized_deductions": {PERIOD: row["itemized"]}, + "separate_filer_itemizes": {PERIOD: row["mandatory"]}, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {PERIOD: "ID"}, + } + + # Enumerate the standard and itemized routes using scalar arithmetic. + # Their combined reduction, rather than deduction alone, determines + # the favorable election; a spouse's itemization overrides that choice. + # Premium inputs identify the payer: only the head and spouse's + # payments enter this return. The dependent's payments and deductions + # belong on the dependent's own return. Pretax payroll premiums are + # a separate leaf input and never reduce these after-tax payments. + # The explicit pretax example has 1,000 + 400 - 150 = 1,250 available, + # irrespective of its separate 100 + 50 = 150 pretax premiums. + # Its standard route retains 1,250 and reduces income by + # 16,100 + 1,250 = 17,350, exceeding itemized 0 + 1,250 = 1,250. + available = max( + 0, + sum(row["premiums"][:2]) - row["above_line"], + ) + unclaimed_if_itemizing = max(0, available - row["medical"]) + standard_route = (row["standard"] + available, False, available) + itemized_route = ( + row["itemized"] + unclaimed_if_itemizing, + True, + unclaimed_if_itemizing, + ) + elected = ( + itemized_route + if row["mandatory"] + else max((standard_route, itemized_route), key=lambda route: route[0]) + ) + expected_reductions.append(elected[0]) + expected_itemizes.append(elected[1]) + expected_subtractions.append(elected[2]) + available_premiums.append(available) + + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + }, + ) + subtraction = simulation.calculate( + "id_health_insurance_premiums_subtraction", PERIOD + ) + deductions = simulation.calculate("id_deductions", PERIOD) + + assert simulation.calculate( + "id_qualified_health_insurance_premiums", PERIOD + ).tolist() == pytest.approx(available_premiums) + assert simulation.calculate("id_itemizes", PERIOD).tolist() == expected_itemizes + assert subtraction.tolist() == pytest.approx(expected_subtractions) + assert (deductions + subtraction).tolist() == pytest.approx(expected_reductions) + assert all( + 0 <= actual <= available + for actual, available in zip(subtraction, available_premiums) + ) + + +def test_actual_medical_deduction_is_scoped_to_period_and_branch(): + years = (2025, 2026) + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "head": { + "age": {year: 40 for year in years}, + "health_insurance_premiums": {year: 3_000 for year in years}, + } + }, + "tax_units": { + "tax_unit": { + "members": ["head"], + "adjusted_gross_income": {year: 30_000 for year in years}, + "medical_expense_deduction": {2026: 3_000}, + } + }, + "households": { + "household": { + "members": ["head"], + "state_code": {year: "ID" for year in years}, + } + }, + }, + ) + # Create branches before calculating the helper so they inherit no + # previously calculated Idaho value. A sibling's input must not change + # the parent's derived claim, but a nested branch inherits that input. + actual_claim = simulation.get_branch("actual_claim") + actual_claim.set_input("medical_expense_deduction", 2025, np.array([3_000])) + nested_claim = actual_claim.get_branch("nested_claim") + deleted_claim = simulation.get_branch("deleted_claim") + deleted_claim.set_input("medical_expense_deduction", 2025, np.array([3_000])) + deleted_claim.delete_arrays("medical_expense_deduction", 2025) + + variable = "id_health_insurance_premiums_medical_deduction" + assert simulation.calculate(variable, 2025).tolist() == [750] + assert simulation.calculate(variable, 2026).tolist() == [3_000] + assert actual_claim.calculate(variable, 2025).tolist() == [3_000] + assert nested_claim.calculate(variable, 2025).tolist() == [3_000] + # Check before federal recalculation, while the deleted input's array is + # absent. Core versions before 3.32.27 retain its input key, so recalculating + # first triggers the provenance defect documented in the PR's limitation. + assert deleted_claim.calculate(variable, 2025).tolist() == [750] + + +@pytest.mark.xfail( + condition=Version(version("policyengine-core")) < Version("3.32.27"), + strict=True, + reason=( + "policyengine-core<3.32.27 retains deleted input provenance and " + "misclassifies a federal medical deduction recalculated before the " + "Idaho helper as supplied; fixed by policyengine-core#561." + ), +) +def test_deleted_medical_input_recalculated_federal_first(): + year = 2025 + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "head": { + "age": {year: 40}, + "is_tax_unit_head": {year: True}, + "health_insurance_premiums": {year: 3_000}, + }, + "dependent": { + "age": {year: 20}, + "is_tax_unit_dependent": {year: True}, + "health_insurance_premiums": {year: 500}, + }, + }, + "tax_units": { + "tax_unit": { + "members": ["head", "dependent"], + "adjusted_gross_income": {year: 30_000}, + "standard_deduction": {year: 15_750}, + "id_itemized_deductions": {year: 20_000}, + } + }, + "households": { + "household": { + "members": ["head", "dependent"], + "state_code": {year: "ID"}, + } + }, + }, + ) + simulation.set_input("medical_expense_deduction", year, np.array([3_000])) + simulation.delete_arrays("medical_expense_deduction", year) + + # Federal first: 3,000 + 500 - 30,000 * 7.5% = 1,250. + assert simulation.calculate("medical_expense_deduction", year).tolist() == [1_250] + # Idaho excludes the dependent's payment: medical overlap is + # 3,000 - 30,000 * 7.5% = 750, leaving 3,000 - 750 = 2,250. + # Core's provenance defect before 3.32.27 uses 1,250 and returns 1,750. + assert simulation.calculate( + "id_health_insurance_premiums_subtraction", year + ).tolist() == [2_250] diff --git a/policyengine_us/tests/core/test_il_aabd_expense_exemption_invariants.py b/policyengine_us/tests/core/test_il_aabd_expense_exemption_invariants.py new file mode 100644 index 00000000000..5d2aa68aa11 --- /dev/null +++ b/policyengine_us/tests/core/test_il_aabd_expense_exemption_invariants.py @@ -0,0 +1,274 @@ +"""Invariants for the Illinois AABD employment expense exemption. + +89 Ill. Adm. Code 113.125(a) exempts withholding taxes and Social Security +tax as recognized expenses of employment, and IDHS PM 08-02-00 allows these +deductions "from the employed person's earned income". So each person's +exemption comes from their own wages only. The exemption's sources list used +to name the tax unit's state_withheld_income_tax, which policyengine-core +projects onto every member of the tax unit, so each member deducted the whole +unit's Illinois withholding. It now names the person's own +il_withheld_income_tax. + +Hypothesis draws batches of Illinois tax units (single, joint, head of +household with dependents, joint with dependents), and a seeded population +of 200 units adds breadth. Each batch runs as one vectorized simulation, and +again with every head's wages raised by a drawn non-negative amount. For +every person, in January 2024 and January 2025: + +1. Differential: il_aabd_expense_exemption_person equals an independent + numpy sum of the person's own Illinois withholding estimate (4.95% of + wages above the personal exemption) and their own 6.2% Social Security + tax, per month. A tax unit dependent's withholding estimate is zero, + because irs_gross_income leaves a dependent's income to their own return; + their Social Security tax still counts. +2. Accounting: in each tax unit, the members' withholding parts sum to the + unit's state_withheld_income_tax, so each member's withholding is counted + once rather than once per member. +3. Non-interference: raising the head's wages changes no other member's + exemption, or their AABD earned income after exemptions. +4. Monotone: raising the head's wages never lowers the head's exemption. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars a month + +# Illinois flat rate (35 ILCS 5/201(b)) and personal exemption amount from the +# IL-1040 instructions for each tax year. +IL_RATE = 0.0495 +IL_PERSONAL_EXEMPTION = {2024: 2_775, 2025: 2_850} +# Employee Social Security tax rate and contribution and benefit base. +SS_RATE = 0.062 +SS_WAGE_BASE = {2024: 168_600, 2025: 176_100} +YEARS = sorted(IL_PERSONAL_EXEMPTION) +MONTHS = 12 + +# Zeros, wages around the personal exemption, and ordinary wages. +wages = st.one_of( + st.just(0.0), + st.integers(1, 3_000).map(float), + st.integers(3_000, 150_000).map(float), +) +raises = st.one_of(st.just(0.0), st.integers(1, 20_000).map(float)) + + +@st.composite +def people(draw): + return { + "employment_income": draw(wages), + "is_disabled": draw(st.booleans()), + "is_blind": draw(st.booleans()), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "hoh", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(people()), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [ + {**draw(people()), "age": draw(st.sampled_from([10, 17, 25]))} + for _ in range(n_dependents) + ], + "raise": draw(raises), + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def person(): + band = rng.choice([0, 1, 2]) + return { + "employment_income": float( + [0, rng.integers(1, 3_001), rng.integers(3_000, 150_001)][band] + ), + "is_disabled": bool(rng.random() < 0.5), + "is_blind": bool(rng.random() < 0.2), + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "hoh", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": person(), + "spouse": person() if kind.startswith("joint") else None, + "dependents": [ + {**person(), "age": int(rng.choice([10, 17, 25]))} + for _ in range(n_dependents) + ], + "raise": float(rng.integers(0, 20_001)) if rng.random() < 0.7 else 0.0, + } + ) + return units + + +def _situation(units, year, *, raise_heads): + people = {} + groups = { + name: {} + for name in [ + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ] + } + + def add(name, values, role, extra_wages=0.0): + people[name] = { + "age": {"head": 45, "spouse": 43}.get(role, values.get("age")), + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "employment_income": values["employment_income"] + extra_wages, + "is_disabled": values["is_disabled"], + "is_blind": values["is_blind"], + # Keep SSI, and so AABD unearned income, at zero. + "takes_up_ssi_if_eligible": False, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head", raise_heads * unit["raise"]) + members, couple = [head], [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse") + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, dependent in enumerate(unit["dependents"]): + name = f"dependent_{i}_{j}" + add(name, dependent, "dependent") + members.append(name) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [name]} + for group in ["tax_units", "spm_units", "families"]: + groups[group][f"{group}_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "IL"}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, raise_heads=False): + sim = Simulation(situation=_situation(units, year, raise_heads=raise_heads)) + month = f"{year}-01" + out = { + name: np.asarray(sim.calculate(name, month), dtype=float) + for name in [ + "il_aabd_expense_exemption_person", + "il_aabd_child_care_expense_exemption", + "il_aabd_earned_income_after_exemption_person", + "il_withheld_income_tax", + "employee_social_security_tax", + "state_withheld_income_tax", + ] + } + out["wages"] = np.asarray(sim.calculate("employment_income", year), dtype=float) + out["head"] = np.asarray(sim.calculate("is_tax_unit_head", year)) + out["dependent"] = np.asarray(sim.calculate("is_tax_unit_dependent", year)) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], + weights=values, + minlength=len(run["state_withheld_income_tax"]), + ) + + +def _check(units, year): + base = _run(units, year) + exemption = base["il_aabd_expense_exemption_person"] + + # 1. Differential against numpy. + wages = base["wages"] + own_withholding = np.where( + base["dependent"], + 0, + IL_RATE * np.maximum(wages - IL_PERSONAL_EXEMPTION[year], 0), + ) + own_ss_tax = SS_RATE * np.minimum(wages, SS_WAGE_BASE[year]) + np.testing.assert_allclose( + base["il_withheld_income_tax"], own_withholding / MONTHS, atol=TOLERANCE + ) + np.testing.assert_allclose( + base["employee_social_security_tax"], own_ss_tax / MONTHS, atol=TOLERANCE + ) + np.testing.assert_allclose( + exemption, + (own_withholding + own_ss_tax) / MONTHS + + base["il_aabd_child_care_expense_exemption"], + atol=TOLERANCE, + ) + + # 2. Each member's withholding is counted once per tax unit. + withholding_part = ( + exemption + - base["employee_social_security_tax"] + - base["il_aabd_child_care_expense_exemption"] + ) + np.testing.assert_allclose( + _unit_sum(base, withholding_part), + base["state_withheld_income_tax"], + atol=TOLERANCE, + ) + + # 3. and 4. Raise only the heads' wages. + raised = _run(units, year, raise_heads=True) + others = ~base["head"] + for name in [ + "il_aabd_expense_exemption_person", + "il_aabd_earned_income_after_exemption_person", + ]: + np.testing.assert_allclose( + raised[name][others], base[name][others], atol=TOLERANCE, err_msg=name + ) + head = base["head"] + assert ( + raised["il_aabd_expense_exemption_person"][head] >= exemption[head] - TOLERANCE + ).all() + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +def test_il_aabd_expense_exemption_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + _check(_seeded_units(), year) 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/core/test_nj_worker_payroll_contributions.py b/policyengine_us/tests/core/test_nj_worker_payroll_contributions.py new file mode 100644 index 00000000000..23b8fb4365b --- /dev/null +++ b/policyengine_us/tests/core/test_nj_worker_payroll_contributions.py @@ -0,0 +1,148 @@ +"""Vectorized and reform invariants for NJ employee UI/workforce contributions. + +YAML covers the individual policy examples. This test checks the simulation's +mixed-state masking, per-person caps within a shared tax unit, and the change +in aggregated employee payroll tax under a two-rate parameter reform. +""" + +import numpy as np + +from policyengine_us import Simulation +from policyengine_us.system import system as SYSTEM + +PERIOD = "2026" +# NJDOL's 2026 worker contribution table, independent of the model parameters. +WAGE_BASE = 44_800 +UI_RATE = 0.003825 +WORKFORCE_RATE = 0.000425 +TOLERANCE = 0.01 + +NJ_WAGES = [-500, 0, 1, WAGE_BASE - 1, WAGE_BASE, WAGE_BASE + 1, 2 * WAGE_BASE] + + +def make_situation(): + workers = [ + {"wages": wages, "state": "NJ", "unit": f"nj_{i}"} + for i, wages in enumerate(NJ_WAGES) + ] + [ + {"wages": WAGE_BASE - 1, "state": "NY", "unit": "ny"}, + {"wages": 2 * WAGE_BASE, "state": "PA", "unit": "pa"}, + {"wages": 2 * WAGE_BASE, "state": "NJ", "unit": "couple"}, + {"wages": 2 * WAGE_BASE, "state": "NJ", "unit": "couple"}, + {"wages": 30_000, "state": "NJ", "unit": "without_premiums"}, + { + "wages": 30_000, + "state": "NJ", + "unit": "with_premiums", + "premiums": 5_000, + }, + ] + situation = { + entity: {} + for entity in ( + "people", + "households", + "tax_units", + "spm_units", + "families", + "marital_units", + ) + } + unit_indices = [] + for i, worker in enumerate(workers): + person = f"person_{i}" + unit = worker["unit"] + new_unit = unit not in situation["tax_units"] + situation["people"][person] = { + "age": {PERIOD: 40}, + "employment_income": {PERIOD: worker["wages"]}, + "pre_tax_health_insurance_premiums": {PERIOD: worker.get("premiums", 0)}, + "is_tax_unit_head": {PERIOD: new_unit}, + "is_tax_unit_spouse": {PERIOD: not new_unit}, + } + for entity in situation: + if entity == "people": + continue + if new_unit: + situation[entity][unit] = {"members": []} + situation[entity][unit]["members"].append(person) + situation["households"][unit]["state_code"] = {PERIOD: worker["state"]} + unit_indices.append(list(situation["tax_units"]).index(unit)) + return situation, workers, np.asarray(unit_indices) + + +def test_nj_worker_contributions_caps_state_masking_and_payroll_reform(): + situation, workers, unit_indices = make_situation() + baseline = Simulation(tax_benefit_system=SYSTEM, situation=situation) + without_contributions = Simulation( + tax_benefit_system=SYSTEM, + situation=situation, + reform={ + "gov.states.nj.tax.payroll.unemployment.employee_rate": {PERIOD: 0}, + "gov.states.nj.tax.payroll.workforce_development.employee_rate": { + PERIOD: 0 + }, + }, + ) + wages = np.array([worker["wages"] for worker in workers]) + in_nj = np.array([worker["state"] == "NJ" for worker in workers]) + expected_base = np.where(in_nj, np.clip(wages, 0, WAGE_BASE), 0) + expected_ui = expected_base * UI_RATE + expected_workforce = expected_base * WORKFORCE_RATE + expected_total = expected_ui + expected_workforce + + def calculate(simulation, variable): + return np.asarray(simulation.calculate(variable, PERIOD)) + + covered_wages = calculate(baseline, "nj_employee_unemployment_taxable_wages") + ui = calculate(baseline, "nj_employee_unemployment_insurance_contribution") + workforce = calculate(baseline, "nj_employee_workforce_fund_contribution") + for actual, expected in ( + (covered_wages, expected_base), + (ui, expected_ui), + (workforce, expected_workforce), + ): + np.testing.assert_allclose(actual, expected, atol=TOLERANCE, rtol=0) + + # Raising one worker's wages cannot lower either contribution, and wages + # above that worker's cap cannot increase the contribution. + for values in (covered_wages, ui, workforce): + assert (np.diff(values[: len(NJ_WAGES)]) >= 0).all() + np.testing.assert_allclose(values[4:7], values[4], atol=TOLERANCE, rtol=0) + + # Both members of one tax unit retain their own wage cap. + np.testing.assert_allclose(covered_wages[9:11], WAGE_BASE, atol=TOLERANCE, rtol=0) + assert unit_indices[9] == unit_indices[10] + + # A federal FICA exclusion must not reduce NJ's covered employee wages. + federal_wages = calculate(baseline, "payroll_tax_gross_wages") + np.testing.assert_allclose( + federal_wages[-2:], [30_000, 25_000], atol=TOLERANCE, rtol=0 + ) + for values in (covered_wages, ui, workforce): + np.testing.assert_allclose(values[-2], values[-1], atol=TOLERANCE, rtol=0) + + # A true rate reform isolates the new contributions from TDI/FLI and + # federal payroll taxes without replacing those variables with inputs. + for variable in ( + "nj_employee_unemployment_insurance_contribution", + "nj_employee_workforce_fund_contribution", + ): + np.testing.assert_allclose( + calculate(without_contributions, variable), 0, atol=TOLERANCE, rtol=0 + ) + np.testing.assert_allclose( + calculate(baseline, "nj_employee_state_payroll_tax") + - calculate(without_contributions, "nj_employee_state_payroll_tax"), + expected_total, + atol=TOLERANCE, + rtol=0, + ) + expected_tax_unit_total = np.bincount(unit_indices, weights=expected_total) + for variable in ("employee_state_payroll_tax", "employee_payroll_tax"): + np.testing.assert_allclose( + calculate(baseline, variable) - calculate(without_contributions, variable), + expected_tax_unit_total, + atol=TOLERANCE, + rtol=0, + ) diff --git a/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py new file mode 100644 index 00000000000..01c54bad117 --- /dev/null +++ b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py @@ -0,0 +1,255 @@ +"""Invariants for the New York itemized medical expense deduction. + +NY Tax Law 615(a) computes New York itemized deductions as federal deductions +existed immediately before Public Law 115-97, so Form IT-196 lines 1-4 keep +the 10% floor of pre-TCJA 26 USC 213(a): line 4 is medical and dental +expenses less 10% of federal AGI. The federal floor has been 7.5% since 2017. +Through 2017, Form IT-201-D line 1 took federal Schedule A line 4, so New +York followed the federal 7.5% floor in 2017. + +Hypothesis draws batches of tax units (single or joint, in New York or +another state, with AGI from negative to several million, medical expenses, +real estate taxes and charitable gifts), and a seeded population of 200 units +adds breadth. Each batch runs as one vectorized simulation, and again with +every head's medical expenses raised by a drawn non-negative amount. For +every tax unit: + +1. Bound: 0 <= ny_medical_expense_deduction <= itemized_medical_expenses, + and it is zero outside New York. +2. Differential: in New York, ny_medical_expense_deduction equals an + independent numpy max(0, expenses - floor * max(AGI, 0)), with the floor + read from the forms (Form IT-196 line 3 from 2018; for 2017, the federal + Schedule A line 3 that IT-201-D line 1 used) rather than from the model's + parameters. +3. Order: it never exceeds the federal medical_expense_deduction. +4. Monotone: raising expenses by d raises it by at least 0 and at most d. +5. Composition: ny_itemized_deductions_max less ny_medical_expense_deduction + does not change when medical expenses change. +6. Line 40: ny_itemized_deductions_phase_out does not change when medical + expenses change, because the medical deduction enters worksheet line 1 + (deductions before the limitation) and line 2 (deductions not subject to + it) alike. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +# The model computes in float32, about 7 significant digits. A difference of +# float32 amounts can be off by a few ulps of its largest operand (half an +# ulp is about 0.03 near 600,000) even when the result is near zero, so each +# comparison also allows RTOL times the largest amount that went into it. +RTOL = 1e-6 + +# Form IT-196 line 3, "Multiply line 2 by 10% (0.10)", on the 2018, 2021, +# 2024 and 2025 forms (tax.ny.gov/pdf//inc/it196__fill_in.pdf). +# For 2017, IT-201-D line 1 took federal Schedule A line 4, and the 2017 +# Schedule A line 3 reads "Multiply line 2 by 7.5% (0.075)". +PUBLISHED_FLOOR = {2017: 0.075, 2018: 0.1, 2021: 0.1, 2024: 0.1, 2025: 0.1} +YEARS = sorted(PUBLISHED_FLOOR) +STATES = ["NY", "NY", "NY", "CA", "TX"] + +agis = st.one_of( + st.integers(-200_000, 0).map(float), + st.integers(0, 500_000).map(float), + st.integers(500_000, 6_000_000).map(float), +) +amounts = st.one_of( + st.just(0.0), + st.integers(1, 60_000).map(float), + st.integers(60_000, 800_000).map(float), +) +increases = st.one_of(st.just(0.0), st.integers(1, 100_000).map(float)) + + +@st.composite +def tax_units(draw): + return { + "joint": draw(st.booleans()), + "state": draw(st.sampled_from(STATES)), + "agi": draw(agis), + "medical": draw(amounts), + "real_estate_taxes": draw(amounts), + "charity": draw(amounts), + "increase": draw(increases), + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def amount(): + return float( + [0, rng.integers(1, 60_001), rng.integers(60_000, 800_001)][ + rng.choice([0, 1, 2]) + ] + ) + + return [ + { + "joint": bool(rng.random() < 0.5), + "state": str(rng.choice(STATES)), + "agi": float( + [ + rng.integers(-200_000, 1), + rng.integers(0, 500_001), + rng.integers(500_000, 6_000_001), + ][rng.choice([0, 1, 2])] + ), + "medical": amount(), + "real_estate_taxes": amount(), + "charity": amount(), + "increase": float(rng.integers(0, 100_001)) if rng.random() < 0.7 else 0.0, + } + for _ in range(n) + ] + + +def _situation(units, year, *, raised): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, unit in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 45, + "is_tax_unit_head": True, + "other_medical_expenses": unit["medical"] + raised * unit["increase"], + "real_estate_taxes": unit["real_estate_taxes"], + "charitable_cash_donations": unit["charity"], + } + members = [head] + if unit["joint"]: + spouse = f"spouse_{i}" + people[spouse] = {"age": 43, "is_tax_unit_spouse": True} + members.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": members} + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "adjusted_gross_income": {year: unit["agi"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: unit["state"]}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, raised=False): + sim = Simulation(situation=_situation(units, year, raised=raised)) + return { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "itemized_medical_expenses", + "adjusted_gross_income", + "medical_expense_deduction", + "ny_medical_expense_deduction", + "ny_itemized_deductions_max", + "ny_itemized_deductions_phase_out", + ] + } + + +def _slack(*operands): + return TOLERANCE + RTOL * np.max(np.abs(np.stack(operands)), axis=0) + + +def _assert_close(actual, desired, slack, what): + off = np.abs(actual - desired) > slack + assert not off.any(), f"{what}: {actual[off]} != {desired[off]}" + + +def _check(units, year): + in_ny = np.array([unit["state"] == "NY" for unit in units]) + base = _run(units, year) + expenses = base["itemized_medical_expenses"] + deduction = base["ny_medical_expense_deduction"] + + agi = np.maximum(base["adjusted_gross_income"], 0) + slack = _slack(expenses, agi) + + # 1. Bounds, and zero outside New York. + assert (deduction >= 0).all() + assert (deduction <= expenses + slack).all() + assert (deduction[~in_ny] == 0).all() + + # 2. Differential against numpy and the published floor. + expected = np.maximum(expenses - PUBLISHED_FLOOR[year] * agi, 0) + _assert_close(deduction[in_ny], expected[in_ny], slack[in_ny], "differential") + + # 3. Never more than the federal deduction, whose floor is no higher. + assert (deduction <= base["medical_expense_deduction"] + slack).all() + + # 4. Monotone, and at most dollar for dollar, in expenses. + raised = _run(units, year, raised=True) + raised_slack = _slack(raised["itemized_medical_expenses"], agi) + increase = raised["itemized_medical_expenses"] - expenses + gain = raised["ny_medical_expense_deduction"] - deduction + assert (gain >= -raised_slack).all() + assert (gain <= increase + raised_slack).all() + + # 5. Medical expenses reach the NY itemized total only through the NY + # medical deduction. + total_slack = _slack( + raised["ny_itemized_deductions_max"], base["ny_itemized_deductions_max"] + ) + _assert_close( + raised["ny_itemized_deductions_max"] - raised["ny_medical_expense_deduction"], + base["ny_itemized_deductions_max"] - deduction, + total_slack, + "composition", + ) + + # 6. The Line 40 limitation is unaffected by medical expenses. + _assert_close( + raised["ny_itemized_deductions_phase_out"], + base["ny_itemized_deductions_phase_out"], + total_slack, + "Line 40", + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def _near_floor(agi, medical): + return { + "joint": False, + "state": "NY", + "agi": float(agi), + "medical": float(medical), + "real_estate_taxes": 0.0, + "charity": 0.0, + "increase": 0.0, + } + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +# Expenses within float32 rounding of a large floor, where a tolerance scaled +# by the result rather than the operands fails (review of #9931). +@example(2025, [_near_floor(5_999_999, 600_000)] * 5) +@example(2025, [_near_floor(5_999_997, 600_000), _near_floor(5_242_881, 524_290)]) +def test_ny_medical_expense_deduction_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + _check(_seeded_units(), year) diff --git a/policyengine_us/tests/core/test_payroll_contributions.py b/policyengine_us/tests/core/test_payroll_contributions.py index cc646ec7975..6939b040785 100644 --- a/policyengine_us/tests/core/test_payroll_contributions.py +++ b/policyengine_us/tests/core/test_payroll_contributions.py @@ -335,8 +335,10 @@ def make_employer_total_simulation( { "nj_employee_temporary_disability_insurance_contribution": 190, "nj_employee_family_leave_insurance_contribution": 230, - "nj_employee_state_payroll_tax": 420, - "employee_state_payroll_tax": 420, + "nj_employee_unemployment_insurance_contribution": 171.36, + "nj_employee_workforce_fund_contribution": 19.04, + "nj_employee_state_payroll_tax": 610.40, + "employee_state_payroll_tax": 610.40, }, id="NJ", ), diff --git a/policyengine_us/tests/core/test_per_capita_uprating.py b/policyengine_us/tests/core/test_per_capita_uprating.py index 89ed3c4ceb7..99a30adf594 100644 --- a/policyengine_us/tests/core/test_per_capita_uprating.py +++ b/policyengine_us/tests/core/test_per_capita_uprating.py @@ -28,6 +28,7 @@ PER_CAPITA_SUFFIX, POPULATION_PATH, _add_per_capita_parameter, + _is_unclassified_calibration_path, add_per_capita_uprating, is_national_total_path, per_capita_path, @@ -36,6 +37,7 @@ SOI_EMPLOYMENT = "calibration.gov.irs.soi.employment_income" CBO_AGI = "calibration.gov.cbo.income_by_source.adjusted_gross_income" CMS_MOOP = "calibration.gov.hhs.cms.moop_per_capita" +CMS_ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" YEARS = (2025, 2030, 2035) BASE_YEAR = 2024 @@ -57,6 +59,7 @@ def _growth(path, year, parameters=None): (CBO_AGI, True), ("calibration.gov.cbo.unemployment_compensation", True), (CMS_MOOP, False), + (CMS_ESI, False), (CPI_U, False), (POPULATION_PATH, False), (per_capita_path(SOI_EMPLOYMENT), False), @@ -88,6 +91,7 @@ def test_no_variable_uprates_by_a_national_total(): ("charitable_cash_donations", per_capita_path(CBO_AGI)), ("miscellaneous_income", per_capita_path(CBO_AGI)), ("other_medical_expenses", CMS_MOOP), + ("employer_sponsored_insurance_premiums", CMS_ESI), ("child_support_expense", CPI_U), ("household_weight", POPULATION_PATH), ], @@ -400,6 +404,34 @@ def test_unclassified_calibration_path_is_rejected(): add_per_capita_uprating(_StubSystem("calibration.gov.aca.spending.state.AL")) +@pytest.mark.parametrize("path", [CMS_MOOP, CMS_ESI]) +def test_per_person_rates_are_classified(path): + """CMS per-capita and per-enrollee series are per-person rates: neither + national totals to divide nor unclassified paths to reject.""" + assert not is_national_total_path(path) + assert not _is_unclassified_calibration_path(path) + + +@pytest.mark.parametrize( + "path, national_total", + [ + # A marker in an ancestor segment does not make a total a rate. + ("calibration.gov.cbo.per_enrollee_estimates.spending_total", True), + ("calibration.gov.irs.soi.per_capita_tables.wages", True), + ("calibration.gov.hhs.per_enrollee_estimates.spending_total", False), + # In the last segment it does, wherever it falls in the name. + ("calibration.gov.cbo.spending_per_enrollee", False), + ("calibration.gov.hhs.medicare.per_capita_cost", False), + ], +) +def test_only_the_last_segment_marks_a_per_person_rate(path, national_total): + assert is_national_total_path(path) is national_total + # Outside the national-total families, an ancestor marker leaves the path + # unclassified, so the build rejects it instead of guessing. + if path.startswith("calibration.gov.hhs.per_enrollee_estimates"): + assert _is_unclassified_calibration_path(path) + + def test_existing_per_capita_series_is_never_overwritten(): """A series someone wrote by hand that happens to share the derived name must stop the build, not be replaced.""" diff --git a/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py new file mode 100644 index 00000000000..2d158a813f6 --- /dev/null +++ b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py @@ -0,0 +1,264 @@ +"""Invariants for pre-tax payroll premiums and Schedule A medical expenses. + +IRC 213(a) allows "the expenses paid during the taxable year, not compensated +for by insurance or otherwise, for medical care", above 7.5% of adjusted gross +income. A premium paid by salary reduction under a section 125 cafeteria plan +is paid by the employer, not the employee, and is excluded from wages under +IRC 106(a) (Rev. Rul. 2002-3), so it is not an expense the taxpayer paid. + +pre_tax_health_insurance_premiums holds those premiums. The other premium +inputs hold premiums not paid that way, and the two are disjoint: a premium is +reported in one or the other, never both. + +Hypothesis draws batches of single and joint filers, each person with wages, +pre-tax premiums, after-tax premiums and other medical expenses. The after-tax +premiums are supplied either directly or as non-Medicare premiums. Each batch +runs as one vectorized simulation, again with every pre-tax premium removed, +and again with every pre-tax premium paid after tax instead. + +The invariants are stated for these filers only: wage earners under 65 who pay +their own costs, with no self-employment (so no self-employed health insurance +deduction), no Medicare premiums and no reimbursed expenses. Dependents are +left out, because whether a dependent's own payments belong on the filer's +Schedule A is a separate question. + +The YAML cases in itemized_medical_expenses.yaml pin hand-computed households. This +file adds what YAML cannot express: relations between paired runs of the same +generated households (invariant 5), and the same checks across a mixed batch +in one vectorized simulation. For every batch: + +1. Exclusion: Schedule A medical expenses equal the filers' after-tax + premiums plus other medical expenses, whatever the pre-tax premiums are. +2. One tax benefit per dollar: each person's wage exclusion is their pre-tax + premiums, up to their wages, and their Schedule A premiums are their + after-tax premiums. The two never cover the same dollar, and together they + never exceed the premiums paid. +3. Differential: the deduction equals an independent numpy calculation, with + the 7.5% floor taken from the statute rather than the parameters. +4. Bounds: 0 <= deduction <= Schedule A medical expenses. +5. Payment method: paying the pre-tax premiums after tax instead raises + Schedule A medical expenses by exactly those premiums and wages by exactly + the wage exclusion, and never lowers the deduction. Removing the pre-tax + premiums leaves Schedule A medical expenses unchanged and never raises the + deduction. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.1 # dollars; incomes are float32 + +# IRC 213(a): "to the extent that such expenses exceed 7.5 percent of +# adjusted gross income". The rate has applied since 2017. +STATUTORY_FLOOR = 0.075 +YEARS = [2024, 2026] + +amounts = st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) +wages = st.one_of(st.just(0.0), st.integers(1, 200_000).map(float)) + + +@st.composite +def people(draw): + return { + "wages": draw(wages), + "pre_tax": draw(amounts), + "after_tax": draw(amounts), + "other": draw(st.one_of(st.just(0.0), st.integers(1, 40_000).map(float))), + # Which input carries the after-tax premiums. + "direct": draw(st.booleans()), + } + + +@st.composite +def tax_units(draw): + return {"head": draw(people()), "spouse": draw(st.none() | people())} + + +def _person(wages=0.0, pre_tax=0.0, after_tax=0.0, other=0.0, direct=False): + return { + "wages": wages, + "pre_tax": pre_tax, + "after_tax": after_tax, + "other": other, + "direct": direct, + } + + +def _situation(units, year, treatment): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + def add(name, values, role): + pre_tax = values["pre_tax"] if treatment == "drawn" else 0 + after_tax = values["after_tax"] + if treatment == "paid_after_tax": + after_tax += values["pre_tax"] + people[name] = { + "age": {"head": 45, "spouse": 43}[role], + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "employment_income": values["wages"], + "pre_tax_health_insurance_premiums": pre_tax, + "health_insurance_premiums": after_tax * values["direct"], + "health_insurance_premiums_without_medicare_part_b": after_tax + * (not values["direct"]), + "other_medical_expenses": values["other"], + # No modeled Medicare Part B premium, so the premiums are only + # the drawn ones. + "medicare_enrolled": False, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head") + members = [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse") + members.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": members} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +PERSON = [ + "employment_income", + "irs_employment_income", + "medical_expense_health_insurance_premiums", +] +TAX_UNIT = [ + "adjusted_gross_income", + "itemized_medical_expenses", + "medical_expense_deduction", +] + + +def _run(units, year, treatment): + sim = Simulation(situation=_situation(units, year, treatment)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in PERSON + TAX_UNIT + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _members(units): + return [ + person + for unit in units + for person in [unit["head"], unit["spouse"]] + if person is not None + ] + + +def _check(units, year): + drawn = _run(units, year, "drawn") + unit = drawn["unit"] + members = _members(units) + pre_tax = np.array([person["pre_tax"] for person in members]) + after_tax = np.array([person["after_tax"] for person in members]) + other = np.array([person["other"] for person in members]) + earned = np.array([person["wages"] for person in members]) + np.testing.assert_array_equal(drawn["employment_income"], earned) + + def unit_sum(values): + return np.bincount(unit, weights=values, minlength=len(units)) + + expenses = drawn["itemized_medical_expenses"] + deduction = drawn["medical_expense_deduction"] + + # 1. Exclusion, against the drawn amounts. + np.testing.assert_allclose(expenses, unit_sum(after_tax + other), atol=TOLERANCE) + + # 2. One tax benefit per dollar. + wage_exclusion = earned - drawn["irs_employment_income"] + schedule_a_premiums = drawn["medical_expense_health_insurance_premiums"] + np.testing.assert_allclose( + wage_exclusion, np.minimum(pre_tax, earned), atol=TOLERANCE + ) + np.testing.assert_allclose(schedule_a_premiums, after_tax, atol=TOLERANCE) + assert ( + wage_exclusion + schedule_a_premiums <= pre_tax + after_tax + TOLERANCE + ).all() + + # 3. Differential against an independent calculation. + agi = drawn["adjusted_gross_income"] + expected = np.maximum( + unit_sum(after_tax + other) - STATUTORY_FLOOR * np.maximum(agi, 0), 0 + ) + np.testing.assert_allclose(deduction, expected, atol=TOLERANCE) + + # 4. Bounds. + assert (deduction >= 0).all() + assert (deduction <= expenses + TOLERANCE).all() + + # 5. Payment method. + removed = _run(units, year, "removed") + np.testing.assert_allclose( + removed["itemized_medical_expenses"], expenses, atol=TOLERANCE + ) + np.testing.assert_allclose(removed["irs_employment_income"], earned, atol=TOLERANCE) + assert (removed["medical_expense_deduction"] <= deduction + TOLERANCE).all() + + paid_after_tax = _run(units, year, "paid_after_tax") + np.testing.assert_allclose( + paid_after_tax["itemized_medical_expenses"] - expenses, + unit_sum(pre_tax), + atol=TOLERANCE, + ) + np.testing.assert_allclose( + paid_after_tax["irs_employment_income"], earned, atol=TOLERANCE + ) + assert (paid_after_tax["medical_expense_deduction"] >= deduction - TOLERANCE).all() + + +# Each example is a batch of filers run three ways, at about 0.1 seconds. +SETTINGS = dict( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +EDGE_CASES = [ + # Nothing paid. + {"head": _person(wages=50_000), "spouse": None}, + # Pre-tax premiums only. + {"head": _person(wages=60_000, pre_tax=2_000, other=9_000), "spouse": None}, + # Pre-tax premiums above wages: the wage exclusion stops at wages. + { + "head": _person(wages=1_000, pre_tax=2_500, after_tax=400, direct=True), + "spouse": None, + }, + # Each spouse pays a different way. + { + "head": _person(wages=50_000, pre_tax=2_400, other=6_000), + "spouse": _person(wages=30_000, after_tax=1_800, other=1_000, direct=True), + }, + # One spouse pays both ways. + { + "head": _person(wages=40_000, pre_tax=600, after_tax=300, other=250), + "spouse": _person(), + }, +] + + +@settings(**SETTINGS) +@example(year=2025, units=EDGE_CASES) +@given( + year=st.sampled_from(YEARS), + units=st.lists(tax_units(), min_size=5, max_size=30), +) +def test_schedule_a_pre_tax_premium_invariants(year, units): + _check(units, year) diff --git a/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py b/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py index 3425312fbf1..bc0d37f1ccc 100644 --- a/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py +++ b/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py @@ -22,18 +22,27 @@ neither the gain nor the QBI deduction. 4. Cap and floor: the deduction is never negative and never above 20% of taxable income less the gain, or the 199A(i) minimum when larger. +5. Gain and deduction ordering: for the same component-derived inputs, + the 199A gain is nonnegative and at least adjusted net capital gain, so + its deduction never exceeds the deduction using the former adjusted-gain + cap, including when the 199A(i) minimum applies. """ import numpy as np from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period from policyengine_us import Simulation +from policyengine_us.variables.gov.irs.income.taxable_income.deductions.qualified_business_income_deduction.qualified_business_income_deduction import ( + qualified_business_income_deduction, +) TOLERANCE = 0.01 # dollars GAIN_INPUTS = [ "long_term_capital_gains", "short_term_capital_gains", + "schedule_d_capital_gain_distributions", "non_sch_d_capital_gains", "qualified_dividend_income", "long_term_capital_gains_on_collectibles", @@ -59,10 +68,14 @@ def _maybe(strategy): @st.composite def person_amounts(draw): - long_term = draw(_maybe(signed)) + distributions = draw(_maybe(st.integers(1, 5_000).map(float))) + # Schedule D line 13 is already inside line 15. The separately stored + # distribution is a memo component, never an additional gain. + long_term = draw(_maybe(signed)) + distributions return { "long_term_capital_gains": long_term, "short_term_capital_gains": draw(_maybe(signed)), + "schedule_d_capital_gain_distributions": distributions, "non_sch_d_capital_gains": draw(_maybe(st.integers(1, 5_000).map(float))), "qualified_dividend_income": draw(_maybe(positive)), # Collectibles gain is part of the long-term gain. @@ -127,7 +140,22 @@ def by_year(values): } -def _run(units, year, **kwargs): +class _AdjustedGainCap: + """Run the production deduction with only its former gain input restored.""" + + def __init__(self, tax_unit): + self.tax_unit = tax_unit + + def __call__(self, variable, formula_period): + if variable == "section_199a_net_capital_gain": + variable = "adjusted_net_capital_gain" + return self.tax_unit(variable, formula_period) + + def __getattr__(self, name): + return getattr(self.tax_unit, name) + + +def _run(units, year, *, compare_legacy=False, **kwargs): sim = Simulation(situation=_situation(units, year, **kwargs)) out = { name: np.asarray(sim.calculate(name, year), dtype=float) @@ -141,6 +169,21 @@ def _run(units, year, **kwargs): out["filer"] = ~np.asarray(sim.calculate("is_tax_unit_dependent", year), bool) out["unit"] = sim.populations["tax_unit"].members_entity_id out["p"] = sim.tax_benefit_system.parameters(year).gov.irs.deductions.qbi + if compare_legacy: + out["adjusted_net_capital_gain"] = np.asarray( + sim.calculate("adjusted_net_capital_gain", year), dtype=float + ) + # Reuse this simulation's population and parameters. This evaluates + # the actual deduction formula, including its floor and filer rules, + # without another model build or a duplicate implementation of QBID. + out["legacy_qualified_business_income_deduction"] = np.asarray( + qualified_business_income_deduction.formula( + _AdjustedGainCap(sim.populations["tax_unit"]), + period(year), + sim.tax_benefit_system.parameters, + ), + dtype=float, + ) return out @@ -150,7 +193,7 @@ def _filer_sum(run, name): def _check(units, year): - run = _run(units, year) + run = _run(units, year, compare_legacy=True) gain = run["section_199a_net_capital_gain"] deduction = run["qualified_business_income_deduction"] @@ -193,6 +236,15 @@ def _check(units, year): assert (deduction >= -TOLERANCE).all() assert (deduction <= ceiling + TOLERANCE).all() + # 5. Preferential-rate exclusions and elections can only reduce the + # former gain input. Replacing it cannot increase the deduction, even + # when the common statutory minimum exceeds either ordinary cap. + assert (gain >= -TOLERANCE).all() + assert (gain + TOLERANCE >= run["adjusted_net_capital_gain"]).all() + assert ( + deduction <= run["legacy_qualified_business_income_deduction"] + TOLERANCE + ).all() + # Each example is one vectorized batch of tax units. SETTINGS = dict( diff --git a/policyengine_us/tests/core/test_spm_system.py b/policyengine_us/tests/core/test_spm_system.py index 388dc55cd84..f746b651c34 100644 --- a/policyengine_us/tests/core/test_spm_system.py +++ b/policyengine_us/tests/core/test_spm_system.py @@ -835,13 +835,13 @@ def test_subsampling_never_changes_which_policy_a_holder_belongs_to( belongs to that policy. The rebuilt baseline arm has no baseline of its own, holds nothing that only the reform defines, and keeps no holder the reform built for its own definition of a variable: here, yearly storage - for the baseline's ``ETERNITY`` ``is_household_head``. That includes what - the reform arm calculated, and core therefore exported as an input, - before subsampling. + for the baseline's ``ETERNITY`` ``is_household_head``. - This compares no values: values the reform arm calculated before - subsampling become inputs of the rebuilt population, in both arms. That - is core's export, not a binding, and is not this invariant. + Core 3.33.1 and later rebuild from recorded inputs without exporting + calculated values as inputs; earlier versions also exported calculated + values. Recalculating after each subsample exercises the reform's own + definitions under either behavior. This compares no values: core's + export behavior is not the policy-binding invariant. """ changed = Microsimulation( dataset=earner_dataset(), @@ -871,13 +871,16 @@ def test_subsampling_never_changes_which_policy_a_holder_belongs_to( for simulation in (changed, baseline): assert_bound_to_own_policy(simulation) if reform == "adds and redefines variables": - # What the reform arm held came through the rebuild as inputs, so - # the baseline branch started out holding the reform's holders. + # Core 3.33.1 and later discard calculated caches when rebuilding; + # earlier versions imported them as inputs. Calculate again so + # both behaviors exercise holders for the reform's definitions. for variable in ("reform_only_income", "is_household_head"): - if ("reform", variable) in calculated: - holder = changed.person._holders[variable] - assert holder.get_known_periods() - assert not holder._memory_storage.is_eternal + changed.calculate(variable, 2024) + holder = changed.get_holder(variable) + assert holder.get_known_periods() + assert not holder._memory_storage.is_eternal + assert_bound_to_own_policy(changed) + assert "reform_only_income" not in baseline.person._holders def test_a_national_selection_records_no_county_input_types(): diff --git a/policyengine_us/tests/core/test_ssi_resource_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_resource_deeming_invariants.py new file mode 100644 index 00000000000..fecded556f1 --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_resource_deeming_invariants.py @@ -0,0 +1,462 @@ +"""Simulation-API invariants for SSI resource deeming (20 CFR 416.1202/1205). + +The complete seven-point Cartesian grids cover single applicants, eligible +couples, applicants with an ineligible spouse, and children with one or two +parents. Each coordinate is one person's resource stock. Seven points include +both federal limits, the individual limit's immediate neighbors, the couple +limit's upper neighbor, zero, and a value above the reformed couple limit. +Another 81 four-axis households exercise sibling reallocation. All 578 +independent households (1,654 people) share one vectorized simulation per +policy: baseline and $10,000/$20,000 limits. + +The grids keep household relationships fixed. Household membership represents +coresidence, including retained membership during a temporary absence under +20 CFR 416.1167. + +YAML covers the source examples. Python is needed here to compare a frozen +prechange formula, check every ordered edge of each Cartesian grid, and compare +independent resource-test oracles using the Simulation API. Those oracles fail +if either deemed-resource formula is removed. A single module fixture builds +two independent simulation caches from the read-only system singleton; it never +constructs a model per grid point. This file routes to the existing Rest/core +Python group in Makefile. Local elapsed time and peak RSS must be reported after +the test run; there is no claim that a local measurement represents Linux CI +cost. +""" + +from copy import deepcopy +from itertools import product + +import numpy as np +import pytest + +from policyengine_core.periods import period as make_period +from policyengine_core.reforms import Reform +from policyengine_us import Simulation +from policyengine_us.system import system + + +YEAR = 2026 +PERIOD = "2026-01" +RESOURCES = (0, 1_999, 2_000, 2_001, 3_000, 3_001, 20_001) +DIMENSIONS = { + "single": 1, + "joint": 2, + "spouse": 2, + "one_parent": 2, + "two_parents": 3, +} +CASE_GRIDS = { + kind: (RESOURCES,) * dimensions for kind, dimensions in DIMENSIONS.items() +} +# Own resources, parent resources, parent-spouse resources, sibling resources. +# The 225/1500/4800 combination is the Goode reallocation example in +# POMS SI 01330.280: sibling2400 fails, then claimant2025 fails. +CASE_GRIDS["two_children"] = ( + (0, 225, 2_001), + (0, 3_000, 4_800), + (0, 1_000, 3_001), + (0, 500, 1_500), +) +GRID_SHAPES = { + kind: tuple(len(axis) for axis in axes) for kind, axes in CASE_GRIDS.items() +} +GRID_SHAPES["two_children_sibling"] = GRID_SHAPES["two_children"] + + +class HigherSSIResourceLimits(Reform): + def apply(self): + def modifier(parameters): + limits = parameters.gov.ssa.ssi.eligibility.resources.limit + active = make_period(f"year:{YEAR}:10") + limits.individual.update(period=active, value=10_000) + limits.couple.update(period=active, value=20_000) + return parameters + + self.modify_parameters(modifier) + + +def _situation(): + situation = { + plural: {} + for plural in ( + "people", + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ) + } + claimant_positions = {kind: [] for kind in GRID_SHAPES} + no_deemor_positions = [] + frozen_own = [] + frozen_joint = [] + frozen_marital_group = [] + marital_group_index = 0 + for kind, axes in CASE_GRIDS.items(): + for case_index, amounts in enumerate(product(*axes)): + prefix = f"{kind}_{case_index}" + child_case = kind in ("one_parent", "two_parents", "two_children") + if child_case: + roles = ["parent"] + if kind in ("two_parents", "two_children"): + roles.append("parent_spouse") + roles.append("child") + amount_by_role = dict(zip(["child", *roles[:-1]], amounts)) + if kind == "two_children": + roles.append("sibling") + amount_by_role["sibling"] = amounts[-1] + claimant_role = "child" + else: + roles = ["head"] + ([] if kind == "single" else ["spouse"]) + amount_by_role = dict(zip(roles, amounts)) + claimant_role = "head" + members = [f"{prefix}_{role}" for role in roles] + for role, member in zip(roles, members): + position = len(situation["people"]) + is_child = role in ("child", "sibling") + is_head = role in ("head", "parent") + is_spouse = role in ("spouse", "parent_spouse") + abd = is_child or role == "head" or (kind == "joint") + situation["people"][member] = { + "age": {YEAR: 10 if is_child else 40}, + "is_ssi_aged_blind_disabled": {YEAR: abd}, + "is_parent": {YEAR: role in ("parent", "parent_spouse")}, + "is_tax_unit_head": {YEAR: is_head}, + "is_tax_unit_spouse": {YEAR: is_spouse}, + "is_tax_unit_dependent": {YEAR: is_child}, + "is_household_head": {YEAR: is_head}, + "ssi_countable_resources": {YEAR: amount_by_role[role]}, + } + frozen_own.append(amount_by_role[role]) + frozen_joint.append(kind == "joint") + frozen_marital_group.append( + marital_group_index + + (2 if role == "sibling" else 1 if is_child else 0) + ) + if role == claimant_role: + claimant_positions[kind].append(position) + if role == "sibling": + claimant_positions["two_children_sibling"].append(position) + if kind in ("single", "joint"): + no_deemor_positions.append(position) + for plural in ("tax_units", "households", "families", "spm_units"): + situation[plural][prefix] = {"members": members} + if child_case: + child_count = 2 if kind == "two_children" else 1 + situation["marital_units"][f"{prefix}_parents"] = { + "members": members[:-child_count] + } + for child_index, member in enumerate(members[-child_count:]): + situation["marital_units"][f"{prefix}_child_{child_index}"] = { + "members": [member] + } + marital_group_index += 1 + child_count + else: + situation["marital_units"][prefix] = {"members": members} + marital_group_index += 1 + frozen = { + "own": np.array(frozen_own, dtype=float), + "joint": np.array(frozen_joint, dtype=bool), + "marital_group": np.array(frozen_marital_group), + "no_deemor": np.array(no_deemor_positions), + } + return situation, claimant_positions, frozen + + +def _frozen_old_formula(frozen, individual_limit, couple_limit): + """Exact numpy translation of the formula frozen at commit 00968dee79. + + countable = where(joint_claim, marital_unit.sum(own), own) + limit = where(joint_claim, couple, individual) + return countable <= limit + + The inputs are constructed independently of the new deemed-resource + formulas. No current deeming output enters this reference calculation. + """ + marital_totals = np.bincount(frozen["marital_group"], weights=frozen["own"]) + countable = np.where( + frozen["joint"], + marital_totals[frozen["marital_group"]], + frozen["own"], + ) + limits = np.where(frozen["joint"], couple_limit, individual_limit) + return countable <= limits + + +def _redistributed_child_passes(own_resources, parental_excess, individual_limit): + """Independent SI 01330.200 B equal-division eligibility calculation. + + Start with children whose own resources pass. Repeatedly divide the excess + among the remaining children and remove every child who fails that share. + This oracle needs neither a deeming amount nor the model's entity helpers. + """ + own_resources = np.asarray(own_resources, dtype=float) + eligible = own_resources <= individual_limit + while np.any(eligible): + share = parental_excess / np.count_nonzero(eligible) + remaining = eligible & (own_resources + share <= individual_limit) + if np.array_equal(remaining, eligible): + break + eligible = remaining + return eligible + + +def _resource_test_oracles(individual_limit, couple_limit): + """Closed-form references built only from the resource coordinates/limits.""" + expected = {} + own, spouse = np.meshgrid(*CASE_GRIDS["spouse"], indexing="ij") + expected["spouse"] = own + spouse <= couple_limit + for kind, allowance in ( + ("one_parent", individual_limit), + ("two_parents", couple_limit), + ): + own, *parents = np.meshgrid(*CASE_GRIDS[kind], indexing="ij") + parental_excess = np.maximum(0, np.sum(parents, axis=0) - allowance) + expected[kind] = own + parental_excess <= individual_limit + + children = [] + for own, parent, parent_spouse, sibling in product(*CASE_GRIDS["two_children"]): + children.append( + _redistributed_child_passes( + [own, sibling], + max(0, parent + parent_spouse - couple_limit), + individual_limit, + ) + ) + children = np.asarray(children) + expected["two_children"] = children[:, 0].reshape(GRID_SHAPES["two_children"]) + expected["two_children_sibling"] = children[:, 1].reshape( + GRID_SHAPES["two_children_sibling"] + ) + return expected + + +@pytest.fixture(scope="module") +def resource_grids(): + situation, positions, frozen = _situation() + policies = (system, HigherSSIResourceLimits(system)) + snapshots = [] + for policy in policies: + simulation = Simulation( + tax_benefit_system=policy, situation=deepcopy(situation) + ) + passes = np.asarray(simulation.calculate("meets_ssi_resource_test", PERIOD)) + limits = policy.parameters(PERIOD).gov.ssa.ssi.eligibility.resources.limit + snapshots.append( + { + "passes": passes, + "limits": (limits.individual, limits.couple), + "grids": { + kind: passes[indices].reshape(GRID_SHAPES[kind]) + for kind, indices in positions.items() + }, + "deemed": { + name: np.asarray(simulation.calculate(name, PERIOD)) + for name in ( + "ssi_resources_for_deeming", + "ssi_resources_deemed_from_ineligible_spouse", + "ssi_resources_deemed_from_ineligible_parent", + ) + }, + "old": _frozen_old_formula(frozen, limits.individual, limits.couple), + } + ) + # Only immutable result arrays survive; each policy has its own cache. + del simulation + return snapshots, frozen["no_deemor"] + + +def test_no_deemor_matches_frozen_old_formula(resource_grids): + snapshots, no_deemor = resource_grids + for snapshot in snapshots: + np.testing.assert_array_equal( + snapshot["passes"][no_deemor], snapshot["old"][no_deemor] + ) + + +def test_resource_grids_match_independent_oracles(resource_grids): + snapshots, _ = resource_grids + for snapshot in snapshots: + for kind, expected in _resource_test_oracles(*snapshot["limits"]).items(): + np.testing.assert_array_equal( + snapshot["grids"][kind], expected, err_msg=kind + ) + + +def test_resource_and_limit_monotonicity(resource_grids): + snapshots, _ = resource_grids + for snapshot in snapshots: + for kind, grid in snapshot["grids"].items(): + # Every adjacent ordered resource value, along every resident's + # resource axis, must preserve a failure. Adjacent comparisons + # imply the property for every larger value in the finite grid. + for axis in range(grid.ndim): + assert np.all(np.diff(grid.astype(int), axis=axis) <= 0), ( + kind, + axis, + ) + baseline, higher_limits = snapshots + assert np.all(~baseline["passes"] | higher_limits["passes"]) + # Ensure the grids exercise both outcomes and a real reform transition. + assert np.any(baseline["passes"]) and np.any(~baseline["passes"]) + assert np.any(baseline["passes"] != higher_limits["passes"]) + assert np.any(~higher_limits["passes"]) + + +def test_deemed_resources_are_nonnegative(resource_grids): + snapshots, _ = resource_grids + for snapshot in snapshots: + for name, amounts in snapshot["deemed"].items(): + assert np.all(np.isfinite(amounts)), name + assert np.all(amounts >= 0), name + + +def test_resource_test_does_not_compute_filing_status(): + """SSI eligibility must not depend on the tax-filing chain. + + Deeming establishes a parent's spouse from tax-unit roles, not from + filing_status, whose formula reaches dependents' gross income and + retirement-contribution limits that do not exist in every year. + """ + situation = { + "people": { + "parent": {"age": 40, "bank_account_assets": 5_000}, + "stepparent": {"age": 41}, + "child": {"age": 8, "is_blind": True}, + }, + "tax_units": {"tax_unit": {"members": ["parent", "stepparent", "child"]}}, + "marital_units": { + "couple": {"members": ["parent", "stepparent"]}, + "child_unit": {"members": ["child"]}, + }, + "families": {"family": {"members": ["parent", "stepparent", "child"]}}, + "spm_units": {"spm_unit": {"members": ["parent", "stepparent", "child"]}}, + "households": {"household": {"members": ["parent", "stepparent", "child"]}}, + } + simulation = Simulation(situation=situation) + simulation.calculate("meets_ssi_resource_test", PERIOD) + assert simulation.get_holder("filing_status").get_known_periods() == [] + + +POLICIES = {"baseline": system, "higher_limits": HigherSSIResourceLimits(system)} + + +def _limits(policy): + limits = policy.parameters(PERIOD).gov.ssa.ssi.eligibility.resources.limit + return limits.individual, limits.couple + + +OTHER_ROLES = ("head_of_own_unit", "dependent", "tax_spouse_role") + + +@pytest.mark.parametrize("policy_name", list(POLICIES)) +def test_co_resident_adult_in_own_marital_unit_is_not_a_spouse(policy_name): + """No deemor: equals the prechange own-resource test. + + Another adult in the household with their own marital unit is never a + spouse, whether they head another tax unit, are the claimant's + dependent, or hold a tax-spouse role, whatever they own. + """ + policy = POLICIES[policy_name] + individual, _ = _limits(policy) + situation = { + plural: {} for plural in ("people", "tax_units", "marital_units", "households") + } + expected = [] + for index, (claimant, other, role) in enumerate( + product(RESOURCES, (0, 20_001), OTHER_ROLES) + ): + names = (f"claimant_{index}", f"other_{index}") + situation["people"][names[0]] = { + "age": {YEAR: 70}, + "is_ssi_aged_blind_disabled": {YEAR: True}, + "is_tax_unit_head": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "is_tax_unit_dependent": {YEAR: False}, + "ssi_countable_resources": {YEAR: claimant}, + } + situation["people"][names[1]] = { + "age": {YEAR: 40}, + "is_ssi_aged_blind_disabled": {YEAR: False}, + "is_tax_unit_head": {YEAR: role == "head_of_own_unit"}, + "is_tax_unit_spouse": {YEAR: role == "tax_spouse_role"}, + "is_tax_unit_dependent": {YEAR: role == "dependent"}, + "ssi_countable_resources": {YEAR: other}, + } + if role == "head_of_own_unit": + situation["tax_units"][f"claimant_unit_{index}"] = {"members": [names[0]]} + situation["tax_units"][f"other_unit_{index}"] = {"members": [names[1]]} + else: + situation["tax_units"][f"unit_{index}"] = {"members": list(names)} + for name in names: + situation["marital_units"][f"{name}_unit"] = {"members": [name]} + situation["households"][f"home_{index}"] = {"members": list(names)} + expected.append(claimant <= individual) + simulation = Simulation(tax_benefit_system=policy, situation=situation) + passes = np.asarray(simulation.calculate("meets_ssi_resource_test", PERIOD)) + np.testing.assert_array_equal(passes[::2], np.array(expected)) + + +@pytest.mark.parametrize("policy_name", list(POLICIES)) +def test_parent_spouse_in_another_tax_unit_matches_oracle(policy_name): + """A parent's partner counts when they share a two-person marital unit. + + The partner files separately; either the parent or the partner claims + the disabled child. Sharing the parent's marital unit makes the partner + the parent's spouse, so both are deemors against the couple allowance + wherever each files. In their own marital unit the partner is not a + deemor, and a child claimed by the partner then has none (416.1202(b)(1)). + """ + policy = POLICIES[policy_name] + individual, couple = _limits(policy) + situation = { + plural: {} for plural in ("people", "tax_units", "marital_units", "households") + } + expected = [] + child_rows = [] + grid = product( + (0, 225, 2_001), + RESOURCES, + (0, 1_000, 3_001, 20_001), + (True, False), + ("parent", "partner"), + ) + for index, (child, parent, partner, married, claimant) in enumerate(grid): + names = [f"{role}_{index}" for role in ("parent", "partner", "child")] + amounts = (parent, partner, child) + for role, name, amount in zip(("parent", "partner", "child"), names, amounts): + situation["people"][name] = { + "age": {YEAR: 10 if role == "child" else 40}, + "is_ssi_aged_blind_disabled": {YEAR: role == "child"}, + "is_parent": {YEAR: role == "parent"}, + "is_tax_unit_head": {YEAR: role != "child"}, + "is_tax_unit_spouse": {YEAR: False}, + "is_tax_unit_dependent": {YEAR: role == "child"}, + "ssi_countable_resources": {YEAR: amount}, + } + claimer = names[0] if claimant == "parent" else names[1] + other = names[1] if claimant == "parent" else names[0] + situation["tax_units"][f"claiming_unit_{index}"] = { + "members": [claimer, names[2]] + } + situation["tax_units"][f"other_unit_{index}"] = {"members": [other]} + if married: + situation["marital_units"][f"parents_{index}"] = {"members": names[:2]} + else: + for name in names[:2]: + situation["marital_units"][f"{name}_unit"] = {"members": [name]} + situation["marital_units"][f"child_{index}"] = {"members": [names[2]]} + situation["households"][f"home_{index}"] = {"members": names} + child_rows.append(3 * index + 2) + if married: + deemors, allowance = parent + partner, couple + elif claimant == "parent": + deemors, allowance = parent, individual + else: + deemors, allowance = 0, individual + expected.append(child + max(0, deemors - allowance) <= individual) + simulation = Simulation(tax_benefit_system=policy, situation=situation) + passes = np.asarray(simulation.calculate("meets_ssi_resource_test", PERIOD)) + np.testing.assert_array_equal(passes[child_rows], np.array(expected)) diff --git a/policyengine_us/tests/core/test_state_estate_income_invariants.py b/policyengine_us/tests/core/test_state_estate_income_invariants.py new file mode 100644 index 00000000000..2cb59ded568 --- /dev/null +++ b/policyengine_us/tests/core/test_state_estate_income_invariants.py @@ -0,0 +1,177 @@ +"""Invariants for estate and trust income in state-built income concepts. + +Six states build an income concept from their own source list rather than +from federal AGI, and each state's form puts a beneficiary's estate or trust +income on a line the list already covers: + +- Alabama AGI: Form 40, Part I, line 5. Code of Ala. § 40-18-25(b) follows + federal Subchapter J for a beneficiary's income and deductions, so the + Schedule E amount enters signed. +- Iowa gross income, tax years 2021 and 2022: IA 1040 line 10, "the income or + loss from federal Schedule E". +- Mississippi AGI: Form 80-105 line 41, "the income or loss from activities + reported on Federal Schedule E". +- New Jersey gross income: N.J.S. 54A:5-1(h), a category of its own, so + N.J.S. 54A:5-2 disregards a net loss. +- New Mexico modified gross income: NMSA 1978 § 7-2-2(L)(15), "undiminished + by losses". +- Oklahoma gross household income: Forms 538-S and 538-H line 11, "gross + (positive) income". + +Hypothesis draws batches of single filers with wages, interest, partnership +income of either sign and an estate or trust amount of either sign; a seeded +population adds breadth. Each batch runs as one vectorized simulation holding +three copies of every filer in every state: no estate income, the estate +amount, and the same amount entered as rental income instead. For every filer: + +1. Signed pass-through (Alabama, Iowa, Mississippi): the concept moves by + exactly the estate amount, loss or gain. +2. Floored pass-through (New Jersey, New Mexico, Oklahoma): the concept moves + by exactly max(estate amount, 0), so a loss never lowers it and never + offsets wages, interest or partnership income. +3. Differential against the existing rental line: the estate amount moves the + concept exactly as the same amount of rental income does, loss or gain. + Each state puts both on the same form line or under the same loss rule. + +The identities hold for this income mix. The federal EITC is set to zero +because Oklahoma counts it (Form 538-S line 9) and an estate loss changes it. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# (state, variable, year, whether an estate or trust loss passes through) +CONCEPTS = [ + ("AL", "al_agi", 2025, True), + ("IA", "ia_gross_income", 2022, True), + ("MS", "ms_agi", 2025, True), + ("NJ", "nj_gross_income", 2025, False), + ("NM", "nm_modified_gross_income", 2025, False), + ("OK", "ok_gross_income", 2025, False), +] +YEARS = sorted({year for _, _, year, _ in CONCEPTS}) +VARIANTS = ("none", "estate", "rental") + +amounts = st.one_of( + st.just(0.0), + st.integers(-200_000, -1).map(float), + st.integers(1, 400_000).map(float), +) + + +@st.composite +def filers(draw): + return { + "wages": float(draw(st.integers(0, 300_000))), + "interest": float(draw(st.integers(0, 40_000))), + "partnership": float(draw(st.integers(-50_000, 100_000))), + "estate": draw(amounts), + } + + +SEED = 20261010 + + +def _seeded_filers(n=60): + rng = np.random.default_rng(SEED) + drawn = [ + { + "wages": float(rng.integers(0, 300_001)), + "interest": float(rng.integers(0, 40_001)), + "partnership": float(rng.integers(-50_000, 100_001)), + "estate": float( + rng.choice( + [0, rng.integers(-200_000, 0), rng.integers(1, 400_001)], + p=[0.1, 0.4, 0.5], + ) + ), + } + for _ in range(n) + ] + edges = [ + # Nothing but an estate or trust loss. + {"wages": 0.0, "interest": 0.0, "partnership": 0.0, "estate": -40_000.0}, + # Nothing but estate or trust income. + {"wages": 0.0, "interest": 0.0, "partnership": 0.0, "estate": 25_000.0}, + # An estate loss beside partnership income of the same size. + {"wages": 50_000.0, "interest": 0.0, "partnership": 8_000.0, "estate": -8_000.0}, + # An estate gain beside a partnership loss of the same size. + {"wages": 50_000.0, "interest": 0.0, "partnership": -8_000.0, "estate": 8_000.0}, + ] # fmt: skip + return drawn + edges + + +def _situation(units): + def every_year(value): + return {year: value for year in YEARS} + + people, tax_units, households = {}, {}, {} + for state, _, _, _ in CONCEPTS: + for variant in VARIANTS: + for i, unit in enumerate(units): + name = f"{state}_{variant}_{i}" + people[name] = { + "age": every_year(45), + "employment_income": every_year(unit["wages"]), + "taxable_interest_income": every_year(unit["interest"]), + "partnership_s_corp_income": every_year(unit["partnership"]), + "estate_income": every_year( + unit["estate"] if variant == "estate" else 0.0 + ), + "rental_income": every_year( + unit["estate"] if variant == "rental" else 0.0 + ), + } + tax_units[name] = {"members": [name], "eitc": every_year(0)} + households[name] = { + "members": [name], + "state_code": every_year(state), + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def _check(units): + sim = Simulation(situation=_situation(units)) + n = len(units) + estate = np.array([unit["estate"] for unit in units]) + for index, (state, variable, year, loss_passes_through) in enumerate(CONCEPTS): + # One person per tax unit and household, in insertion order, so a + # person-level and a tax-unit-level concept index the same way. + values = np.asarray(sim.calculate(variable, year), dtype=float) + start = index * len(VARIANTS) * n + none, with_estate, with_rental = ( + values[start + k * n : start + (k + 1) * n] for k in range(len(VARIANTS)) + ) + expected = estate if loss_passes_through else np.maximum(estate, 0) + + # 1 and 2. Pass-through, signed or floored. + np.testing.assert_allclose( + with_estate - none, expected, atol=TOLERANCE, err_msg=f"{state} {variable}" + ) + + # 3. Differential against the rental line. + np.testing.assert_allclose( + with_estate, + with_rental, + atol=TOLERANCE, + err_msg=f"{state} {variable} against rental income", + ) + + +@settings( + max_examples=4, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(filers(), min_size=1, max_size=10)) +def test_state_estate_income_invariants(units): + _check(units) + + +def test_state_estate_income_invariants_seeded_population(): + _check(_seeded_filers()) diff --git a/policyengine_us/tests/core/test_tax_unit_non_dep_add_projector.py b/policyengine_us/tests/core/test_tax_unit_non_dep_add_projector.py index 6b72c37480f..c933771d886 100644 --- a/policyengine_us/tests/core/test_tax_unit_non_dep_add_projector.py +++ b/policyengine_us/tests/core/test_tax_unit_non_dep_add_projector.py @@ -65,7 +65,7 @@ def simulation(): sim.set_input(name, year, np.zeros(sim.populations[variable.entity.key].count)) sim.set_input("irs_employment_income", year, [1_000, 500, 0, 1_500, 0]) sim.set_input("loss_ald", year, [10, 20]) - sim.set_input("traditional_ira_contributions", year, [1, 2, 900, 3, 800]) + sim.set_input("traditional_ira_deduction", year, [1, 2, 900, 3, 800]) sim.set_input("student_loan_interest_ald_eligible", year, [True] * len(names)) sim.set_input("taxable_public_pension_income", year, [100, 20, 900, 200, 800]) sim.set_input("tax_unit_social_security", year, [5, 10]) diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index 01b5a7e01b7..fb7d4e7aec6 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -1,6 +1,7 @@ """Each dollar input follows the series closest to what it measures: its own national series, the income source it is part of, prices for consumption -expenses and debts, CMS per-capita spending for health costs.""" +expenses and debts, CMS per-capita spending for health costs and CMS premiums +per enrollee for employer plan premiums.""" from pathlib import Path @@ -18,6 +19,7 @@ UNEMPLOYMENT = per_capita_path("calibration.gov.irs.soi.unemployment_compensation") DEFAULT = per_capita_path("calibration.gov.cbo.income_by_source.adjusted_gross_income") CMS = "calibration.gov.hhs.cms.moop_per_capita" +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" COLA = "gov.ssa.uprating" @@ -60,6 +62,10 @@ "unemployment_compensation": UNEMPLOYMENT, # Health costs. "health_insurance_premiums": CMS, + # Employer plan premiums: the employer's share and the employee's pre-tax + # share. + "employer_sponsored_insurance_premiums": ESI, + "pre_tax_health_insurance_premiums": ESI, # Benefits adjusted by the Social Security cost-of-living index. "veterans_benefits": COLA, # Consumption expenses, and the interest on debts, follow prices. The diff --git a/policyengine_us/tests/core/test_wv_senior_citizen_disability_deduction.py b/policyengine_us/tests/core/test_wv_senior_citizen_disability_deduction.py new file mode 100644 index 00000000000..359eed4a6d0 --- /dev/null +++ b/policyengine_us/tests/core/test_wv_senior_citizen_disability_deduction.py @@ -0,0 +1,178 @@ +"""Hypothesis coverage of Schedule M's person-level subtraction limits. + +The 2025 IT-140 Schedule M line 47, boxes (c) and (d), first caps the income +remaining after lines 35-46 at $8,000, then subtracts the person's own +lines 29-34 modifications. These properties exercise combinations that the +worked YAML examples do not enumerate, using the real model and independent +conservation and spouse-isolation checks. + +The form interpretation includes line 39's deductible 529 contribution in +the excluded Schedule M amounts: it removes income already deducted on that +line from the remainder in box (c), although a contribution is not an income +receipt. Compatibility with the old formula therefore means no excluded +Schedule M amounts, including no deductible 529 contribution. + +Fresh two- or four-person simulations reuse the package's reference model; +they do not share simulation caches, mutate parameters, or load datasets. +""" + +import numpy as np +from hypothesis import example, given, settings, strategies as st + +from policyengine_us import Simulation + +YEAR = 2025 +CAP = 8_000 +DEDUCTION = "wv_senior_citizen_disability_deduction_person" +MODIFICATION_SOURCES = ( + "us_govt_interest_person", + "military_retirement_pay", + "wv_public_pension_subtraction_person", + "wv_social_security_benefits_subtraction_person", +) +EXCLUDED_SOURCES = ( + "military_service_income", + "salt_refund_income", + "investment_in_529_plan_indv", +) + +AGIS = st.integers(min_value=-20_000, max_value=60_000) +AMOUNTS = st.integers(min_value=0, max_value=20_000) +MODIFICATIONS = st.tuples(*(AMOUNTS for _ in MODIFICATION_SOURCES)) +EXCLUSIONS = st.tuples(*(AMOUNTS for _ in EXCLUDED_SOURCES)) +PERSON = st.tuples(AGIS, MODIFICATIONS, EXCLUSIONS) +COUPLE = st.tuples(PERSON, PERSON) +PROPERTY_SETTINGS = settings(max_examples=50, deadline=None, derandomize=True) +ZERO_MODIFICATIONS = (0, 0, 0, 0) +ZERO_EXCLUSIONS = (0, 0, 0) +LOW_INCOME_COUPLE = ( + (6_000, ZERO_MODIFICATIONS, ZERO_EXCLUSIONS), + (6_000, ZERO_MODIFICATIONS, ZERO_EXCLUSIONS), +) + + +def _simulation(*couples): + """Build independent joint returns, preserving each source's ownership.""" + people, tax_units, households = {}, {}, {} + for couple_index, couple in enumerate(couples): + members = [] + for person_index, (agi, modifications, exclusions) in enumerate(couple): + name = f"person_{couple_index}_{person_index}" + members.append(name) + values = { + "age": {YEAR: 66}, + "adjusted_gross_income_person": {YEAR: agi}, + "is_tax_unit_head": {YEAR: person_index == 0}, + "is_tax_unit_spouse": {YEAR: person_index == 1}, + } + for source, amount in zip(MODIFICATION_SOURCES, modifications): + values[source] = {YEAR: amount} + for source, amount in zip(EXCLUDED_SOURCES, exclusions): + values[source] = {YEAR: amount} + people[name] = values + tax_units[f"tax_unit_{couple_index}"] = { + "members": members, + "filing_status": {YEAR: "JOINT"}, + } + households[f"household_{couple_index}"] = { + "members": members, + "state_code": {YEAR: "WV"}, + } + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + } + ) + + +@PROPERTY_SETTINGS +@given(couple=COUPLE) +def test_each_persons_modification_is_between_zero_and_cap(couple): + deduction = _simulation(couple).calculate(DEDUCTION, YEAR) + assert np.all(deduction >= 0) + assert np.all(deduction <= CAP) + + +@PROPERTY_SETTINGS +@given( + agis=st.tuples(AGIS, AGIS), + modifications=st.tuples(MODIFICATIONS, MODIFICATIONS), +) +def test_without_excluded_amounts_matches_previous_formula(agis, modifications): + couple = tuple( + (agi, person_modifications, ZERO_EXCLUSIONS) + for agi, person_modifications in zip(agis, modifications) + ) + deduction = _simulation(couple).calculate(DEDUCTION, YEAR) + # The pre-fix formula is a compatibility reference only for this domain. + previous = np.maximum( + 0, + np.minimum(CAP, np.asarray(agis)) - np.sum(modifications, axis=1), + ) + np.testing.assert_array_equal(deduction, previous) + + +@PROPERTY_SETTINGS +@given(couple=COUPLE) +@example( + couple=( + (6_000, ZERO_MODIFICATIONS, (0, 2_000, 0)), + (6_000, ZERO_MODIFICATIONS, ZERO_EXCLUSIONS), + ) +) +@example( + couple=( + (6_000, ZERO_MODIFICATIONS, (2_000, 0, 0)), + (6_000, ZERO_MODIFICATIONS, ZERO_EXCLUSIONS), + ) +) +@example( + couple=( + (6_000, ZERO_MODIFICATIONS, (0, 0, 2_000)), + (6_000, ZERO_MODIFICATIONS, ZERO_EXCLUSIONS), + ) +) +def test_senior_and_other_subtractions_do_not_exceed_income_or_other_subtractions( + couple, +): + deduction = _simulation(couple).calculate(DEDUCTION, YEAR) + # Sum each person's independently supplied lines 29-46 amounts, without + # using the deduction formula or its intermediate calculated totals. + subtractions = np.array( + [ + sum(modifications) + sum(exclusions) + for _, modifications, exclusions in couple + ] + ) + agis = np.array([agi for agi, _, _ in couple]) + assert np.all(deduction + subtractions <= np.maximum(agis, subtractions)) + + +@PROPERTY_SETTINGS +@given( + couple=COUPLE, + changed_person=st.integers(min_value=0, max_value=1), + source_index=st.integers(min_value=0, max_value=len(EXCLUDED_SOURCES) - 1), + increase=st.integers(min_value=1, max_value=8_000), +) +@example(couple=LOW_INCOME_COUPLE, changed_person=0, source_index=0, increase=2_000) +@example(couple=LOW_INCOME_COUPLE, changed_person=1, source_index=0, increase=2_000) +@example(couple=LOW_INCOME_COUPLE, changed_person=0, source_index=1, increase=2_000) +@example(couple=LOW_INCOME_COUPLE, changed_person=1, source_index=1, increase=2_000) +@example(couple=LOW_INCOME_COUPLE, changed_person=0, source_index=2, increase=2_000) +@example(couple=LOW_INCOME_COUPLE, changed_person=1, source_index=2, increase=2_000) +def test_changing_one_spouses_excluded_amount_preserves_the_others_modification( + couple, changed_person, source_index, increase +): + agi, modifications, exclusions = couple[changed_person] + changed_exclusions = list(exclusions) + changed_exclusions[source_index] += increase + changed_couple = list(couple) + changed_couple[changed_person] = (agi, modifications, tuple(changed_exclusions)) + # Paired joint returns in one simulation also check vectorized isolation. + deduction = _simulation(couple, changed_couple).calculate(DEDUCTION, YEAR) + other_person = 1 - changed_person + assert deduction[other_person] == deduction[2 + other_person] + assert deduction[2 + changed_person] <= deduction[changed_person] diff --git a/policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py b/policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py new file mode 100644 index 00000000000..d34e1d08c61 --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py @@ -0,0 +1,130 @@ +"""The engine ages employer plan premium inputs by CMS's employer-sponsored +insurance premium per enrollee, in a simulation and in dataset extension. + +``tests/core/test_esi_premium_uprating.py`` checks the series itself and that +both inputs name it. This file checks the distinct failure that a parameter +read cannot: that holder uprating in a ``Simulation`` and +``extend_single_year_dataset`` resolve the per-enrollee path undivided by +population (``tools/per_capita_uprating.py`` divides national totals) and +apply it to these inputs. + +Invariants: + +1. Uprating: a premium entered for 2024 equals that amount times the index + ratio in any later year, for both inputs, so uprating is linear in the + amount (Hypothesis, one vectorized simulation per example). +2. Dataset extension: each person's amount follows the index ratio, and the + weighted total grows by the index ratio times population growth, because + weights follow population. +""" + +from functools import lru_cache + +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us import Simulation +from policyengine_us.data.dataset_schema import USSingleYearDataset +from policyengine_us.data.economic_assumptions import extend_single_year_dataset +from policyengine_us.system import system +from policyengine_us.tools.per_capita_uprating import POPULATION_PATH + +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +EMPLOYER, EMPLOYEE = ( + "employer_sponsored_insurance_premiums", + "pre_tax_health_insurance_premiums", +) +BASE_YEAR, END_YEAR = 2024, 2100 + + +def _ratio(path, year): + parameter = get_parameter(system.parameters, path) + return parameter(f"{year}-01-01") / parameter(f"{BASE_YEAR}-01-01") + + +# Each example builds one simulation holding a batch of people. +@settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + st.lists( + st.tuples( + st.integers(0, 5_000_000).map(lambda cents: cents / 100), + st.integers(0, 2_000_000).map(lambda cents: cents / 100), + ), + min_size=2, + max_size=8, + ), + st.lists(st.integers(BASE_YEAR + 1, END_YEAR), min_size=2, max_size=4), +) +def test_simulation_uprates_premiums_by_the_index_ratio(amounts, years): + people = { + f"person_{i}": { + "age": {BASE_YEAR: 40}, + EMPLOYER: {BASE_YEAR: employer}, + EMPLOYEE: {BASE_YEAR: employee}, + } + for i, (employer, employee) in enumerate(amounts) + } + simulation = Simulation( + situation={ + "people": people, + "households": {"household": {"members": list(people)}}, + } + ) + for year in years: + for column, variable in enumerate((EMPLOYER, EMPLOYEE)): + values = simulation.calculate(variable, year) + for value, pair in zip(values, amounts): + assert value == pytest.approx( + pair[column] * _ratio(ESI, year), rel=1e-6, abs=0.01 + ) + + +@lru_cache(maxsize=1) +def _extended(): + person = pd.DataFrame( + { + "person_id": [1, 2, 3, 4], + "person_household_id": [1, 1, 2, 3], + EMPLOYER: [14_000.0, 0.0, 6_500.0, 0.0], + EMPLOYEE: [4_200.0, 0.0, 1_100.0, 900.0], + "age": [40.0, 8.0, 55.0, 62.0], + } + ) + household = pd.DataFrame( + {"household_id": [1, 2, 3], "household_weight": [1_000.0, 2_500.0, 400.0]} + ) + dataset = USSingleYearDataset( + person=person, + household=household, + tax_unit=pd.DataFrame({"tax_unit_id": [1, 2, 3]}), + spm_unit=pd.DataFrame({"spm_unit_id": [1, 2, 3]}), + family=pd.DataFrame({"family_id": [1, 2, 3]}), + marital_unit=pd.DataFrame({"marital_unit_id": [1, 2, 3, 4]}), + time_period=BASE_YEAR, + ) + return extend_single_year_dataset(dataset, end_year=2036, system=system) + + +def _weighted_total(dataset, column): + weights = dataset.household.set_index("household_id")["household_weight"] + person_weights = dataset.person["person_household_id"].map(weights) + return float((dataset.person[column] * person_weights).sum()) + + +@pytest.mark.parametrize("year", [2025, 2030, 2034, 2036]) +@pytest.mark.parametrize("variable", [EMPLOYER, EMPLOYEE]) +def test_dataset_extension_uprates_by_the_index(variable, year): + base, later = _extended().datasets[BASE_YEAR], _extended().datasets[year] + for before, after in zip(base.person[variable], later.person[variable]): + assert after == pytest.approx(before * _ratio(ESI, year), rel=1e-9) + assert _weighted_total(later, variable) / _weighted_total( + base, variable + ) == pytest.approx(_ratio(ESI, year) * _ratio(POPULATION_PATH, year), rel=1e-9) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_earned_income.yaml index 5f622e9b40e..8d76a04159c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_earned_income.yaml @@ -56,3 +56,329 @@ members: [person] output: eitc_earned_income: 0 + +# Full PolicyBench snapshot inputs plus isolated earned-income rules. +- name: PolicyBench scenario_100 excludes traditional elective deferrals from credit earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 46 + employment_income: 5914.720703125 + bank_account_assets: 2800.0 + employer_sponsored_insurance_premiums: 20643.525390625 + fsla_overtime_premium: 709.7664794921875 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 564.0 + hourly_wage: 2.319999933242798 + hours_worked_last_week: 55.0 + is_disabled: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 564.0 + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 480.0 + roth_401k_contributions_desired: 681.0 + roth_ira_contributions_desired: 279.67999267578125 + traditional_401k_contributions_desired: 3859.0 + traditional_ira_contributions_desired: 180.32000732421875 + weekly_hours_worked_before_lsr: 55.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child1: + age: 6 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child2: + age: 5 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 500.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + head_marital_unit: + members: [head] + child1_marital_unit: + members: [child1] + child2_marital_unit: + members: [child2] + tax_units: + tax_unit: + members: [head, child1, child2] + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head, child1, child2] + takes_up_snap_if_eligible: true + families: + family: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: MT + household_vehicles_value: 1980.88232421875 + output: + income_tax_refundable_credits: 822.29 + state_refundable_credits: 164.46 + eitc_earned_income: 2055.720703125 + filer_adjusted_earnings: 2055.720703125 + +- name: PolicyBench scenario_119 excludes traditional elective deferrals from credit earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 55000.0 + bank_account_assets: 123797.0625 + employer_sponsored_insurance_premiums: 6389.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2000.0 + hourly_wage: 33.0625 + hours_worked_last_week: 32.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 2000.0 + other_medical_expenses: 1000.0 + over_the_counter_health_expenses: 250.0 + roth_401k_contributions_desired: 544.7999877929688 + roth_ira_contributions_desired: 223.74400329589844 + stock_assets: 300000.0 + taxable_interest_income: 800.0 + traditional_401k_contributions_desired: 3087.199951171875 + traditional_ira_contributions_desired: 144.25599670410156 + weekly_hours_worked_before_lsr: 32.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child1: + age: 14 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 250.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child2: + age: 11 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 2500.0 + over_the_counter_health_expenses: 250.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + head_marital_unit: + members: [head] + child1_marital_unit: + members: [child1] + child2_marital_unit: + members: [child2] + tax_units: + tax_unit: + members: [head, child1, child2] + first_home_mortgage_balance: 88911.765625 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head, child1, child2] + spm_unit_pre_subsidy_childcare_expenses: 1560.0 + takes_up_snap_if_eligible: true + families: + family: + members: [head, child1, child2] + households: + household: + members: [head, child1, child2] + state_code: VA + household_vehicles_value: 4232.05859375 + output: + income_tax_refundable_credits: 3366.07 + state_income_tax: 1756.68 + eitc_earned_income: 51912.800048828125 + filer_adjusted_earnings: 51912.800048828125 + +- name: Traditional 401k deferrals reduce EITC and ACTC wages while Roth deferrals remain taxable + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + roth_401k_contributions_desired: 1000 + child: + age: 5 + is_tax_unit_head: false + employment_income: 0 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + irs_employment_income: [8000, 0] + earned_income: [10000, 0] + eitc_earned_income: 8000 + filer_adjusted_earnings: 8000 + ctc_phase_in_relevant_earnings: 825 + +- name: Traditional 403b deferrals reduce credit wages while Roth deferrals remain taxable + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + traditional_403b_contributions_desired: 2000 + roth_403b_contributions_desired: 1000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + eitc_earned_income: 8000 + filer_adjusted_earnings: 8000 + +- name: Elective deferral exclusion also applies before 2026 + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + eitc_earned_income: 8000 + filer_adjusted_earnings: 8000 + +- name: Already supplied federal taxable wages are not reduced a second time + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + irs_employment_income: 8000 + traditional_401k_contributions_desired: 2000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + eitc_earned_income: 8000 + filer_adjusted_earnings: 8000 + +- name: Tax unit dependent wages and deferrals do not change filer earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + child: + age: 5 + is_tax_unit_head: false + employment_income: 3000 + traditional_401k_contributions_desired: 1000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + eitc_earned_income: 8000 + filer_adjusted_earnings: 8000 + +- name: Federal credit earnings exclude pre-tax health premiums and payroll HSA contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + pre_tax_health_insurance_premiums: 500 + health_savings_account_payroll_contributions: 1000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + eitc_earned_income: 8500 + filer_adjusted_earnings: 8500 + +- name: AMT kiddie-tax exemption retains its original gross earnings cap + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 20 + is_tax_unit_head: true + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + tax_units: + tax_unit: + members: [head] + amt_kiddie_tax_applies: true + amt_income: 200000 + households: + household: + members: [head] + state_code: TX + output: + filer_adjusted_earnings: 8000 + amt_exemption: 18200 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml index 8fec9eeaef1..11d92ee3b0d 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml @@ -1,14 +1,18 @@ +# IRA contributions cannot exceed compensation (26 U.S.C. 219(b)(1)(B)), so each +# contributor is given compensation above their contributions. - name: joint with one eligible person period: 2023 input: people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 retirement_distributions: 1_000 person2: savers_credit_eligible_person: false + ira_compensation: 10_000 traditional_ira_contributions_desired: 0 roth_ira_contributions_desired: 0 tax_units: @@ -26,11 +30,13 @@ person1: savers_credit_eligible_person: true adjusted_gross_income_person: 39_000 + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 person2: savers_credit_eligible_person: true adjusted_gross_income_person: 50_000 + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 tax_units: @@ -47,10 +53,12 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 person2: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 tax_units: @@ -67,11 +75,13 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 retirement_distributions: 4_000 person2: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 tax_units: @@ -88,6 +98,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 roth_ira_contributions_desired: 500 roth_401k_contributions_desired: 1_000 tax_units: @@ -104,6 +115,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 roth_ira_contributions_desired: 500 roth_401k_contributions_desired: 1_000 tax_units: @@ -120,10 +132,12 @@ people: person1: savers_credit_eligible_person: false + ira_compensation: 10_000 traditional_ira_contributions_desired: 3_000 roth_ira_contributions_desired: 2_000 person2: savers_credit_eligible_person: false + ira_compensation: 10_000 traditional_ira_contributions_desired: 0 roth_ira_contributions_desired: 0 tax_units: @@ -143,6 +157,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -161,6 +176,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -179,6 +195,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -197,6 +214,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -214,6 +232,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -232,6 +251,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -249,6 +269,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -267,6 +288,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: @@ -282,6 +304,7 @@ people: person1: savers_credit_eligible_person: true + ira_compensation: 10_000 traditional_ira_contributions_desired: 2_000 tax_units: tax_unit: diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml index 37347ee08ed..672916a2ac5 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml @@ -1,6 +1,9 @@ +# IRA contributions cannot exceed compensation (26 U.S.C. 219(b)(1)(B)), so each +# contributor is given compensation above their contributions. - name: Contributions larger than distributions period: 2023 input: + ira_compensation: 10_000 traditional_ira_contributions_desired: 1_000 traditional_401k_contributions_desired: 3_000 traditional_403b_contributions_desired: 2_000 @@ -17,6 +20,7 @@ - name: Contributions smaller than distributions period: 2023 input: + ira_compensation: 10_000 traditional_ira_contributions_desired: 1_000 traditional_401k_contributions_desired: 0 traditional_403b_contributions_desired: 0 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/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.yaml index 7ba0766da42..ef20649033c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.yaml @@ -129,3 +129,31 @@ # Taxable = min(0.85 * 26,000, 0.85 * 12,000) = min(22,100, 10,200) # = 10,200. dependent_taxable_social_security: [0, 10_200, 0] + +- name: A dependent's own IRA deduction reduces their modified AGI + # Pub. 915 Worksheet 1: $29,000 wages less a $5,000 IRA deduction gives + # modified AGI of $24,000. Adding half of $10,000 in benefits gives $29,000, + # $4,000 over the $25,000 base, so half of that, $2,000, is taxable. + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 29_000 + traditional_ira_contributions: 5_000 + social_security_survivors: 10_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + dependent_taxable_social_security: [0, 2_000] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml index f5dfb884933..f4eca3c333c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml @@ -142,3 +142,31 @@ output: # Modified AGI = 3,000 + 13,000 tax-exempt interest (IRC 86(b)(2)(B)). dependent_taxable_ss_magi: [0, 16_000] + +- name: A dependent's own IRA deduction reduces their modified AGI + # Pub. 915 Worksheet 1: $29,000 wages less a $5,000 IRA deduction gives + # modified AGI of $24,000. Adding half of $10,000 in benefits gives $29,000, + # $4,000 over the $25,000 base, so half of that, $2,000, is taxable. + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 29_000 + traditional_ira_contributions: 5_000 + social_security_survivors: 10_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + dependent_taxable_ss_magi: [0, 24_000] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml index e9dfb1c6311..580d1f7e672 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml @@ -11,3 +11,163 @@ members: [person1, person2] output: itemized_medical_expenses: 1_500 + +# Premiums paid through pre-tax payroll deductions. +# +# IRC 213(a) allows "the expenses paid during the taxable year, not +# compensated for by insurance or otherwise, for medical care". A premium paid +# by salary reduction under a section 125 cafeteria plan is paid by the +# employer, not the employee, and is excluded from wages under IRC 106(a) +# (Rev. Rul. 2002-3). It is therefore not an expense the taxpayer paid. +# IRS Publication 502 (2025), "Employer-Sponsored Health Insurance Plan": +# "Don't include in your medical and dental expenses any insurance premiums +# paid by an employer-sponsored health insurance plan unless the premiums are +# included on your Form W-2, Wage and Tax Statement." +# +# pre_tax_health_insurance_premiums and the other premium inputs are disjoint: +# a premium is reported in one or the other, never both. Schedule A therefore +# counts the other premium inputs in full and never the pre-tax input. + +- name: Pre-tax payroll premiums reduce wages and are not Schedule A medical expenses. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 9_000 + output: + # 60,000 - 2,000, for income tax and for FICA. + irs_employment_income: 58_000 + payroll_tax_gross_wages: 58_000 + adjusted_gross_income: 58_000 + medical_expense_health_insurance_premiums: 0 + # Counting the premium again would give 11,000. + itemized_medical_expenses: 9_000 + # 9,000 - 7.5% of 58,000. + medical_expense_deduction: 4_650 + +- name: A worker who pays premiums both ways counts only the after-tax premiums. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + health_insurance_premiums_without_medicare_part_b: 3_000 + other_medical_expenses: 9_000 + output: + irs_employment_income: 58_000 + adjusted_gross_income: 58_000 + # The 3,000 is not reduced by the 2,000: the inputs do not overlap. + medical_expense_health_insurance_premiums: 3_000 + itemized_medical_expenses: 12_000 + # 12,000 - 7.5% of 58,000. + medical_expense_deduction: 7_650 + +- name: The directly supplied premium input likewise excludes pre-tax premiums. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + health_insurance_premiums: 3_000 + other_medical_expenses: 9_000 + output: + medical_expense_health_insurance_premiums: 3_000 + itemized_medical_expenses: 12_000 + medical_expense_deduction: 7_650 + +- name: The same premiums paid after tax are medical expenses and do not reduce wages. + period: 2025 + input: + age: 40 + employment_income: 60_000 + health_insurance_premiums_without_medicare_part_b: 5_000 + other_medical_expenses: 9_000 + output: + irs_employment_income: 60_000 + payroll_tax_gross_wages: 60_000 + adjusted_gross_income: 60_000 + medical_expense_health_insurance_premiums: 5_000 + itemized_medical_expenses: 14_000 + # 14,000 - 7.5% of 60,000. + medical_expense_deduction: 9_500 + +- name: A working Medicare enrollee counts Part B premiums but not pre-tax payroll premiums. + period: 2025 + input: + age: 67 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + medicare_enrolled: true + medicare_part_b_premium: 2_220 + other_medical_expenses: 9_000 + output: + irs_employment_income: 58_000 + medical_expense_health_insurance_premiums: 2_220 + itemized_medical_expenses: 11_220 + # 11,220 - 7.5% of 58,000. + medical_expense_deduction: 6_870 + +- name: Joint filers count one spouse's after-tax premiums and not the other's pre-tax premiums. + period: 2025 + input: + people: + person1: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + pre_tax_health_insurance_premiums: 2_400 + other_medical_expenses: 6_000 + person2: + age: 40 + is_tax_unit_spouse: true + employment_income: 30_000 + health_insurance_premiums_without_medicare_part_b: 1_800 + other_medical_expenses: 1_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + output: + irs_employment_income: [47_600, 30_000] + adjusted_gross_income: 77_600 + medical_expense_health_insurance_premiums: [0, 1_800] + # 6,000 + 1,000 + 1,800. + itemized_medical_expenses: 8_800 + # 8,800 - 7.5% of 77,600. + medical_expense_deduction: 2_980 + +- name: An itemizer with pre-tax premiums deducts only the other medical expenses. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 30_000 + # Isolate the medical deduction from modeled state and local taxes. + salt_deduction: 0 + output: + adjusted_gross_income: 58_000 + itemized_medical_expenses: 30_000 + # 30,000 - 7.5% of 58,000. + medical_expense_deduction: 25_650 + tax_unit_itemizes: true + # 58,000 - 25,650. Counting the premium again would give 30,350. + taxable_income: 32_350 + +- name: A non-itemizer with pre-tax premiums gets the wage exclusion and the standard deduction. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + output: + adjusted_gross_income: 58_000 + itemized_medical_expenses: 0 + medical_expense_deduction: 0 + tax_unit_itemizes: false + standard_deduction: 15_750 + # 58,000 - 15,750. + taxable_income: 42_250 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/estate_income_would_be_qualified.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/estate_income_would_be_qualified.yaml new file mode 100644 index 00000000000..975e470be96 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/estate_income_would_be_qualified.yaml @@ -0,0 +1,210 @@ +# PolicyBench snapshot scenarios 039 and 110, tax year 2026. +# Generic estate income does not establish the separately reported QBI +# qualification in Form 1041 Schedule K-1 box 14 code I. + +- name: PolicyBench scenario 039 excludes unspecified estate income from QBI + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 61 + employment_income: 0.0 + bank_account_assets: 16000.0 + employer_sponsored_insurance_premiums: 3589.27587890625 + estate_income: 25950.0 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 4800.0 + is_disabled: true + is_surviving_spouse: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 4800.0 + other_medical_expenses: 200.0 + self_employment_income: -6260.029296875 + social_security_retirement: 36105.0 + taxable_ira_distributions: 26800.0 + taxable_private_pension_income: 2030.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: [head] + tax_units: + tax_unit: + members: [head] + first_home_mortgage_balance: 47500.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head] + takes_up_snap_if_eligible: true + families: + family: + members: [head] + households: + household: + members: [head] + household_vehicles_value: 5184.11767578125 + state_code: VA + output: + income_tax_before_refundable_credits: 8596.03 + +- name: PolicyBench scenario 110 excludes unspecified estate income from QBI + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 51 + employment_income: 100000.0 + bank_account_assets: 762650.0 + charitable_cash_donations: 387.4705810546875 + charitable_non_cash_donations: 700.0 + employer_sponsored_insurance_premiums: 6889.27587890625 + estate_income: 6322.94140625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 1500.0 + home_mortgage_interest: 7566.0 + hourly_wage: 40.06395721435547 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: 30851.765625 + non_qualified_dividend_income: 5662.11767578125 + other_health_insurance_premiums: 1500.0 + other_medical_expenses: 300.0 + over_the_counter_health_expenses: 500.0 + qualified_dividend_income: 10032.0 + real_estate_taxes: 2262.441162109375 + roth_401k_contributions_desired: 749.0999755859375 + roth_ira_contributions_desired: 307.64801025390625 + short_term_capital_gains: 18.0 + stock_assets: 3285294.0 + tax_exempt_interest_income: 954.0 + taxable_interest_income: 21301.0 + traditional_401k_contributions_desired: 4244.89990234375 + traditional_ira_contributions_desired: 198.3520050048828 + unreimbursed_business_employee_expenses: 8730.1328125 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: [head] + tax_units: + tax_unit: + members: [head] + first_home_mortgage_balance: 273000.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head] + takes_up_snap_if_eligible: true + families: + family: + members: [head] + households: + household: + members: [head] + household_vehicles_value: 14630.0 + state_code: OH + output: + # With the IRA 219(g) phase-out as well, the two-cause cell lands on its + # PolicyBench target. + income_tax_before_refundable_credits: 25747.77 + +- name: Unspecified estate income is taxable but does not qualify as QBI + period: 2026 + input: + estate_income: 10000 + employment_income: 40000 + output: + estate_income_would_be_qualified: false + irs_gross_income: 50000 + qualified_business_income: 0 + qualified_business_income_deduction: 0 + +- name: Explicitly qualified estate business income remains QBI + period: 2026 + input: + estate_income: 10000 + estate_income_would_be_qualified: true + employment_income: 40000 + output: + irs_gross_income: 50000 + qualified_business_income: 10000 + qualified_business_income_deduction: 2000 + +- name: Explicitly nonqualified estate income remains taxable + period: 2026 + input: + estate_income: 10000 + estate_income_would_be_qualified: false + employment_income: 40000 + output: + irs_gross_income: 50000 + qualified_business_income: 0 + qualified_business_income_deduction: 0 + +- name: Differential invariant - nonqualified estate income leaves both QBI categories unchanged + period: 2026 + input: + people: + no_estate: + partnership_s_corp_income: 10000 + sstb_self_employment_income: 30000 + estate_income: 0 + self_employment_tax_ald_person: 1000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + omitted_positive: + partnership_s_corp_income: 10000 + sstb_self_employment_income: 30000 + estate_income: 20000 + self_employment_tax_ald_person: 1000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + omitted_negative: + partnership_s_corp_income: 10000 + sstb_self_employment_income: 30000 + estate_income: -20000 + self_employment_tax_ald_person: 1000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + false_positive: + partnership_s_corp_income: 10000 + sstb_self_employment_income: 30000 + estate_income: 20000 + self_employment_tax_ald_person: 1000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + estate_income_would_be_qualified: false + false_negative: + partnership_s_corp_income: 10000 + sstb_self_employment_income: 30000 + estate_income: -20000 + self_employment_tax_ald_person: 1000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + estate_income_would_be_qualified: false + output: + qualified_business_income: + - 9750 + - 9750 + - 9750 + - 9750 + - 9750 + sstb_qualified_business_income: + - 29250 + - 29250 + - 29250 + - 29250 + - 29250 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml index cb7e7713312..8265d10a334 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml @@ -307,3 +307,37 @@ section_199a_net_capital_gain: 45_000 # 20% x (100,000 - 45,000) = 11,000. qualified_business_income_deduction: 11_000 + +- name: Income limitation nets short-term loss before retaining section 1250 gain + period: 2025 + input: + qbid_amount: 20_000 + taxable_income_less_qbid: 100_000 + long_term_capital_gains: 30_000 + short_term_capital_gains: -10_000 + qualified_dividend_income: 5_000 + # Schedule D line 19, already reduced for losses. + unrecaptured_section_1250_gain: 15_000 + output: + # 2025 Form 8995 instructions, page 4, line 12: + # Schedule D line 15 = $30,000; line 16 = $30,000 - $10,000 = $20,000. + # Form 8995 line 12 = min($30,000, $20,000) + $5,000 = $25,000. + section_199a_net_capital_gain: 25_000 + adjusted_net_capital_gain: 10_000 + # Lines 13-15: min($20,000, 20% x ($100,000 - $25,000)) = $15,000. + # The former adjusted-gain cap would allow $18,000. + qualified_business_income_deduction: 15_000 + +- name: Income limitation binds with capital gain distributions without Schedule D + period: 2025 + input: + qbid_amount: 8_000 + taxable_income_less_qbid: 40_000 + non_sch_d_capital_gains: 8_000 + qualified_dividend_income: 2_000 + output: + # 2025 Form 8995 instructions, page 4, line 12: without Schedule D, + # use Form 1040 line 7a ($8,000) plus line 3a ($2,000) = $10,000. + section_199a_net_capital_gain: 10_000 + # Lines 13-15: min($8,000, 20% x ($40,000 - $10,000)) = $6,000. + qualified_business_income_deduction: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml index 9aeaefef571..8408febd2e6 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml @@ -140,3 +140,17 @@ output: # 10,000 + max(0, 5,000 - 2,000). section_199a_net_capital_gain: 13_000 + +- name: Schedule D distributions are included in long-term gain exactly once + period: 2025 + input: + # The $8,000 Schedule D line 13 distribution is already in line 15. + long_term_capital_gains: 30_000 + schedule_d_capital_gain_distributions: 8_000 + short_term_capital_gains: -10_000 + qualified_dividend_income: 5_000 + output: + # 2025 Form 8995 instructions, page 4, line 12: + # line 15 = $30,000; line 16 = $20,000; line 3a = $5,000. + # Adding the memo distribution again would incorrectly produce $33,000. + section_199a_net_capital_gain: 25_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/ira/dependent_traditional_ira_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/ira/dependent_traditional_ira_deduction.yaml new file mode 100644 index 00000000000..e1dd8ac9c91 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/ira/dependent_traditional_ira_deduction.yaml @@ -0,0 +1,160 @@ +# A dependent deducts their own traditional IRA contributions on their own +# return (26 U.S.C. 219(b)(1), 219(g)); the filer's traditional_ira_deduction +# excludes them. +- name: A student dependent deducts their own contribution in full + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 29_000 + traditional_ira_contributions: 5_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + dependent_traditional_ira_deduction: [0, 5_000] + # The dependent's own-return deduction is recorded on the dependent. + traditional_ira_deduction: [0, 5_000] + +- name: The dependent's deduction is limited to their compensation + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 3_000 + traditional_ira_contributions: 5_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + dependent_traditional_ira_deduction: [0, 3_000] + +- name: An active-participant dependent is phased out on the single schedule + # Notice 2024-80: the 2025 single range is $79,000 to $89,000 and the dollar + # limit $7,000. Modified AGI of $85,000 is 60% through the range, so the + # limit falls by $4,200 to $2,800. + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + student: + age: 22 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 85_000 + ira_active_participant: true + traditional_ira_contributions: 7_000 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + dependent_traditional_ira_deduction: [0, 2_800] + +- name: A dependent living with a covered spouse is phased out on the separate schedule + # Pub. 590-A Table 1-3: married filing separately, living with a spouse who + # is covered, phases out from $0 to $10,000. Modified AGI of $10,000 leaves + # no deduction, though the dependent is not covered. + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 10_000 + traditional_ira_contributions: 5_000 + students_spouse: + age: 22 + is_tax_unit_head: true + employment_income: 30_000 + ira_active_participant: true + tax_units: + parents_unit: + members: [parent, student] + spouses_unit: + members: [students_spouse] + marital_units: + parent_unit: + members: [parent] + couple: + members: [student, students_spouse] + households: + household: + members: [parent, student, students_spouse] + state_code: TX + output: + dependent_traditional_ira_deduction: [0, 0, 0] + +- name: The dependent's IRA modified AGI includes taxable Social Security + # Pub. 590-A Appendix B, Worksheet 1: a covered dependent aged 67 living + # with their spouse has $3,000 of wages and $5,000 of benefits. With a zero + # base amount, 85% of the benefits, $4,250, is taxable before the IRA + # deduction, so modified AGI is $7,250. That is 72.5% through the $0 to + # $10,000 separate range, so the $8,000 limit falls by $5,800 to $2,200. + period: 2025 + input: + people: + parent: + age: 50 + employment_income: 80_000 + is_tax_unit_head: true + elder: + age: 67 + is_tax_unit_dependent: true + employment_income: 3_000 + traditional_ira_contributions: 3_000 + ira_active_participant: true + social_security_retirement: 5_000 + elders_spouse: + age: 66 + is_tax_unit_head: true + employment_income: 30_000 + tax_units: + parents_unit: + members: [parent, elder] + spouses_unit: + members: [elders_spouse] + marital_units: + parent_unit: + members: [parent] + couple: + members: [elder, elders_spouse] + households: + household: + members: [parent, elder, elders_spouse] + state_code: TX + output: + dependent_traditional_ira_deduction: [0, 2_200, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/ira/ira_active_participant.yaml b/policyengine_us/tests/policy/baseline/gov/irs/ira/ira_active_participant.yaml new file mode 100644 index 00000000000..0d85c94c98c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/ira/ira_active_participant.yaml @@ -0,0 +1,172 @@ +- name: No employer plan contributions leave coverage false by default. + period: 2025 + input: + age: 40 + output: + ira_active_participant: false + +- name: Traditional 401k deferrals trigger the single IRA deduction phaseout. + period: 2025 + input: + age: 40 + traditional_401k_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 84_000 + output: + ira_active_participant: true + ira_219g_deductible_limit: 3_500 + traditional_ira_deduction: 3_500 + +- name: Roth 401k deferrals trigger the single IRA deduction phaseout. + period: 2025 + input: + age: 40 + roth_401k_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 84_000 + output: + ira_active_participant: true + traditional_ira_deduction: 3_500 + +- name: Traditional 403b deferrals trigger the single IRA deduction phaseout. + period: 2025 + input: + age: 40 + traditional_403b_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 84_000 + output: + ira_active_participant: true + traditional_ira_deduction: 3_500 + +- name: Roth 403b deferrals trigger the single IRA deduction phaseout. + period: 2025 + input: + age: 40 + roth_403b_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 84_000 + output: + ira_active_participant: true + traditional_ira_deduction: 3_500 + +- name: Self employed pension contributions trigger the single IRA deduction phaseout. + period: 2025 + input: + age: 40 + self_employed_pension_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 84_000 + output: + ira_active_participant: true + traditional_ira_deduction: 3_500 + +- name: IRA contributions alone do not establish employer plan coverage. + period: 2025 + input: + age: 40 + traditional_ira_contributions: 4_000 + roth_ira_contributions: 3_000 + ira_compensation: 100_000 + ira_219g_magi: 89_000 + output: + ira_active_participant: false + traditional_ira_deduction: 4_000 + +- name: Negative employer plan contributions do not establish coverage. + period: 2025 + input: + traditional_401k_contributions: -1_000 + roth_401k_contributions: -1_000 + traditional_403b_contributions: -1_000 + roth_403b_contributions: -1_000 + self_employed_pension_contributions: -1_000 + output: + ira_active_participant: false + +- name: A negative contribution does not cancel positive coverage from another plan. + period: 2025 + input: + traditional_401k_contributions: 1_000 + roth_401k_contributions: -2_000 + output: + ira_active_participant: true + +- name: Desired 401k contributions derive the deferrals that establish coverage. + period: 2025 + input: + age: 40 + employment_income: 85_000 + traditional_401k_contributions_desired: 1_000 + traditional_ira_contributions_desired: 7_000 + output: + traditional_401k_contributions: 1_000 + ira_active_participant: true + ira_219g_magi: 84_000 + traditional_ira_deduction: 3_500 + +- name: Explicit coverage true overrides zero contributions for defined benefit coverage. + period: 2025 + input: + age: 40 + ira_active_participant: true + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 84_000 + output: + ira_active_participant: true + traditional_ira_deduction: 3_500 + +- name: Explicit coverage false overrides positive deferrals. + period: 2025 + input: + age: 40 + ira_active_participant: false + traditional_401k_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 89_000 + output: + ira_active_participant: false + traditional_ira_deduction: 7_000 + +- name: Derived coverage fully phases out the deduction at the upper single threshold. + period: 2025 + input: + age: 40 + traditional_401k_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + ira_219g_magi: 89_000 + output: + ira_active_participant: true + ira_219g_deductible_limit: 0 + traditional_ira_deduction: 0 + +- name: Each spouse uses their own coverage and the appropriate joint phaseout range. + period: 2025 + input: + people: + head: + age: 40 + traditional_401k_contributions: 1_000 + traditional_ira_contributions: 7_000 + ira_compensation: 150_000 + spouse: + age: 40 + traditional_ira_contributions: 7_000 + ira_compensation: 100_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + ira_219g_magi: 241_000 + output: + ira_active_participant: [true, false] + ira_219g_deductible_limit: [0, 3_500] + traditional_ira_deduction: [0, 3_500] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/ira/ira_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/irs/ira/ira_phase_out.yaml new file mode 100644 index 00000000000..ed1042d324c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/ira/ira_phase_out.yaml @@ -0,0 +1,226 @@ +- name: Elective deferrals establish coverage and phase out the IRA deduction by default. + period: 2026 + input: + age: 40 + employment_income: 200_000 + traditional_401k_contributions_desired: 10_000 + traditional_ira_contributions_desired: 7_500 + output: + ira_active_participant: true + traditional_ira_contributions: 7_500 + traditional_ira_deduction: 0 + +- name: An active participant above the single range cannot deduct actual contributions. + period: 2026 + input: + age: 40 + employment_income: 100_000 + ira_active_participant: true + traditional_ira_contributions: 7_500 + output: + ira_219g_magi: 100_000 + traditional_ira_contributions: 7_500 + traditional_ira_deduction: 0 + above_the_line_deductions: 0 + +- name: The phaseout rounds the reduction down to ten dollars. + period: 2026 + input: + age: 40 + employment_income: 81_001 + ira_active_participant: true + traditional_ira_contributions: 7_500 + output: + ira_219g_deductible_limit: 7_500 + traditional_ira_deduction: 7_500 + +- name: The deduction limit stays at two hundred dollars until complete phaseout. + period: 2026 + input: + age: 40 + employment_income: 90_999 + ira_active_participant: true + traditional_ira_contributions: 7_500 + output: + ira_219g_deductible_limit: 200 + traditional_ira_deduction: 200 + +- name: The two hundred dollar floor cannot exceed actual contributions. + period: 2026 + input: + age: 40 + employment_income: 90_999 + ira_active_participant: true + traditional_ira_contributions: 100 + output: + ira_219g_deductible_limit: 200 + traditional_ira_deduction: 100 + +- name: Compensation limits direct actual contribution overrides. + period: 2026 + input: + age: 40 + employment_income: 300 + traditional_ira_contributions: 5_000 + output: + traditional_ira_contributions: 5_000 + traditional_ira_deduction: 300 + above_the_line_deductions: 300 + +- name: A lower earning joint spouse deducts only compensation remaining after actual spouse contributions. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 5_000 + traditional_ira_contributions: 3_000 + roth_ira_contributions: 1_000 + spouse: + age: 40 + employment_income: 1_000 + traditional_ira_contributions: 7_500 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + traditional_ira_deduction: [3_000, 2_000] + above_the_line_deductions: 5_000 + +- name: IRA MAGI adds back education adoption and bond deductions and section 911 gross exclusions. + period: 2026 + input: + employment_income: 82_000 + ira_active_participant: true + traditional_ira_contributions: 7_500 + student_loan_interest_ald: 1_000 + qualified_adoption_assistance_expense: 1_000 + us_bonds_for_higher_ed: 1_000 + foreign_earned_income_exclusion_amount: 2_000 + foreign_earned_income_exclusion_disallowed_deductions: 500 + output: + ira_219g_magi: 84_000 + ira_219g_deductible_limit: 5_250 + +- name: IRA MAGI recomputes taxable Social Security before the IRA deduction. + period: 2025 + input: + people: + head: + age: 65 + employment_income: 118_000 + social_security: 12_000 + traditional_ira_contributions: 8_000 + ira_active_participant: true + spouse: + age: 65 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + ira_219g_taxable_social_security: 10_200 + ira_219g_magi: 128_200 + traditional_ira_deduction: [7_120, 0] + +- name: Section 85 unemployment exclusion is disregarded for IRA MAGI in 2020. + period: 2020 + input: + employment_income: 58_000 + unemployment_compensation: 10_000 + ira_active_participant: true + traditional_ira_contributions: 6_000 + capped_qualified_tuition_expenses_ald: 1_000 + output: + ira_219g_magi: 68_000 + ira_219g_deductible_limit: 4_200 + +- name: A dependent's personal deductions and income do not reduce the filer's IRA MAGI. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 85_000 + ira_active_participant: true + traditional_ira_contributions_desired: 7_500 + child: + age: 17 + employment_income: 40_000 + early_withdrawal_penalty: 10_000 + traditional_ira_contributions_desired: 1_000 + tax_units: + tax_unit: + members: [head, child] + filing_status: HEAD_OF_HOUSEHOLD + output: + ira_219g_magi: 85_000 + traditional_ira_contributions: [7_500, 1_000] + # The dependent's $1,000 is their own-return deduction; the filer's is + # unchanged. + traditional_ira_deduction: [4_500, 1_000] + above_the_line_deductions: 4_500 + +- name: Massachusetts disallows the IRA deduction allowed federally. + period: 2026 + input: + age: 40 + state_code: MA + employment_income: 20_000 + traditional_ira_contributions_desired: 3_000 + output: + traditional_ira_deduction: 3_000 + adjusted_gross_income: 17_000 + ma_part_b_agi: 20_000 + +- name: Mississippi allows only the federally deductible IRA amount. + period: 2026 + input: + age: 40 + state_code: MS + employment_income: 100_000 + traditional_ira_contributions_desired: 7_500 + ira_active_participant: true + output: + traditional_ira_deduction: 0 + ms_agi_adjustments: 0 + +- name: Excess contributions above the spouse's dollar limit do not consume the spousal allowance. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 15_000 + traditional_ira_contributions: 10_000 + spouse: + age: 40 + traditional_ira_contributions: 7_500 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + traditional_ira_contributions: [10_000, 7_500] + traditional_ira_deduction: [7_500, 7_500] + above_the_line_deductions: 15_000 + +- name: Any spouse Roth contributions consume compensation, including excess amounts. + period: 2025 + input: + people: + head: + age: 40 + employment_income: 10_000 + roth_ira_contributions: 9_000 + spouse: + age: 40 + traditional_ira_contributions: 7_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + traditional_ira_deduction: [0, 1_000] + above_the_line_deductions: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/ira/policybench_ira.yaml b/policyengine_us/tests/policy/baseline/gov/irs/ira/policybench_ira.yaml new file mode 100644 index 00000000000..dc32eb9c434 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/ira/policybench_ira.yaml @@ -0,0 +1,687 @@ +# PolicyBench 2026 snapshot households affected by IRC section 219. +# Coverage is derived from each person's employer-plan contributions. +# Compatible targets use corrected_value from the PolicyBench policyengine-us +# 2.37.2 evidence; comments identify the standalone IRA targets retained below. +- name: PolicyBench scenario 003 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 64 + employment_income: 0.0 + bank_account_assets: 95841.1796875 + employer_sponsored_insurance_premiums: 16407.525390625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 4800.0 + hours_worked_last_week: 32.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: -2662.0 + non_qualified_dividend_income: 544.0 + other_health_insurance_premiums: 4800.0 + other_medical_expenses: 1000.0 + over_the_counter_health_expenses: 50.0 + qualified_dividend_income: 3760.0 + roth_401k_contributions_desired: 4222.2001953125 + roth_ira_contributions_desired: 1734.0159912109375 + short_term_capital_gains: 1080.0 + social_security_retirement: 59509.70703125 + stock_assets: 288735.28125 + tax_exempt_interest_income: 5242.2353515625 + tax_exempt_private_pension_income: 18994.705078125 + taxable_interest_income: 2739.705810546875 + taxable_ira_distributions: 94720.0 + taxable_private_pension_income: 32525.294921875 + traditional_401k_contributions_desired: 23925.80078125 + traditional_ira_contributions_desired: 1117.9840087890625 + weekly_hours_worked_before_lsr: 32.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 61 + employment_income: 0.0 + has_esi: true + hours_worked_last_week: 10.0 + is_tax_unit_head: false + is_tax_unit_spouse: true + other_medical_expenses: 5000.0 + over_the_counter_health_expenses: 50.0 + weekly_hours_worked_before_lsr: 10.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + tax_units: + tax_unit: + members: &id001 + - head + - spouse + first_home_mortgage_balance: 39000.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id001 + takes_up_snap_if_eligible: true + families: + family: + members: *id001 + households: + household: + members: *id001 + state_code: TX + auto_loan_balance: 47000.0 + auto_loan_interest: 500.0 + household_vehicles_value: 1610.0 + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 22400.654296875 +- name: PolicyBench scenario 005 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 35 + employment_income: 180000.0 + bank_account_assets: 559200.0 + charitable_cash_donations: 1069.7646484375 + charitable_non_cash_donations: 1503.7646484375 + employer_sponsored_insurance_premiums: 6889.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 1500.0 + hourly_wage: 69.19999694824219 + hours_worked_last_week: 50.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: 51920.0 + non_qualified_dividend_income: 9722.3525390625 + other_health_insurance_premiums: 1500.0 + other_medical_expenses: 800.0 + over_the_counter_health_expenses: 200.0 + qualified_dividend_income: 77920.0 + real_estate_taxes: 17280.0 + roth_401k_contributions_desired: 4086.0 + roth_ira_contributions_desired: 1678.0799560546875 + salt_refund_income: 4147.67626953125 + short_term_capital_gains: -62830.58984375 + stock_assets: 4000.0 + tax_exempt_interest_income: 8961.3525390625 + tax_exempt_private_pension_income: 70000.0 + taxable_interest_income: 65351.0 + traditional_401k_contributions_desired: 23154.0 + traditional_ira_contributions_desired: 1081.9200439453125 + unreimbursed_business_employee_expenses: 13847.8232421875 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 33 + employment_income: 250000.0 + employer_sponsored_insurance_premiums: 6479.69775390625 + has_esi: true + hourly_wage: 108.0 + hours_worked_last_week: 40.0 + is_tax_unit_head: false + is_tax_unit_spouse: true + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 50.0 + roth_401k_contributions_desired: 4086.0 + roth_ira_contributions_desired: 1678.0799560546875 + taxable_interest_income: 585.0 + traditional_401k_contributions_desired: 23154.0 + traditional_ira_contributions_desired: 1081.9200439453125 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + tax_units: + tax_unit: + members: &id002 + - head + - spouse + first_home_mortgage_balance: 2850000.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id002 + takes_up_snap_if_eligible: true + families: + family: + members: *id002 + households: + household: + members: *id002 + state_code: CA + household_vehicles_value: 8932.3525390625 + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 108525.578125 + # Raw CA target: evidence also applies frozen CA parameter conventions + # and the separate r11 California itemized-deduction conformity fix. + # Also includes #10034's California itemized conformity (merged), since + # this is a two-cause IRA + CA cell. + state_income_tax_before_refundable_credits: 40683.11 +- name: PolicyBench scenario 049 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 53 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_medical_expenses: 2000.0 + over_the_counter_health_expenses: 150.0 + taxable_interest_income: 1500.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 52 + employment_income: 253000.0 + bank_account_assets: 40500.0 + charitable_cash_donations: 579.7647094726562 + charitable_non_cash_donations: 2758.0 + employer_sponsored_insurance_premiums: 16407.525390625 + fsla_overtime_premium: 44647.05859375 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 4800.0 + home_mortgage_interest: 2743.0 + hourly_wage: 55.14285659790039 + hours_worked_last_week: 70.0 + is_paid_hourly: true + is_tax_unit_head: false + is_tax_unit_spouse: true + non_qualified_dividend_income: 112.0 + other_health_insurance_premiums: 4800.0 + other_medical_expenses: 2000.0 + over_the_counter_health_expenses: 150.0 + qualified_dividend_income: 6208.0 + real_estate_taxes: 20655.0 + roth_401k_contributions_desired: 2724.0 + roth_ira_contributions_desired: 1118.719970703125 + stock_assets: 1030823.5 + taxable_interest_income: 2001.0 + traditional_401k_contributions_desired: 15436.0 + traditional_ira_contributions_desired: 721.280029296875 + weekly_hours_worked_before_lsr: 70.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + tax_units: + tax_unit: + members: &id003 + - head + - spouse + first_home_mortgage_balance: 1000000.0 + first_home_mortgage_interest: 2743.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id003 + takes_up_snap_if_eligible: true + families: + family: + members: *id003 + households: + household: + members: *id003 + state_code: NH + auto_loan_balance: 30000.0 + auto_loan_interest: 927.7941284179688 + household_vehicles_value: 40400.0 + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 30702.58984375 +- name: PolicyBench scenario 052 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 59 + employment_income: 500000.0 + bank_account_assets: 415000.0 + employer_sponsored_insurance_premiums: 16207.5263671875 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 5000.0 + hourly_wage: 170.14967346191406 + hours_worked_last_week: 60.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: -9042.0 + other_health_insurance_premiums: 5000.0 + other_medical_expenses: 1300.0 + over_the_counter_health_expenses: 200.0 + qualified_bdc_income: 0.4030679762363434 + qualified_dividend_income: 848.0 + roth_401k_contributions_desired: 163.44000244140625 + roth_ira_contributions_desired: 67.12319946289062 + short_term_capital_gains: -20880.0 + stock_assets: 300329.40625 + taxable_interest_income: 7200.0 + traditional_401k_contributions_desired: 926.1599731445312 + traditional_ira_contributions_desired: 43.276798248291016 + weekly_hours_worked_before_lsr: 60.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 55 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: true + other_medical_expenses: 400.0 + over_the_counter_health_expenses: 200.0 + taxable_interest_income: 700.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + tax_units: + tax_unit: + members: &id004 + - head + - spouse + first_home_mortgage_balance: 685735.3125 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id004 + takes_up_snap_if_eligible: true + families: + family: + members: *id004 + households: + household: + members: *id004 + state_code: TX + household_vehicles_value: 28840.0 + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 104225.2578125 +- name: PolicyBench scenario 082 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 23 + employment_income: 100195.0 + bank_account_assets: 127030.0 + charitable_cash_donations: 700.0 + employer_sponsored_insurance_premiums: 21207.525390625 + has_esi: true + hourly_wage: 50.0 + hours_worked_last_week: 35.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: -33000.0 + non_qualified_dividend_income: 7056.0 + other_medical_expenses: 666.0 + over_the_counter_health_expenses: 300.0 + qualified_dividend_income: 13424.0 + real_estate_taxes: 9216.529296875 + roth_401k_contributions_desired: 32.00699996948242 + roth_ira_contributions_desired: 13.144960403442383 + salt_refund_income: 67.5 + short_term_capital_gains: 2289.176513671875 + stock_assets: 564000.0 + taxable_interest_income: 100.0 + traditional_401k_contributions_desired: 181.3730010986328 + traditional_ira_contributions_desired: 8.475040435791016 + weekly_hours_worked_before_lsr: 35.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child1: + age: 1 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 666.0 + over_the_counter_health_expenses: 333.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + marital_unit_2: + members: + - child1 + tax_units: + tax_unit: + members: &id005 + - head + - child1 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id005 + spm_unit_pre_subsidy_childcare_expenses: 9600.0 + takes_up_snap_if_eligible: true + families: + family: + members: *id005 + households: + household: + members: *id005 + state_code: NY + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 9579.767578125 +- name: PolicyBench scenario 099 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 42 + employment_income: 85000.0 + employer_sponsored_insurance_premiums: 18707.525390625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2500.0 + hourly_wage: 42.0 + hours_worked_last_week: 40.0 + is_paid_hourly: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 2500.0 + other_medical_expenses: 2000.0 + over_the_counter_health_expenses: 500.0 + roth_401k_contributions_desired: 272.3999938964844 + roth_ira_contributions_desired: 111.87200164794922 + taxable_interest_income: 500.0 + traditional_401k_contributions_desired: 1543.5999755859375 + traditional_ira_contributions_desired: 72.12799835205078 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 41 + employment_income: 80000.0 + bank_account_assets: 28035.294921875 + charitable_cash_donations: 11932.94140625 + charitable_non_cash_donations: 688.058837890625 + educator_expense: 337.5 + employer_sponsored_insurance_premiums: 5889.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2500.0 + hourly_wage: 30.799999237060547 + hours_worked_last_week: 50.0 + is_tax_unit_head: false + is_tax_unit_spouse: true + long_term_capital_gains: -7018.0 + non_qualified_dividend_income: 16.0 + other_health_insurance_premiums: 2500.0 + other_medical_expenses: 2000.0 + over_the_counter_health_expenses: 500.0 + pre_subsidy_rent: 26400.0 + qualified_dividend_income: 128.0 + real_estate_taxes: 8653.5 + roth_401k_contributions_desired: 272.3999938964844 + roth_ira_contributions_desired: 111.87200164794922 + short_term_capital_gains: 540.0 + stock_assets: 2230000.0 + taxable_interest_income: 510.0 + traditional_401k_contributions_desired: 1543.5999755859375 + traditional_ira_contributions_desired: 72.12799835205078 + unreimbursed_business_employee_expenses: 1262.6470947265625 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child1: + age: 9 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 200.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child2: + age: 4 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 200.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + marital_unit_2: + members: + - child1 + marital_unit_3: + members: + - child2 + tax_units: + tax_unit: + members: &id006 + - head + - spouse + - child1 + - child2 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id006 + spm_unit_pre_subsidy_childcare_expenses: 12740.0 + takes_up_snap_if_eligible: true + families: + family: + members: *id006 + households: + household: + members: *id006 + state_code: CA + auto_loan_balance: 42000.0 + auto_loan_interest: 1575.0 + household_vehicles_value: 21964.705078125 + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 10711.484375 + # Raw CA target: evidence also applies frozen CA parameter conventions + # and the separate r11 California itemized-deduction conformity fix. + # Also includes #10034's California itemized conformity (merged), since + # this is a two-cause IRA + CA cell. + state_income_tax_before_refundable_credits: 4441.45 +- name: PolicyBench scenario 110 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 51 + employment_income: 100000.0 + bank_account_assets: 762650.0 + charitable_cash_donations: 387.4705810546875 + charitable_non_cash_donations: 700.0 + employer_sponsored_insurance_premiums: 6889.27587890625 + estate_income: 6322.94140625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 1500.0 + home_mortgage_interest: 7566.0 + hourly_wage: 40.06395721435547 + hours_worked_last_week: 50.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: 30851.765625 + non_qualified_dividend_income: 5662.11767578125 + other_health_insurance_premiums: 1500.0 + other_medical_expenses: 300.0 + over_the_counter_health_expenses: 500.0 + qualified_dividend_income: 10032.0 + real_estate_taxes: 2262.441162109375 + roth_401k_contributions_desired: 749.0999755859375 + roth_ira_contributions_desired: 307.64801025390625 + short_term_capital_gains: 18.0 + stock_assets: 3285294.0 + tax_exempt_interest_income: 954.0 + taxable_interest_income: 21301.0 + traditional_401k_contributions_desired: 4244.89990234375 + traditional_ira_contributions_desired: 198.3520050048828 + unreimbursed_business_employee_expenses: 8730.1328125 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + tax_units: + tax_unit: + members: &id007 + - head + first_home_mortgage_balance: 273000.0 + first_home_mortgage_interest: 7566.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id007 + takes_up_snap_if_eligible: true + families: + family: + members: *id007 + households: + household: + members: *id007 + state_code: OH + household_vehicles_value: 14630.0 + output: + tax_units: + tax_unit: + # A two-cause cell: with estate income out of QBI (#10027) and the IRA + # phase-out, it lands on its PolicyBench target. + income_tax_before_refundable_credits: 25747.77 + state_income_tax_before_refundable_credits: 4236.8076171875 +- name: PolicyBench scenario 120 IRA deduction regression + period: 2026 + absolute_error_margin: 0.02 + input: + people: + head: + age: 76 + employment_income: 165597.0 + bank_account_assets: 124000.0 + charitable_cash_donations: 20164.1171875 + employer_sponsored_insurance_premiums: 7429.27587890625 + farm_rent_income: -2446.588134765625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 960.0 + home_mortgage_interest: 3744.0 + hourly_wage: 79.5 + hours_worked_last_week: 40.0 + is_disabled: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 960.0 + other_medical_expenses: 1000.0 + over_the_counter_health_expenses: 1000.0 + real_estate_taxes: 2011.5 + rental_income: 2000.0 + roth_401k_contributions_desired: 108.95999908447266 + roth_ira_contributions_desired: 44.74879837036133 + salt_refund_income: 1750.2353515625 + social_security_retirement: 41900.0 + tax_exempt_interest_income: 2851.941162109375 + tax_exempt_private_pension_income: 6146.94140625 + taxable_interest_income: 2300.0 + taxable_private_pension_income: 30216.0 + traditional_401k_contributions_desired: 617.4400024414062 + traditional_ira_contributions_desired: 28.851200103759766 + unreimbursed_business_employee_expenses: 7654.5 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + tax_units: + tax_unit: + members: &id008 + - head + first_home_mortgage_balance: 794797.6875 + first_home_mortgage_interest: 2975.722412109375 + second_home_mortgage_balance: 205202.3125 + second_home_mortgage_interest: 768.277587890625 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id008 + takes_up_snap_if_eligible: true + families: + family: + members: *id008 + households: + household: + members: *id008 + state_code: CT + household_vehicles_value: 9880.0 + output: + tax_units: + tax_unit: + income_tax_before_refundable_credits: 40423.17578125 + state_income_tax_before_refundable_credits: 11919.0576171875 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml index af359e3375b..1a36d9f09b3 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.yaml @@ -3,7 +3,7 @@ input: amt_income: 200_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: SINGLE output: # Base exemption for SINGLE in 2022 is 75,900 @@ -16,7 +16,7 @@ input: amt_income: 200_000 amt_kiddie_tax_applies: true - filer_adjusted_earnings: 10_000 + employment_income: 10_000 filing_status: SINGLE output: # Base exemption for SINGLE in 2022 is 75,900 @@ -30,7 +30,7 @@ input: amt_income: 1_000_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: SINGLE output: # Base exemption for SINGLE in 2022 is 75,900 @@ -44,7 +44,7 @@ input: amt_income: 600_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: SINGLE output: # Base exemption for SINGLE in 2022 is 75,900 @@ -58,7 +58,7 @@ input: amt_income: 500_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: JOINT output: # Base exemption for JOINT in 2022 is 118,100 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml index 391d04015c4..7f992628baa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/income/amt_income_less_exemptions.yaml @@ -4,7 +4,7 @@ amt_income: 200_000 taxable_income: 1_000_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: SINGLE output: amt_income_less_exemptions: 124_100 @@ -15,7 +15,7 @@ amt_income: 200_000 taxable_income: 200_000 amt_kiddie_tax_applies: true - filer_adjusted_earnings: 10_000 + employment_income: 10_000 filing_status: SINGLE output: amt_income_less_exemptions: 181_800 @@ -26,7 +26,7 @@ amt_income: 10_000 taxable_income: 1_000_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: SINGLE output: amt_income_less_exemptions: 0 @@ -37,7 +37,7 @@ amt_income: 1_000_000 taxable_income: 1_000_000 amt_kiddie_tax_applies: false - filer_adjusted_earnings: 400_000 + employment_income: 400_000 filing_status: SINGLE output: amt_income_less_exemptions: 1_000_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml index 1b8d0064fed..f5d12dd1015 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml @@ -287,3 +287,186 @@ capital_gains_tax: 1_500 # The same filer income and HOH tax calculation as the child-dividends case. regular_tax_before_credits: 22_938 + + +- name: A dependent's very large gain cannot round a dollar off the filer's Schedule D + period: 2025 + input: + people: + head: + age: 30 + employment_income: 20_000 + long_term_capital_gains: 977 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 16_776_240 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The float32 all-member aggregate rounds 16,777,217 to 16,777,216. + # Subtracting the child's 16,776,240 would give 976, rather than 977. + # Schedule D line 16 and Form 1040 line 7a use the filer's own gain. + filer_loss_limited_net_capital_gains: 977 + net_capital_gain: 977 + dwks09: 977 + dwks10: 977 + adjusted_gross_income: 20_977 + +- name: Each spouse's distributions are floored before they enter the shared Schedule D + period: 2025 + input: + people: + head: + age: 35 + is_tax_unit_head: true + long_term_capital_gains: 6_000 + non_sch_d_capital_gains: -2_000 + spouse: + age: 34 + is_tax_unit_spouse: true + short_term_capital_gains: -3_000 + non_sch_d_capital_gains: 1_500 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + non_sch_d_capital_gains: 9_000 + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child_unit: + members: [child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # Line 13 is max(0, -2,000) + max(0, 1,500) = 1,500, with no child + # distributions. Line 15 is 7,500; line 7 is -3,000; line 16 is 4,500. + filing_status: JOINT + filer_loss_limited_net_capital_gains: 4_500 + net_capital_gain: 4_500 + dwks09: 4_500 + dwks10: 4_500 + +- name: A supplied aggregate still excludes the dependent in Form 1040 line 7a + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 6_000 + short_term_capital_gains: -1_000 + non_sch_d_capital_gains: 1_500 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + short_term_capital_gains: -2_000 + non_sch_d_capital_gains: 9_000 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 15_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied 15,000 covers all members. Remove the child's 8,000 and + # add the head's 1,500 distributions: Schedule D line 16 is 8,500. + filer_loss_limited_net_capital_gains: 8_500 + # Preserve the documented aggregate/component split: line 15 is 7,500, + # so worksheet line 9 is min(7,500, 8,500), while the statutory amount + # uses component-derived lines 15 and 7: 7,500 - 1,000 = 6,500. + dwks09: 7_500 + dwks10: 7_500 + net_capital_gain: 6_500 + + +- name: A large supplied aggregate keeps the dollar in its component adjustment + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: -1 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 16_777_216 + households: + household: + members: [head] + state_code: TX + output: + # The aggregate is supplied, rather than the component-derived -1. + # Reconciling through a float32 difference from -1 rounds 16,777,217 + # down by a dollar. Read the differing supplied aggregate directly. + filer_loss_limited_net_capital_gains: 16_777_216 + # The component-derived line 15 remains a loss, so the preexisting + # aggregate/component split gives no statutory gain or worksheet gain. + net_capital_gain: 0 + dwks09: 0 + dwks10: 0 + +- name: A supplied aggregate removes a large dependent gain without borrowing its rounding error + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 977 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 16_776_240 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 20_000_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied all-member gain is 20,000,000. Removing the child's + # own 16,776,240 gives 3,223,760. A residual against the rounded + # computed aggregate (16,777,216) incorrectly adds a dollar to this. + filer_loss_limited_net_capital_gains: 3_223_760 + # Component-derived line 15 remains 977, so the preexisting + # aggregate/component split gives that gain to the worksheet and + # statutory net-capital-gain calculation. + net_capital_gain: 977 + dwks09: 977 + dwks10: 977 + +- name: A supplied capital gain survives a much larger filer component + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 1_152_921_504_606_846_976 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 1 + households: + household: + members: [head] + state_code: TX + output: + # The component gain is 2**60, exactly representable in float32. + # Even a float64 residual of 1 - 2**60 loses the supplied dollar when + # added back to the component gain. No dependent gains need removal. + filer_loss_limited_net_capital_gains: 1 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml index 473aa347a36..f5ea071528e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml @@ -401,3 +401,15 @@ net_investment_income: 6_000 # 3.8% x 6,000 net_investment_income_tax: 228 + +- name: A supplied net capital gains amount is net investment income + period: 2025 + input: + employment_income: 300_000 + net_capital_gains: 100_000 + state_code: TX + output: + # Form 8960 line 5a: the filer's Form 1040 line 7a, 100,000. + filer_loss_limited_net_capital_gains: 100_000 + net_investment_income: 100_000 + diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml new file mode 100644 index 00000000000..569eef9cce1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml @@ -0,0 +1,494 @@ +# 20 CFR 416.1202(a): an eligible individual living with an ineligible spouse. +# https://www.ecfr.gov/current/title-20/section-416.1202 +# The resource rule is independent of the income deeming threshold. + +# 20 CFR 416.1202(a): resources are deemed independently of an income threshold. +- name: ABD claimant with an ineligible spouse applies resource deeming without income + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + is_ssi_spousal_resource_deeming_applies: [true, false] + +# 20 CFR 416.1202(a): the spouse must be ineligible; joint claims retain the couple test. +- name: Both ABD spouses jointly claiming do not apply ineligible spouse resource deeming + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + is_ssi_spousal_resource_deeming_applies: [false, false] + +# 20 CFR 416.1202(c): an eligible individual must be aged, blind, or disabled. +- name: Neither spouse ABD does not apply resource deeming + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + is_ssi_spousal_resource_deeming_applies: [false, false] + +# 20 CFR 416.1202(a): the other adult must be the claimant spouse. +- name: An unrelated adult in a tax unit is not an ineligible spouse + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + adult: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [claimant, adult] + marital_units: + family_claimant: + members: [claimant] + family_adult: + members: [adult] + households: + family: + members: [claimant, adult] + output: + is_ssi_spousal_resource_deeming_applies: [false, false] + +# 20 CFR 416.1202(a): the spouses must be living together. +- name: Spouses living in separate households do not deem resources + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + first: + members: [claimant] + second: + members: [spouse] + output: + is_ssi_spousal_resource_deeming_applies: [false, false] + +# Keep SSI tax head/spouse roles: an omitted marital unit is not evidence +# that a parent and adult dependent are spouses under 416.1202(a). +- name: Omitted marital units do not make a tax head parent an adult dependent spouse + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 5000 + is_parent: true + adult_child: + age: 25 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: + - parent + - adult_child + households: + home: + members: + - parent + - adult_child + output: + is_ssi_spousal_resource_deeming_applies: + - false + - false + ssi_resources_deemed_from_ineligible_spouse: + - 0 + - 0 + meets_ssi_resource_test: + - false + - true + +# 416.1160 and 416.1205(a): a married claimant who is a tax dependent still +# lives with an ineligible spouse; tax roles do not decide marriage. +- name: Married dependents in a parent's tax unit deem the ineligible spouse's resources + period: 2026-01 + input: + people: + mother: + age: 55 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + ssi_countable_resources: + '2026': 0 + claimant: + age: 25 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 25 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 3001 + tax_units: + family: + members: [mother, claimant, spouse] + marital_units: + mother_unit: + members: [mother] + couple: + members: [claimant, spouse] + households: + home: + members: [mother, claimant, spouse] + output: + is_ssi_spousal_resource_deeming_applies: [false, true, false] + ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] + meets_ssi_resource_test: [true, false, false] + +# A co-resident head of another tax unit in their own marital unit is not a +# spouse (416.1160), whatever they own. +- name: A co-resident adult in their own marital unit is not a spouse + period: 2026-01 + input: + people: + claimant: + age: 70 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 2500 + son: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + tax_units: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + marital_units: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + households: + home: + members: [claimant, son] + output: + is_ssi_spousal_resource_deeming_applies: [false, false] + ssi_resources_deemed_from_ineligible_spouse: [0, 0] + meets_ssi_resource_test: [false, true] + +# A two-person marital unit is a married, cohabiting couple whether or not +# they file together; 416.1205(a) applies the couple limit to their combined +# resources. +- name: Spouses filing separately in one marital unit count each other's resources + period: 2026-01 + input: + people: + claimant: + age: 70 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 68 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + ssi_countable_resources: + '2026': 3001 + tax_units: + claimant_unit: + members: [claimant] + spouse_unit: + members: [spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + output: + is_ssi_spousal_resource_deeming_applies: [true, false] + ssi_resources_deemed_from_ineligible_spouse: [3001, 0] + meets_ssi_resource_test: [false, false] + +# Two aged, blind or disabled spouses who are both tax dependents do not +# claim jointly in the model, but 416.1205(b) still tests a couple's +# combined resources against the couple limit: 0 + 3,001 > 3,000. +- name: Disabled married dependents count each other's resources + period: 2026-01 + input: + people: + mother: + age: 55 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + ssi_countable_resources: + '2026': 0 + claimant: + age: 25 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 25 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 3001 + tax_units: + family: + members: [mother, claimant, spouse] + marital_units: + mother_unit: + members: [mother] + couple: + members: [claimant, spouse] + households: + home: + members: [mother, claimant, spouse] + output: + is_ssi_spousal_resource_deeming_applies: [false, true, true] + ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] + meets_ssi_resource_test: [true, false, false] + +# Explicit marital units stand whatever tax roles the model infers: with +# roles omitted the model may treat one spouse as the mother's tax spouse and +# the other as her dependent, but 416.1205(b) still combines the couple's +# resources: 0 + 3,001 > 3,000. +- name: Married adults in a parent's tax unit with roles omitted count each other's resources + period: 2026-01 + input: + people: + mother: + age: 55 + is_ssi_aged_blind_disabled: false + ssi_countable_resources: + '2026': 0 + claimant: + age: 25 + is_ssi_aged_blind_disabled: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 25 + is_ssi_aged_blind_disabled: true + ssi_countable_resources: + '2026': 3001 + tax_units: + family: + members: [mother, claimant, spouse] + marital_units: + mother_unit: + members: [mother] + couple: + members: [claimant, spouse] + households: + home: + members: [mother, claimant, spouse] + output: + ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] + meets_ssi_resource_test: [true, false, false] + +# 416.1202(a)(2) excludes retroactive military payments of an ineligible +# spouse; an aged, blind or disabled spouse is tested as part of a couple +# (416.1205(b)) with the exclusions an eligible individual gets. Here each +# spouse's 2,000 of countable resources stays countable: 4,000 > 3,000. +- name: An eligible spouse's resources count without ineligible-spouse exclusions + period: 2026-01 + input: + people: + mother: + age: 55 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + claimant: + age: 30 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 2000 + ssi_resource_deeming_excluded_military_resources: 2000 + spouse: + age: 30 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 2000 + ssi_resource_deeming_excluded_military_resources: 2000 + tax_units: + family: + members: [mother, claimant, spouse] + marital_units: + mother_unit: + members: [mother] + couple: + members: [claimant, spouse] + households: + home: + members: [mother, claimant, spouse] + output: + ssi_resources_deemed_from_ineligible_spouse: [0, 2000, 2000] + meets_ssi_resource_test: [true, false, false] + +# A spouse who fails SSI's citizenship or qualified-noncitizen condition is +# 'not eligible for SSI benefits' (416.1160), so keeps the ineligible-spouse +# exclusion of retroactive military pay (416.1202(a)(2)): 3,001 - 2,000 = +# 1,001 counted, and the claimant passes the 3,000 couple limit. +- name: An immigration-ineligible disabled spouse keeps ineligible-spouse exclusions + period: 2026-01 + input: + people: + mother: + age: 55 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + claimant: + age: 30 + is_ssi_aged_blind_disabled: true + immigration_status: CITIZEN + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 30 + is_ssi_aged_blind_disabled: true + immigration_status: UNDOCUMENTED + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 3001 + ssi_resource_deeming_excluded_military_resources: 2000 + tax_units: + family: + members: [mother, claimant, spouse] + marital_units: + mother_unit: + members: [mother] + couple: + members: [claimant, spouse] + households: + home: + members: [mother, claimant, spouse] + output: + ssi_resources_deemed_from_ineligible_spouse: [0, 1001, 0] + meets_ssi_resource_test: [true, true, false] + diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.yaml new file mode 100644 index 00000000000..7d42e07cc67 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.yaml @@ -0,0 +1,2196 @@ +# Hand-computed cases: 20 CFR 416.1202(b), excess over the same individual +# or couple resource limit used for SSI resource eligibility, never negative. +# https://www.ecfr.gov/current/title-20/section-416.1202 +# https://www.ecfr.gov/current/title-20/section-416.1205 +# POMS SI 01330.200.A includes a parent spouse; .200.B equally divides excess +# and reallocates a child share once that child is ineligible. +# https://secure.ssa.gov/poms.nsf/lnx/0501330200 +# Official Blake and Goode examples (including cascading ineligibility): +# https://secure.ssa.gov/poms.nsf/lnx/0501330280 +# Official White and Ryder examples include an ABD parent and use 2024 limits: +# https://secure.ssa.gov/poms.nsf/lnx/0501330340 +# Blake/Goode use the unchanged 2026 resource limits; stock amounts are those +# in the 2024 example. Ryder aggregates cash and second-car countable value +# into the published countable resource input because these assets have no +# separate SSI stock input. Waiver input certifies all .1202(b)(2) conditions. +# Optional parent links identify co-resident parents across tax units. Without +# links, tax units remain a relationship proxy and cannot distinguish grandparents. + +# 20 CFR 416.1202(b)(1), 416.1205: one-parent allowance2000; two-parent allowance3000. +- name: One and two ineligible parents below at and above each allowance + period: 2026-01 + input: + people: + n1_total1999_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 1999 + n1_total1999_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n1_total2000_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2000 + n1_total2000_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n1_total2001_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2001 + n1_total2001_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n1_total3999_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 3999 + n1_total3999_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n1_total4000_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 4000 + n1_total4000_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n1_total4001_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 4001 + n1_total4001_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n2_total2999_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 1499 + n2_total2999_parent1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 1500 + n2_total2999_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n2_total3000_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 1500 + n2_total3000_parent1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 1500 + n2_total3000_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n2_total3001_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 1500 + n2_total3001_parent1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 1501 + n2_total3001_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n2_total4999_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2499 + n2_total4999_parent1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + n2_total4999_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n2_total5000_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + n2_total5000_parent1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + n2_total5000_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + n2_total5001_parent0: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + n2_total5001_parent1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 2501 + n2_total5001_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + n1_total1999: + members: [n1_total1999_parent0, n1_total1999_child] + n1_total2000: + members: [n1_total2000_parent0, n1_total2000_child] + n1_total2001: + members: [n1_total2001_parent0, n1_total2001_child] + n1_total3999: + members: [n1_total3999_parent0, n1_total3999_child] + n1_total4000: + members: [n1_total4000_parent0, n1_total4000_child] + n1_total4001: + members: [n1_total4001_parent0, n1_total4001_child] + n2_total2999: + members: [n2_total2999_parent0, n2_total2999_parent1, n2_total2999_child] + n2_total3000: + members: [n2_total3000_parent0, n2_total3000_parent1, n2_total3000_child] + n2_total3001: + members: [n2_total3001_parent0, n2_total3001_parent1, n2_total3001_child] + n2_total4999: + members: [n2_total4999_parent0, n2_total4999_parent1, n2_total4999_child] + n2_total5000: + members: [n2_total5000_parent0, n2_total5000_parent1, n2_total5000_child] + n2_total5001: + members: [n2_total5001_parent0, n2_total5001_parent1, n2_total5001_child] + marital_units: + n1_total1999_n1_total1999_parent0: + members: [n1_total1999_parent0] + n1_total1999_n1_total1999_child: + members: [n1_total1999_child] + n1_total2000_n1_total2000_parent0: + members: [n1_total2000_parent0] + n1_total2000_n1_total2000_child: + members: [n1_total2000_child] + n1_total2001_n1_total2001_parent0: + members: [n1_total2001_parent0] + n1_total2001_n1_total2001_child: + members: [n1_total2001_child] + n1_total3999_n1_total3999_parent0: + members: [n1_total3999_parent0] + n1_total3999_n1_total3999_child: + members: [n1_total3999_child] + n1_total4000_n1_total4000_parent0: + members: [n1_total4000_parent0] + n1_total4000_n1_total4000_child: + members: [n1_total4000_child] + n1_total4001_n1_total4001_parent0: + members: [n1_total4001_parent0] + n1_total4001_n1_total4001_child: + members: [n1_total4001_child] + n2_total2999_couple_0: + members: [n2_total2999_parent0, n2_total2999_parent1] + n2_total2999_n2_total2999_child: + members: [n2_total2999_child] + n2_total3000_couple_0: + members: [n2_total3000_parent0, n2_total3000_parent1] + n2_total3000_n2_total3000_child: + members: [n2_total3000_child] + n2_total3001_couple_0: + members: [n2_total3001_parent0, n2_total3001_parent1] + n2_total3001_n2_total3001_child: + members: [n2_total3001_child] + n2_total4999_couple_0: + members: [n2_total4999_parent0, n2_total4999_parent1] + n2_total4999_n2_total4999_child: + members: [n2_total4999_child] + n2_total5000_couple_0: + members: [n2_total5000_parent0, n2_total5000_parent1] + n2_total5000_n2_total5000_child: + members: [n2_total5000_child] + n2_total5001_couple_0: + members: [n2_total5001_parent0, n2_total5001_parent1] + n2_total5001_n2_total5001_child: + members: [n2_total5001_child] + households: + n1_total1999: + members: [n1_total1999_parent0, n1_total1999_child] + n1_total2000: + members: [n1_total2000_parent0, n1_total2000_child] + n1_total2001: + members: [n1_total2001_parent0, n1_total2001_child] + n1_total3999: + members: [n1_total3999_parent0, n1_total3999_child] + n1_total4000: + members: [n1_total4000_parent0, n1_total4000_child] + n1_total4001: + members: [n1_total4001_parent0, n1_total4001_child] + n2_total2999: + members: [n2_total2999_parent0, n2_total2999_parent1, n2_total2999_child] + n2_total3000: + members: [n2_total3000_parent0, n2_total3000_parent1, n2_total3000_child] + n2_total3001: + members: [n2_total3001_parent0, n2_total3001_parent1, n2_total3001_child] + n2_total4999: + members: [n2_total4999_parent0, n2_total4999_parent1, n2_total4999_child] + n2_total5000: + members: [n2_total5000_parent0, n2_total5000_parent1, n2_total5000_child] + n2_total5001: + members: [n2_total5001_parent0, n2_total5001_parent1, n2_total5001_child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0, 0, 0, 1, 0, 1999, 0, 2000, 0, 2001, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1999, 0, 0, 2000, 0, 0, 2001] + meets_ssi_resource_test: [true, true, true, true, false, true, false, true, false, true, false, false, true, true, true, true, true, true, true, true, true, false, false, true, false, false, true, false, false, false] + +# 20 CFR 416.1202(b)(1), 416.1205: add parental excess500 to child1499/1500/1501. +- name: Child own resources plus parental excess include the individual limit boundary + period: 2026-01 + input: + people: + own1499_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + own1499_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 1499 + own1500_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + own1500_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 1500 + own1501_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + own1501_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 1501 + tax_units: + own1499: + members: [own1499_parent, own1499_child] + own1500: + members: [own1500_parent, own1500_child] + own1501: + members: [own1501_parent, own1501_child] + marital_units: + own1499_own1499_parent: + members: [own1499_parent] + own1499_own1499_child: + members: [own1499_child] + own1500_own1500_parent: + members: [own1500_parent] + own1500_own1500_child: + members: [own1500_child] + own1501_own1501_parent: + members: [own1501_parent] + own1501_own1501_child: + members: [own1501_child] + households: + own1499: + members: [own1499_parent, own1499_child] + own1500: + members: [own1500_parent, own1500_child] + own1501: + members: [own1501_parent, own1501_child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 500, 0, 500, 0, 500] + meets_ssi_resource_test: [false, true, false, true, false, false] + +# POMS SI 01330.340 White example: parent pool1200 <=couple limit3000; child deemed0. +- name: POMS White eligible parent leaves no excess resources to deem + period: 2024-01 + input: + people: + other_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2024': 1000 + abd_parent: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2024': 200 + child: + age: 6 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2024': 0 + tax_units: + family: + members: [other_parent, abd_parent, child] + marital_units: + family_couple_0: + members: [other_parent, abd_parent] + family_child: + members: [child] + households: + family: + members: [other_parent, abd_parent, child] + output: + is_ssi_resource_deeming_child: [false, false, true] + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0] + meets_ssi_resource_test: [true, true, true] + +# POMS SI 01330.340 Ryder example: parent pool3100-3000=100; Mary owns200. +- name: POMS Ryder parent failing resources contributes to the parental pool + period: 2024-01 + input: + people: + other_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2024': 2000 + abd_parent: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2024': 1100 + child: + age: 14 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2024': 200 + tax_units: + family: + members: [other_parent, abd_parent, child] + marital_units: + family_couple_0: + members: [other_parent, abd_parent] + family_child: + members: [child] + households: + family: + members: [other_parent, abd_parent, child] + output: + is_ssi_resource_deeming_child: [false, false, true] + ssi_resources_deemed_from_ineligible_parent: [0, 0, 100] + meets_ssi_resource_test: [true, false, true] + +# POMS SI 01330.280 Blake example: parents4000-3000=1000; Thomas owns0. +- name: POMS Blake two parents with 4000 resources deem 1000 to Thomas + period: 2026-01 + input: + people: + first_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2000 + second_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 2000 + thomas: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [first_parent, second_parent, thomas] + marital_units: + family_couple_0: + members: [first_parent, second_parent] + family_thomas: + members: [thomas] + households: + family: + members: [first_parent, second_parent, thomas] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 1000] + meets_ssi_resource_test: [true, true, true] + +# POMS SI 01330.280 Goode example: (parents4800-3000)/2=900 each. +- name: POMS Goode parent and stepparent divide excess equally between two children + period: 2026-01 + input: + people: + parker: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2700 + peter: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: false + ssi_countable_resources: + '2026': 2100 + john: + age: 15 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 225 + joan: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 500 + tax_units: + family: + members: [parker, peter, john, joan] + marital_units: + family_couple_0: + members: [parker, peter] + family_john: + members: [john] + family_joan: + members: [joan] + households: + family: + members: [parker, peter, john, joan] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 900, 900] + meets_ssi_resource_test: [false, false, true, true] + +# POMS SI 01330.280 Goode cascade: Joan1500+900 fails; John225+1800 then fails. +- name: POMS Goode resource failure reallocates parental excess to the remaining child + period: 2026-01 + input: + people: + parker: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2700 + peter: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: false + ssi_countable_resources: + '2026': 2100 + john: + age: 15 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 225 + joan: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 1500 + tax_units: + family: + members: [parker, peter, john, joan] + marital_units: + family_couple_0: + members: [parker, peter] + family_john: + members: [john] + family_joan: + members: [joan] + households: + family: + members: [parker, peter, john, joan] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 1800, 900] + meets_ssi_resource_test: [false, false, false, false] + +# POMS SI 01330.200.B: remove any child found otherwise ineligible before allocation. +- name: Otherwise ineligible child does not share the parental resource allocation + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 3800 + first_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 225 + ineligible_child: + age: 15 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + ssi_resource_deeming_child_is_ineligible: true + tax_units: + family: + members: [parent, first_child, ineligible_child] + marital_units: + family_parent: + members: [parent] + family_first_child: + members: [first_child] + family_ineligible_child: + members: [ineligible_child] + households: + family: + members: [parent, first_child, ineligible_child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 1800, 0] + meets_ssi_resource_test: [false, false, true] + +# POMS SI 01330.200.B: a child already over their own limit is ineligible before deeming. +- name: Child already over own resource limit receives no parental share + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 2001 + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, false] + +# 20 CFR 416.1202(b)(1)(ii)-(iii): subtract certified excluded military pay first. +- name: Qualified military pay is excluded before the parental allowance + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 5000 + ssi_resource_deeming_excluded_military_resources: 2000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 1000] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1202(b)(1): the child must be under age 18. +- name: Parental deeming stops when a child attains age eighteen + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 18 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 100 + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1202(b)(1), 416.1851(c): child must live in the parental household. +- name: Medical treatment facility exception prevents parental resource deeming + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 100 + ssi_lives_in_medical_treatment_facility: true + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1202(b)(2)(i)-(iii): the input certifies all three conditions. +- name: Certified Medicaid waiver exception prevents parental resource deeming + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 100 + ssi_resource_deeming_waiver: true + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1202(c): the eligible individual must be aged, blind, or disabled. +- name: A non ABD child receives no deemed resources + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 100 + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1856(c): the child must not be the head of a household. +- name: A child household head does not meet the SSI child definition + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 100 + is_household_head: true + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1202(b)(1): the adult must be a natural or adoptive parent, or stepparent. +- name: An adult without a parent relationship does not deem to a child + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: false + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + is_parent: false + ssi_countable_resources: + '2026': 100 + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + family: + members: [parent, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 20 CFR 416.1202(b)(1), 416.1856(b): the child must be unmarried. +- name: Married child is not subject to parental resource deeming + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 100 + child_spouse: + age: 19 + is_ssi_aged_blind_disabled: false + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [parent, child, child_spouse] + marital_units: + family_couple_0: + members: [child, child_spouse] + family_parent: + members: [parent] + households: + family: + members: [parent, child, child_spouse] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0] + +# 20 CFR 416.1202(b)(1): the parent and child must live in the same household. +- name: Nonresident parent resources are not deemed within a shared tax unit + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 6000 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [parent, child] + marital_units: + family_parent: + members: [parent] + family_child: + members: [child] + households: + parent_home: + members: [parent] + child_home: + members: [child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0] + meets_ssi_resource_test: [false, true] + +# 416.1202(b)(1): parent identity and residence govern, not tax filing. +# SI 01330.200 B: divide only among eligible children sharing those deemors. +- name: Linked grandparent resources are not deemed to the grandchild + period: 2026-01 + input: + people: + grandparent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: &id002 + '2026': 5000 + person_id: 1 + is_parent: true + mother: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: &id003 + '2026': 0 + person_id: 2 + parent_1_id: 1 + is_parent: true + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: &id004 + '2026': 1 + person_id: 3 + parent_1_id: 2 + tax_units: + family: + members: &id001 + - grandparent + - mother + - child + marital_units: + grandparent: + members: + - grandparent + mother: + members: + - mother + child: + members: + - child + households: + home: + members: *id001 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 0 + meets_ssi_resource_test: + - false + - true + - true +- name: Without parent links the tax unit proxy cannot distinguish a grandparent + period: 2026-01 + input: + people: + grandparent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: *id002 + is_parent: true + mother: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: *id003 + is_parent: true + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: *id004 + tax_units: + family: + members: &id005 + - grandparent + - mother + - child + marital_units: + grandparent: + members: + - grandparent + mother: + members: + - mother + child: + members: + - child + households: + home: + members: *id005 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 2000 + meets_ssi_resource_test: + - false + - true + - false +- name: Linked co-resident parent deems resources across separate tax units + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 4001 + person_id: 1 + is_parent: true + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 2 + parent_1_id: 1 + tax_units: + parent_unit: + members: + - parent + child_unit: + members: + - child + marital_units: + parent: + members: + - parent + child: + members: + - child + households: + home: + members: + - parent + - child + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 2001 + meets_ssi_resource_test: + - false + - false +- name: Linked siblings across tax units redistribute the same parental excess + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 4800 + person_id: 1 + is_parent: true + first_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 225 + person_id: 2 + parent_1_id: 1 + second_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 1500 + person_id: 3 + parent_2_id: 1 + tax_units: + parent_unit: + members: + - parent + first_unit: + members: + - first_child + second_unit: + members: + - second_child + marital_units: + parent: + members: + - parent + first_child: + members: + - first_child + second_child: + members: + - second_child + households: + home: + members: + - parent + - first_child + - second_child + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 2800 + - 1400 + meets_ssi_resource_test: + - false + - false + - false +- name: Linked children in one tax unit allocate only their own parents resources + period: 2026-01 + input: + people: + first_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 5000 + person_id: 1 + is_parent: true + second_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 2000 + person_id: 2 + is_parent: true + first_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 1 + person_id: 3 + parent_1_id: 1 + second_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 4 + parent_1_id: 2 + tax_units: + family: + members: &id006 + - first_parent + - second_parent + - first_child + - second_child + marital_units: + first_parent: + members: + - first_parent + second_parent: + members: + - second_parent + first_child: + members: + - first_child + second_child: + members: + - second_child + households: + home: + members: *id006 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 3000 + - 0 + meets_ssi_resource_test: + - false + - true + - false + - true +- name: A fully linked parent is not presumed parent of an unlinked child + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 5000 + person_id: 1 + is_parent: true + own_children_in_household: 1 + linked_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 2 + parent_1_id: 1 + unlinked_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + tax_units: + family: + members: &id007 + - parent + - linked_child + - unlinked_child + marital_units: + parent: + members: + - parent + linked_child: + members: + - linked_child + unlinked_child: + members: + - unlinked_child + households: + home: + members: *id007 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 3000 + - 0 + meets_ssi_resource_test: + - false + - false + - true +- name: Linked and unlinked children sharing actual proxy deemors divide equally + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 5000 + person_id: 1 + is_parent: true + own_children_in_household: 2 + linked_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 2 + parent_1_id: 1 + unlinked_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + tax_units: + family: + members: &id008 + - parent + - linked_child + - unlinked_child + marital_units: + parent: + members: + - parent + linked_child: + members: + - linked_child + unlinked_child: + members: + - unlinked_child + households: + home: + members: *id008 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 1500 + - 1500 + meets_ssi_resource_test: + - false + - true + - true +- name: Linked parent established spouse is included without a second parent id + period: 2026-01 + input: + people: + parent: &id010 + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 2500 + person_id: 1 + is_parent: true + step_parent: &id011 + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 2501 + person_id: 2 + is_parent: false + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + parent_1_id: 1 + tax_units: + family: + members: &id009 + - parent + - step_parent + - child + marital_units: + parents: + members: + - parent + - step_parent + child: + members: + - child + households: + home: + members: *id009 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 2001 + meets_ssi_resource_test: + - false + - false + - false +- name: A named step parent already in the linked deemor pool counts once + period: 2026-01 + input: + people: + parent: *id010 + step_parent: *id011 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + tax_units: + family: + members: &id012 + - parent + - step_parent + - child + marital_units: + parents: + members: + - parent + - step_parent + child: + members: + - child + households: + home: + members: *id012 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 2001 + meets_ssi_resource_test: + - false + - false + - false +- name: Linked parent's spouse filing separately counts across tax units + period: 2026-01 + input: + people: + parent: *id010 + step_parent: *id011 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + parent_1_id: 1 + tax_units: + parent_unit: + members: + - parent + cohabitating_spouses: true + step_unit: + members: + - step_parent + child_unit: + members: + - child + marital_units: + parents: + members: + - parent + - step_parent + child: + members: + - child + households: + home: + members: + - parent + - step_parent + - child + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 2001 +- name: Absent linked parent does not substitute a different resident parent + period: 2026-01 + input: + people: + absent_parent: *id010 + other_parent: *id011 + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + parent_1_id: 1 + tax_units: + family: + members: + - absent_parent + - other_parent + - child + marital_units: + absent_parent: + members: + - absent_parent + other_parent: + members: + - other_parent + child: + members: + - child + households: + absent_home: + members: + - absent_parent + child_home: + members: + - other_parent + - child + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 0 +- name: Immigration ineligible sibling does not dilute citizen child parental excess + period: 2026-01 + input: + people: + parent: &id014 + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 5000 + is_parent: true + citizen_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + immigration_status: CITIZEN + undocumented_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + immigration_status: UNDOCUMENTED + is_ssi_qualified_noncitizen: false + tax_units: + family: + members: &id013 + - parent + - citizen_child + - undocumented_child + marital_units: + parent: + members: + - parent + citizen_child: + members: + - citizen_child + undocumented_child: + members: + - undocumented_child + households: + home: + members: *id013 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 3000 + - 0 + meets_ssi_resource_test: + - false + - false + - true + is_ssi_resource_deeming_child: + - false + - true + - false + is_ssi_eligible: + - false + - false + - false +- name: Qualified noncitizen child shares parental resources with a citizen sibling + period: 2026-01 + input: + people: + parent: *id014 + citizen_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + immigration_status: CITIZEN + qualified_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + immigration_status: REFUGEE + is_ssi_qualified_noncitizen: true + tax_units: + family: + members: &id015 + - parent + - citizen_child + - qualified_child + marital_units: + parent: + members: + - parent + citizen_child: + members: + - citizen_child + qualified_child: + members: + - qualified_child + households: + home: + members: *id015 + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 1500 + - 1500 + meets_ssi_resource_test: + - false + - true + - true + is_ssi_resource_deeming_child: + - false + - true + - true +# A two-person marital unit is a married, cohabiting couple, whatever their +# tax filing: 2,500 + 2,501 less the 3,000 couple allowance leaves 2,001. +- name: Linked parent's partner in a two-person marital unit counts across tax units + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 2500 + person_id: 1 + is_parent: true + step_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + is_household_head: false + ssi_countable_resources: + '2026': 2501 + person_id: 2 + is_parent: false + child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + ssi_countable_resources: + '2026': 0 + person_id: 3 + parent_1_id: 1 + tax_units: + parent_unit: + members: + - parent + step_unit: + members: + - step_parent + child_unit: + members: + - child + marital_units: + parents: + members: + - parent + - step_parent + child: + members: + - child + households: + home: + members: + - parent + - step_parent + - child + output: + ssi_resources_deemed_from_ineligible_parent: + - 0 + - 0 + - 2001 + +# 416.1202(b)(1) and POMS SI 01330.200 A deem 'the countable resources of the +# parent(s) or spouse of a parent'. Without parent ids, a parent's spouse who +# files separately still counts: 2,500 + 5,000 less the 3,000 couple +# allowance leaves 4,500. +- name: Without parent links a parent's spouse in another tax unit counts + period: 2026-01 + input: + people: + mother: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + stepfather: + age: 42 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + ssi_countable_resources: + '2026': 5000 + child: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + mother_unit: + members: [mother, child] + cohabitating_spouses: true + stepfather_unit: + members: [stepfather] + marital_units: + parents: + members: [mother, stepfather] + child_unit: + members: [child] + households: + home: + members: [mother, stepfather, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 4500] + meets_ssi_resource_test: [false, false, false] + +# The same when the stepparent claims the child and the parent files alone: +# 416.1202(b)(1) counts the parent and 'his or her ineligible stepparent'. +# 5,001 + 0 less the 3,000 couple allowance leaves 2,001. +- name: Without parent links a parent filing apart from the stepparent who claims the child counts + period: 2026-01 + input: + people: + mother: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 5001 + stepfather: + age: 42 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: false + ssi_countable_resources: + '2026': 0 + child: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + mother_unit: + members: [mother] + stepfather_unit: + members: [stepfather, child] + marital_units: + parents: + members: [mother, stepfather] + child_unit: + members: [child] + households: + home: + members: [mother, stepfather, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 2001] + meets_ssi_resource_test: [false, true, false] + +# A situation that omits marital units puts the parent and the child they +# claim in one default unit; that does not marry the child (416.1856), so +# the parent's 5,000 less the 2,000 individual allowance is deemed. +- name: Omitted marital units do not marry a child to the parent who claims them + period: 2026-01 + input: + people: + parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 5000 + child: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [parent, child] + households: + home: + members: [parent, child] + output: + is_ssi_resource_deeming_child: [false, true] + ssi_resources_deemed_from_ineligible_parent: [0, 3000] + meets_ssi_resource_test: [false, false] + +# SI 01330.200 B divides the excess equally among the children sharing the +# deemors. Three couples are split across two tax units, each claiming one +# child; both children have the same six deemors (6,000 less the 3,000 +# couple allowance = 3,000), so each receives 1,500 and passes. +- name: Children in two tax units with the same six deemors share one pool + period: 2026-01 + input: + people: + a1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 1000 + b1: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 1000 + a2: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_countable_resources: + '2026': 1000 + b2: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_countable_resources: + '2026': 1000 + a3: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_countable_resources: + '2026': 1000 + b3: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_countable_resources: + '2026': 1000 + child_a: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + child_b: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + unit_a: + members: [a1, a2, a3, child_a] + unit_b: + members: [b1, b2, b3, child_b] + marital_units: + couple_1: + members: [a1, b1] + couple_2: + members: [a2, b2] + couple_3: + members: [a3, b3] + child_a_unit: + members: [child_a] + child_b_unit: + members: [child_b] + households: + home: + members: [a1, b1, a2, b2, a3, b3, child_a, child_b] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0, 0, 0, 0, 1500, 1500] + meets_ssi_resource_test: [true, true, true, true, true, true, true, true] + +# SI 01330.200 B divides the excess among SSI-eligible children. A sibling +# whose countable income already exceeds the payment is ineligible, so the +# eligible child's share is the whole 5,000 - 2,000 = 3,000 and the child +# fails. 416.1202(b) still deems the parents' resources to the sibling, who +# is deemed the same share and fails too. +- name: An income-ineligible sibling does not share the parental excess + period: 2026-01 + input: + people: + mother: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 5000 + child: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + sibling: + age: 12 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + ssi_countable_income: + '2026': 120000 + tax_units: + family: + members: [mother, child, sibling] + marital_units: + mother_unit: + members: [mother] + child_unit: + members: [child] + sibling_unit: + members: [sibling] + households: + home: + members: [mother, child, sibling] + output: + is_ssi_resource_deeming_child: [false, true, true] + ssi_resources_deemed_from_ineligible_parent: [0, 3000, 3000] + meets_ssi_resource_test: [false, false, false] + +# 416.1856: a married 17-year-old is not a child, even when the model infers +# tax roles that make the 19-year-old spouse a dependent. +- name: A married minor in an explicit marital unit is not a child for deeming + period: 2026-01 + input: + people: + mother: + age: 45 + is_ssi_aged_blind_disabled: false + is_parent: true + ssi_countable_resources: + '2026': 6000 + minor: + age: 17 + is_ssi_aged_blind_disabled: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 19 + is_ssi_aged_blind_disabled: false + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [mother, minor, spouse] + marital_units: + mother_unit: + members: [mother] + couple: + members: [minor, spouse] + households: + home: + members: [mother, minor, spouse] + output: + is_ssi_resource_deeming_child: [false, false, false] + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0] + +# An only child who is ineligible on income still has the parents' excess +# deemed (416.1202(b)), so a state supplement that reads the resource test +# does not see them pass it: 10,000 - 2,000 = 8,000. +- name: An income-ineligible only child is still deemed the whole excess + period: 2026-01 + input: + people: + mother: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 10000 + child: + age: 10 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + ssi_countable_income: + '2026': 120000 + tax_units: + family: + members: [mother, child] + marital_units: + mother_unit: + members: [mother] + child_unit: + members: [child] + households: + home: + members: [mother, child] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 8000] + meets_ssi_resource_test: [false, false] + diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.yaml new file mode 100644 index 00000000000..5278bda0132 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.yaml @@ -0,0 +1,136 @@ +# 20 CFR 416.1202(a) deems ineligible spouse resources after exclusions. +# Paragraph (a) excludes pension funds and qualified unspent retroactive military +# special pay/family separation allowance for the nine-month exclusion period. +# https://www.ecfr.gov/current/title-20/section-416.1202 +# The military input certifies the pay type, unspent amount and exclusion period. +# Countable-resource inputs already exclude pension funds; the model has no +# dedicated pension-account stock variable. The asset-source case checks liquid +# assets plus the existing home/vehicle exclusions. + +# 20 CFR 416.1202(a): all spouse countable resources are deemed regardless of availability. +- name: All countable resources held by the ineligible spouse are deemed + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 3001 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + ssi_resources_deemed_from_ineligible_spouse: [3001, 0] + meets_ssi_resource_test: [false, false] + +# 20 CFR 416.1202(a)(2)-(3): certified qualified unspent retroactive military pay is excluded. +- name: Qualified unspent military retroactive pay is excluded from spousal resources + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 4000 + ssi_resource_deeming_excluded_military_resources: 1000 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + ssi_resources_deemed_from_ineligible_spouse: [3000, 0] + meets_ssi_resource_test: [true, false] + +# 20 CFR 416.1202(a): excluded resources cannot leave negative resources to deem. +- name: Excluding military resources cannot produce negative spousal deeming + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 1000 + ssi_resource_deeming_excluded_military_resources: 1500 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + ssi_resources_deemed_from_ineligible_spouse: [0, 0] + +# 20 CFR 416.1202(a), 416.1210: count liquid assets and retain existing asset exclusions. +- name: Spouse liquid asset sources count while home and one vehicle remain excluded + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + bank_account_assets: + '2026': 1000 + stock_assets: + '2026': 1500 + bond_assets: + '2026': 501 + primary_residence_value: + '2026': 500000 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + household_vehicles_value: + '2026': 3000 + output: + ssi_countable_resources: [0, 3001] + ssi_resources_deemed_from_ineligible_spouse: [3001, 0] + meets_ssi_resource_test: [false, false] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.yaml new file mode 100644 index 00000000000..111d5625edf --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.yaml @@ -0,0 +1,328 @@ +# Resource eligibility examples are hand computed from 20 CFR 416.1202(a)-(b) +# and 416.1205: own plus deemed resources <= the applicable limit. +# https://www.ecfr.gov/current/title-20/section-416.1202 +# https://www.ecfr.gov/current/title-20/section-416.1205 +# The parental allowance follows the same individual/couple resource limit parameters. + +# 20 CFR 416.1202(a), 416.1205: combined3001 exceeds couple limit3000. +- name: Ineligible spouse holds all resources above the couple limit + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + 2026: 0 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + 2026: 3001 + tax_units: + family: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + output: + ssi_claim_is_joint: [false, false] + meets_ssi_resource_test: [false, false] + +# 20 CFR 416.1202(a), 416.1205: apply couple limit even when spouse has zero assets. +- name: Ineligible spouse with no assets still permits the couple limit + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 2001 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 0 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + meets_ssi_resource_test: [true, true] + +# 20 CFR 416.1202(a), 416.1205: combined3000 equals couple limit and passes. +- name: Spousal resources at the combined couple limit pass + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 1000 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 2000 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + meets_ssi_resource_test: [true, true] + +# 20 CFR 416.1202(a), 416.1205: combined3001 exceeds couple limit and fails. +- name: Spousal resources one dollar above the combined couple limit fail + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 1000 + spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 2001 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + meets_ssi_resource_test: [false, false] + +# 20 CFR 416.1205: jointly eligible couple resources3000 pass. +- name: Joint claim at the couple limit retains its existing result + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 1499 + spouse: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 1501 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + ssi_claim_is_joint: [true, true] + meets_ssi_resource_test: [true, true] + +# 20 CFR 416.1205: jointly eligible couple resources3001 fail. +- name: Joint claim above the couple limit retains its existing result + period: 2026-01 + input: + people: + claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 1500 + spouse: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 1501 + tax_units: + family: + members: [claimant, spouse] + marital_units: + family_couple_0: + members: [claimant, spouse] + households: + family: + members: [claimant, spouse] + output: + ssi_claim_is_joint: [true, true] + meets_ssi_resource_test: [false, false] + +# 20 CFR 416.1205(c): individual1999/2000 pass; individual2001 fails. +- name: Unmarried adults without parents retain individual limit boundaries + period: 2026-01 + input: + people: + p1999: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 1999 + p2000: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 2000 + p2001: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 2001 + tax_units: + p1999: + members: [p1999] + p2000: + members: [p2000] + p2001: + members: [p2001] + marital_units: + p1999: + members: [p1999] + p2000: + members: [p2000] + p2001: + members: [p2001] + households: + p1999: + members: [p1999] + p2000: + members: [p2000] + p2001: + members: [p2001] + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0] + ssi_resources_deemed_from_ineligible_spouse: [0, 0, 0] + meets_ssi_resource_test: [true, true, false] + +# 20 CFR 416.1202(b)(1) expressly cross-references416.1205, so reform increases allowances. +- name: Reform raises spousal joint and parental allowances to 10000 and 20000 + period: 2026-01 + input: + people: + spousal_claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spousal_spouse: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 3001 + joint_claimant: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_head: true + ssi_countable_resources: + '2026': 1500 + joint_spouse: + age: 40 + is_ssi_aged_blind_disabled: true + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 1501 + one_parent_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 4001 + one_parent_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + two_parents_parent: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_head: true + is_parent: true + ssi_countable_resources: + '2026': 2500 + two_parents_second: + age: 40 + is_ssi_aged_blind_disabled: false + is_tax_unit_spouse: true + is_parent: true + ssi_countable_resources: + '2026': 2501 + two_parents_child: + age: 16 + is_ssi_aged_blind_disabled: true + is_tax_unit_dependent: true + ssi_countable_resources: + '2026': 0 + tax_units: + spousal_family: + members: [spousal_claimant, spousal_spouse] + joint_family: + members: [joint_claimant, joint_spouse] + one_parent_family: + members: [one_parent_parent, one_parent_child] + two_parents_family: + members: [two_parents_parent, two_parents_second, two_parents_child] + marital_units: + spousal_family_couple_0: + members: [spousal_claimant, spousal_spouse] + joint_family_couple_0: + members: [joint_claimant, joint_spouse] + one_parent_family_parent: + members: [one_parent_parent] + one_parent_family_child: + members: [one_parent_child] + two_parents_family_couple_0: + members: [two_parents_parent, two_parents_second] + two_parents_family_child: + members: [two_parents_child] + households: + spousal_family: + members: [spousal_claimant, spousal_spouse] + joint_family: + members: [joint_claimant, joint_spouse] + one_parent_family: + members: [one_parent_parent, one_parent_child] + two_parents_family: + members: [two_parents_parent, two_parents_second, two_parents_child] + gov.ssa.ssi.eligibility.resources.limit.individual: 10000 + gov.ssa.ssi.eligibility.resources.limit.couple: 20000 + output: + ssi_resources_deemed_from_ineligible_parent: [0, 0, 0, 0, 0, 0, 0, 0, 0] + ssi_resources_deemed_from_ineligible_spouse: [3001, 0, 0, 0, 0, 0, 0, 0, 0] + meets_ssi_resource_test: [true, true, true, true, true, true, true, true, true] diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml index d1b72912602..42ae27d40ee 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/al_agi.yaml @@ -68,3 +68,27 @@ # AGI leaves all of them on the child's return. adjusted_gross_income: 40_000 al_agi: 48_500 + +- name: A beneficiary's estate or trust income is in Alabama AGI + period: 2025 + input: + state_code: AL + irs_employment_income: 30_000 + estate_income: 5_000 + output: + # Form 40, Part I, line 5 carries Schedule E estate and trust income: + # 30,000 + 5,000. + al_agi: 35_000 + +- name: A beneficiary's estate or trust loss on Schedule E reduces Alabama AGI + period: 2025 + input: + state_code: AL + irs_employment_income: 30_000 + estate_income: -5_000 + output: + # Code of Ala. § 40-18-25(b) follows federal Subchapter J for a + # beneficiary's income and deductions, so depreciation, depletion and + # amortization apportioned to the beneficiary can exceed the income: + # 30,000 - 5,000. + al_agi: 25_000 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/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml index b95a58c4a11..f5786bf4c18 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml @@ -112,3 +112,35 @@ spm_unit_capped_housing_subsidy: 25 output: ca_cpuc_countable_income: 1_000 + +- name: SNAP is excluded before the housing assistance exclusion. + period: 2013 + input: + age: 40 + state_code: CA + employment_income: 1_000 + housing_assistance: 500 + snap: 1_200 + tanf: 0 + ssi: 0 + wic: 0 + output: + # $1,000 wages + $500 housing; SNAP is excluded under 7 U.S.C. 2017(b). + # https://www.govinfo.gov/content/pkg/USCODE-2024-title7/html/USCODE-2024-title7-chap51-sec2017.htm + ca_cpuc_countable_income: 1_500 + +- name: SNAP cannot push income at the CARE limit above income eligibility. + period: 2026 + input: + age: 40 + state_code: CA + employment_income: 43_280 + snap: 1_200 + ca_care_categorically_eligible: false + output: + # CARE uses the two-person poverty line for a one-person household: + # 200% * ($15,960 + $5,680) = $43,280. SNAP adds $0 to countable income + # under 7 U.S.C. 2017(b), so income remains exactly at the limit. + # https://www.govinfo.gov/content/pkg/USCODE-2024-title7/html/USCODE-2024-title7-chap51-sec2017.htm + ca_cpuc_countable_income: 43_280 + ca_care_income_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.yaml index 2af9d8adf5d..5df426499a1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.yaml @@ -1,6 +1,6 @@ # Schedule P (540) Part I, line 19 / line 21. # https://www.ftb.ca.gov/forms/2024/2024-540-p.pdf#page=1 -# AMTI = regular taxable income (line 15) + total adjustments/preferences +# AMTI = signed taxable income (line 15) + total adjustments/preferences # (line 14) - restored itemized deductions limitation (line 18). # Disallowed itemized deductions (property taxes, medical) are added back # once via ca_amti_adjustments; the full pre-limitation itemized deductions @@ -62,3 +62,28 @@ ca_itemized_deductions: 0 output: ca_pre_exemption_amti: 80_000 + + +- name: Standard deduction above income cancels against its AMT add-back + period: 2025 + input: + state_code: CA + ca_agi: 1_000 + ca_standard_deduction: 5_706 + ca_itemized_deductions_pre_limitation: 0 + ca_itemized_deductions: 0 + output: + ca_taxable_income: 0 + ca_pre_exemption_amti: 1_000 + +- name: Negative income stays signed after the standard deduction AMT add-back + period: 2025 + input: + state_code: CA + ca_agi: -1_000 + ca_standard_deduction: 5_706 + ca_itemized_deductions_pre_limitation: 0 + ca_itemized_deductions: 0 + output: + ca_taxable_income: 0 + ca_pre_exemption_amti: -1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml index 55d4814432b..181d814a34b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml @@ -52,3 +52,56 @@ households: {household: {members: [head, spouse], state_code: CA}} output: ca_amt: 0 + + +- name: Excess employee deductions do not create California AMT below the exemption + # Schedule P line 15 uses -99,000, not the zero on Form 540 line 19. + # https://www.ftb.ca.gov/forms/2025/2025-540-p-instructions.html + period: 2025 + absolute_error_margin: 0.02 + input: + people: + head: + age: 40 + employment_income: 50_000 + unreimbursed_business_employee_expenses: 150_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: CA + output: + ca_amt: 0 + ca_misc_deduction: 149_000 + ca_deductions: 149_000 + ca_taxable_income: 0 + ca_pre_exemption_amti: 50_000 + ca_amti: 50_000 + +- name: California AMT with income exceeding deductions is unchanged + period: 2025 + absolute_error_margin: 0.02 + input: + people: + head: + age: 40 + employment_income: 200_000 + unreimbursed_business_employee_expenses: 100_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: CA + output: + ca_amt: 1_396.932 + ca_misc_deduction: 96_000 + ca_deductions: 96_000 + ca_taxable_income: 104_000 + ca_pre_exemption_amti: 200_000 + ca_amti: 200_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.yaml index b4084d14bb0..ad6c7e7373b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.yaml @@ -133,3 +133,95 @@ state_code: CA output: ca_eitc_earned_income: 18_587.045 + +- name: California credit earnings retain payroll HSA contributions and exclude deferrals and premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + pre_tax_health_insurance_premiums: 500 + health_savings_account_payroll_contributions: 1000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: CA + output: + eitc_earned_income: 6500 + filer_adjusted_earnings: 6500 + ca_eitc_earned_income: 7500 + +- name: California wage zero floor is applied before including state taxable HSA amounts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 1000 + traditional_401k_contributions_desired: 600 + health_savings_account_payroll_contributions: 700 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: CA + output: + eitc_earned_income: 0 + filer_adjusted_earnings: 0 + ca_eitc_earned_income: 400 + +- name: California HSA wage inclusion is netted against self-employment losses before the credit floor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 1000 + health_savings_account_payroll_contributions: 500 + self_employment_income: -1200 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: CA + output: + eitc_earned_income: 0 + filer_adjusted_earnings: 0 + ca_eitc_earned_income: 0 + +- name: California credit wages exclude traditional elective deferrals + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: CA + output: + eitc_earned_income: 8000 + filer_adjusted_earnings: 8000 + ca_eitc_earned_income: 8000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/young_child/ca_yctc.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/young_child/ca_yctc.yaml index 62833e2fc63..11ae453eb42 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/young_child/ca_yctc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/young_child/ca_yctc.yaml @@ -210,6 +210,85 @@ # Earned income -10,000; net loss 10,000; wages 20,000. ca_yctc: 1_083 +# R&TC 17052.1(b)(1)(B)(iii), (b)(2); 2025 FTB 3514 lines 13 and 23a: +# the wage ceiling uses California taxable wages (W-2 box 16), excluding +# traditional elective deferrals. Line 23 uses the net earned income on line 19. +- name: Traditional deferrals and a business loss leave zero earned income and eligible California taxable wages + period: 2025 + input: + people: + parent: + age: 35 + employment_income: 40_000 + traditional_401k_contributions_desired: 10_000 + self_employment_income: -30_000 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CA + output: + # FTB 3514 line 13 feeds the line 23a compensation ceiling. + ca_taxable_wages: [30_000, 0] + ca_eitc_earned_income: 0 + ca_eitc: 0 + ca_agi: 0 + ca_yctc: 1_189 + +- name: California taxable wages exactly at the zero-earned-income YCTC ceiling remain eligible + period: 2025 + input: + people: + parent: + age: 35 + employment_income: 45_640 + traditional_401k_contributions_desired: 10_000 + self_employment_income: -35_640 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CA + output: + ca_taxable_wages: [35_640, 0] + ca_eitc_earned_income: 0 + ca_eitc: 0 + ca_agi: 0 + ca_yctc: 1_189 + +- name: California taxable wages one dollar above the zero-earned-income YCTC ceiling are ineligible + period: 2025 + input: + people: + parent: + age: 35 + employment_income: 45_641 + traditional_401k_contributions_desired: 10_000 + self_employment_income: -35_641 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CA + output: + ca_taxable_wages: [35_641, 0] + ca_eitc_earned_income: 0 + ca_eitc: 0 + ca_agi: 0 + ca_yctc: 0 + - name: Wages above $32,490 with negative earned income, so not eligible in 2022. period: 2022 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_casualty_loss_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_casualty_loss_deduction.yaml new file mode 100644 index 00000000000..ff9f57920e5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_casualty_loss_deduction.yaml @@ -0,0 +1,198 @@ +# 2024 Schedule CA (540) instructions, line 15 (Form 540 booklet, PDF p. 66): +# federal law allows the personal casualty and theft loss deduction only for a +# federally declared disaster; "California law does not conform." Filers +# "complete another federal Form 4684 ... using California amounts". +# Cal. Rev. & Tax. Code 17204(b) (as amended by SB 711, 2025): 26 U.S.C. +# 165(h)(5) "shall not apply". +# FTB Publication 1034 (PDF p. 4): "subtract $100 and then 10% of your federal +# adjusted gross income." +# 2024 Form 4684: line 11 enters $100; line 17 enters 10% of adjusted gross +# income from Form 1040 line 11; line 18 is line 16 less line 17, not below 0. +# A joint return is one individual for the $100 rule (165(h)(4)(B)). The model +# records one loss amount per person, not separate casualty events, so a +# return's losses are treated as one casualty. A dependent's loss belongs on +# the dependent's own return, as the dependent's income does. + +- name: Case 1, the $100 reduction applies before the 10 percent floor. + period: 2024 + input: + state_code: CA + employment_income: 100_000 + casualty_loss: 15_000 + output: + # Form 4684: line 12 = 15,000 - 100 = 14,900; line 17 = 10% x 100,000 + # = 10,000; line 18 = 14,900 - 10,000. + ca_casualty_loss_deduction: 4_900 + # California allows the loss though the federal deduction is suspended. + casualty_loss_deduction: 0 + +- name: Case 2, a loss $100 above the floor nets to zero. + period: 2024 + input: + state_code: CA + employment_income: 100_000 + casualty_loss: 10_100 + output: + # (10,100 - 100) - 10% x 100,000 = 10,000 - 10,000 + ca_casualty_loss_deduction: 0 + +- name: Case 3, a loss $101 above the floor leaves $1. + period: 2024 + input: + state_code: CA + employment_income: 100_000 + casualty_loss: 10_101 + output: + # (10,101 - 100) - 10% x 100,000 = 10,001 - 10,000 + ca_casualty_loss_deduction: 1 + +- name: Case 4, a loss of exactly $100 is fully absorbed by the $100 reduction. + period: 2024 + input: + state_code: CA + casualty_loss: 100 + output: + ca_casualty_loss_deduction: 0 + +- name: Case 5, with no income a loss is deductible above the $100. + period: 2024 + input: + state_code: CA + casualty_loss: 101 + output: + # (101 - 100) - 10% x 0 + ca_casualty_loss_deduction: 1 + +- name: Case 6, Publication 547 Example 1 with $80,000 of AGI. + period: 2024 + input: + state_code: CA + employment_income: 80_000 + casualty_loss: 14_800 + output: + # IRS Publication 547 (2017, PDF p. 10), Example 1: loss after + # reimbursement 14,800; subtract $100 = 14,700; subtract 10% of 80,000 AGI + # = 6,700. + ca_casualty_loss_deduction: 6_700 + +- name: Case 7, the floor uses federal AGI, not California AGI. + period: 2024 + input: + state_code: CA + adjusted_gross_income: 100_000 + ca_agi: 60_000 + casualty_loss: 15_000 + output: + # (15,000 - 100) - 10% x 100,000 federal AGI = 4,900. On California AGI it + # would be 14,900 - 6,000 = 8,900. + ca_casualty_loss_deduction: 4_900 + +- name: Case 8, a negative federal AGI gives no floor, not a larger loss. + period: 2024 + input: + state_code: CA + adjusted_gross_income: -20_000 + casualty_loss: 15_000 + output: + # The 10% floor is not allowed below zero: 15,000 - 100. + ca_casualty_loss_deduction: 14_900 + +- name: Case 9, a joint return reduces the spouses' pooled losses by $100 once. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 60_000 + casualty_loss: 10_000 + person2: + age: 40 + employment_income: 40_000 + casualty_loss: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: CA + output: + filing_status: JOINT + # (10,000 + 5,000 - 100) - 10% x 100,000 = 14,900 - 10,000 + ca_casualty_loss_deduction: 4_900 + +- name: Case 10, a dependent's loss is left to the dependent's own return. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 100_000 + casualty_loss: 15_000 + person2: + age: 10 + casualty_loss: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: CA + output: + is_tax_unit_dependent: [false, true] + # The child's 5,000 is not the parent's loss. + # (15,000 - 100) - 10% x 100,000 = 14,900 - 10,000 + ca_casualty_loss_deduction: 4_900 + +- name: Case 11, a loss only the dependent had gives the parent nothing. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 100_000 + person2: + age: 10 + casualty_loss: 15_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: CA + output: + is_tax_unit_dependent: [false, true] + ca_casualty_loss_deduction: 0 + +- name: Case 12, California keeps allowing the loss under its 2025 conformity date. + period: 2026 + input: + state_code: CA + employment_income: 100_000 + casualty_loss: 15_000 + output: + # R&TC 17204(b) turns 165(h)(5) off for taxable years from 2025. + ca_casualty_loss_deduction: 4_900 + casualty_loss_deduction: 0 + +- name: Case 13, the deduction is zero outside California. + period: 2024 + input: + state_code: NY + employment_income: 100_000 + casualty_loss: 15_000 + output: + ca_casualty_loss_deduction: 0 + +- name: Case 14, lifting the federal suspension does not change California's deduction. + period: 2024 + input: + gov.irs.deductions.itemized.casualty.active: true + state_code: CA + employment_income: 100_000 + casualty_loss: 15_000 + output: + # California's deduction does not read the federal switch. + ca_casualty_loss_deduction: 4_900 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions.yaml index 82c1a351f9d..a6847243c65 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions.yaml @@ -26,7 +26,7 @@ state_code: CA filing_status: SINGLE adjusted_gross_income: 100_000 - charitable_deduction: 10_000 + charitable_cash_donations: 10_000 ca_investment_interest_expense_deduction: 5_000 output: ca_itemized_deductions: 15_000 @@ -37,7 +37,7 @@ state_code: CA filing_status: SINGLE adjusted_gross_income: 212_288 + 50_000 - charitable_deduction: 10_000 + charitable_cash_donations: 10_000 ca_investment_interest_expense_deduction: 5_000 output: ca_itemized_deductions: 12_000 # charity subject to limitation @@ -51,3 +51,26 @@ ca_itemized_deductions_pre_limitation: 10_000 output: ca_itemized_deductions: 7_000 # charity subject to limitation + +# 2024 Schedule CA (540) instructions, line 29 worksheet (Form 540 booklet, +# PDF p. 68): line 2 takes medical expenses, investment interest, casualty and +# theft losses and gambling losses out before the 80%/6% limitation. Cal. Rev. +# & Tax. Code 17077 adopts 26 U.S.C. 68, which leaves 165(c)(3) losses out. +- name: California casualty loss is not subject to the high-AGI limitation + absolute_error_margin: 0.01 + period: 2024 + input: + state_code: CA + filing_status: SINGLE + adjusted_gross_income: 244_857 + 50_000 + ca_charitable_deduction: 1_000 + casualty_loss: 40_000 + output: + # Form 4684: (40,000 - 100) - 10% x 294,857 = 39,900 - 29,485.70 + ca_casualty_loss_deduction: 10_414.30 + ca_itemized_deductions_pre_limitation: 11_414.30 + # Worksheet: line 3 = 11,414.30 - 10,414.30 = 1,000; line 4 = 80% x 1,000 + # = 800; line 8 = 6% x 50,000 = 3,000; line 9 = 800. + # 11,414.30 - 800. Were the loss limited too, line 4 would be 9,131.44, + # line 9 would be 3,000 and the result 8,414.30. + ca_itemized_deductions: 10_614.30 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml index eafbae0aeac..eb522ef4498 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml @@ -15,3 +15,401 @@ real_estate_taxes: 5_000 output: ca_itemized_deductions_pre_limitation: 13_000 + +- name: California deducts charitable gifts below the federal charitable floor. + period: 2026 + input: + state_code: CA + age: 40 + employment_income: 100_000 + charitable_cash_donations: 100 + real_estate_taxes: 10_000 + output: + charitable_deduction: 0 + ca_itemized_deductions_pre_limitation: 10_100 + +- name: California retains employee expenses above two percent of federal AGI. + period: 2026 + input: + state_code: CA + age: 40 + employment_income: 100_000 + unreimbursed_business_employee_expenses: 2_400 + tax_preparation_fees: 100 + real_estate_taxes: 10_000 + output: + misc_deduction: 0 + ca_itemized_deductions_pre_limitation: 10_500 + ca_amti_adjustments: 10_500 + +- name: California charitable gifts are capped at fifty percent of federal AGI. + period: 2026 + input: + state_code: CA + age: 40 + employment_income: 100_000 + charitable_cash_donations: 60_000 + output: + charitable_deduction: 59_500 + ca_itemized_deductions_pre_limitation: 50_000 + +- name: California already retains miscellaneous deductions before 2026. + period: 2025 + input: + state_code: CA + age: 40 + employment_income: 100_000 + unreimbursed_business_employee_expenses: 2_500 + real_estate_taxes: 10_000 + output: + misc_deduction: 0 + ca_itemized_deductions_pre_limitation: 10_500 + +- name: California already caps charitable deductions before 2026. + period: 2025 + input: + state_code: CA + age: 40 + employment_income: 100_000 + charitable_cash_donations: 60_000 + output: + charitable_deduction: 60_000 + ca_itemized_deductions_pre_limitation: 50_000 + +- name: California nonconformity continues after 2026. + period: 2027 + input: + state_code: CA + age: 40 + employment_income: 100_000 + charitable_cash_donations: 100 + unreimbursed_business_employee_expenses: 2_500 + real_estate_taxes: 10_000 + output: + charitable_deduction: 0 + misc_deduction: 0 + ca_itemized_deductions_pre_limitation: 10_600 + +# PolicyBench frozen 2026 scenarios. Inputs retain all source values and the +# to_pe_household converter defaults, weekly-hours alias, and marital units. +# Filing status is derived from person relationships, as in the converter. + +- name: 'PolicyBench scenario_005: California itemized conformity' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 180000.0 + bank_account_assets: 559200.0 + charitable_cash_donations: 1069.7646484375 + charitable_non_cash_donations: 1503.7646484375 + employer_sponsored_insurance_premiums: 6889.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 1500.0 + hourly_wage: 69.19999694824219 + hours_worked_last_week: 50.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + long_term_capital_gains: 51920.0 + non_qualified_dividend_income: 9722.3525390625 + other_health_insurance_premiums: 1500.0 + other_medical_expenses: 800.0 + over_the_counter_health_expenses: 200.0 + qualified_dividend_income: 77920.0 + real_estate_taxes: 17280.0 + roth_401k_contributions_desired: 4086.0 + roth_ira_contributions_desired: 1678.0799560546875 + salt_refund_income: 4147.67626953125 + short_term_capital_gains: -62830.58984375 + stock_assets: 4000.0 + tax_exempt_interest_income: 8961.3525390625 + tax_exempt_private_pension_income: 70000.0 + taxable_interest_income: 65351.0 + traditional_401k_contributions_desired: 23154.0 + traditional_ira_contributions_desired: 1081.9200439453125 + unreimbursed_business_employee_expenses: 13847.8232421875 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 33 + employment_income: 250000.0 + employer_sponsored_insurance_premiums: 6479.69775390625 + has_esi: true + hourly_wage: 108.0 + hours_worked_last_week: 40.0 + is_tax_unit_head: false + is_tax_unit_spouse: true + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 50.0 + roth_401k_contributions_desired: 4086.0 + roth_ira_contributions_desired: 1678.0799560546875 + taxable_interest_income: 585.0 + traditional_401k_contributions_desired: 23154.0 + traditional_ira_contributions_desired: 1081.9200439453125 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + tax_units: + tax_unit: + members: + - head + - spouse + first_home_mortgage_balance: 2850000.0 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: + - head + - spouse + takes_up_snap_if_eligible: true + families: + family: + members: + - head + - spouse + households: + household: + members: + - head + - spouse + state_code: CA + household_vehicles_value: 8932.3525390625 + output: + # Also includes #10032's IRA 219(g) phase-out, since this is a two-cause + # CA + IRA cell. + state_income_tax_before_refundable_credits: 40683.11 +- name: 'PolicyBench scenario_022: California itemized conformity' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 77 + employment_income: 52635.0 + bank_account_assets: 3000.0 + charitable_cash_donations: 14693.412109375 + charitable_non_cash_donations: 700.0 + health_insurance_premiums_without_medicare_part_b: 2416.0 + is_surviving_spouse: true + is_tax_unit_head: true + is_tax_unit_spouse: false + miscellaneous_income: 3300.0 + other_health_insurance_premiums: 2416.0 + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 500.0 + real_estate_taxes: 8829.0 + rental_income: 4550.0 + social_security_retirement: 20735.0 + stock_assets: 263200.0 + tax_exempt_private_pension_income: 370.5882263183594 + taxable_interest_income: 50.0 + taxable_private_pension_income: 30989.412109375 + unadjusted_basis_qualified_property: 24786.0625 + unreimbursed_business_employee_expenses: 8350.5439453125 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + tax_units: + tax_unit: + members: + - head + first_home_mortgage_balance: 212852.9375 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: + - head + takes_up_snap_if_eligible: true + families: + family: + members: + - head + households: + household: + members: + - head + state_code: CA + household_vehicles_value: 20782.353515625 + output: + state_income_tax_before_refundable_credits: 1902.59 +- name: 'PolicyBench scenario_099: California itemized conformity' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 42 + employment_income: 85000.0 + employer_sponsored_insurance_premiums: 18707.525390625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2500.0 + hourly_wage: 42.0 + hours_worked_last_week: 40.0 + is_paid_hourly: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 2500.0 + other_medical_expenses: 2000.0 + over_the_counter_health_expenses: 500.0 + roth_401k_contributions_desired: 272.3999938964844 + roth_ira_contributions_desired: 111.87200164794922 + taxable_interest_income: 500.0 + traditional_401k_contributions_desired: 1543.5999755859375 + traditional_ira_contributions_desired: 72.12799835205078 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + spouse: + age: 41 + employment_income: 80000.0 + bank_account_assets: 28035.294921875 + charitable_cash_donations: 11932.94140625 + charitable_non_cash_donations: 688.058837890625 + educator_expense: 337.5 + employer_sponsored_insurance_premiums: 5889.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2500.0 + hourly_wage: 30.799999237060547 + hours_worked_last_week: 50.0 + is_tax_unit_head: false + is_tax_unit_spouse: true + long_term_capital_gains: -7018.0 + non_qualified_dividend_income: 16.0 + other_health_insurance_premiums: 2500.0 + other_medical_expenses: 2000.0 + over_the_counter_health_expenses: 500.0 + pre_subsidy_rent: 26400.0 + qualified_dividend_income: 128.0 + real_estate_taxes: 8653.5 + roth_401k_contributions_desired: 272.3999938964844 + roth_ira_contributions_desired: 111.87200164794922 + short_term_capital_gains: 540.0 + stock_assets: 2230000.0 + taxable_interest_income: 510.0 + traditional_401k_contributions_desired: 1543.5999755859375 + traditional_ira_contributions_desired: 72.12799835205078 + unreimbursed_business_employee_expenses: 1262.6470947265625 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child1: + age: 9 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 200.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + child2: + age: 4 + employment_income: 0.0 + has_esi: true + is_tax_unit_head: false + is_tax_unit_spouse: false + other_medical_expenses: 500.0 + over_the_counter_health_expenses: 200.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit_1: + members: + - head + - spouse + marital_unit_2: + members: + - child1 + marital_unit_3: + members: + - child2 + tax_units: + tax_unit: + members: + - head + - spouse + - child1 + - child2 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: + - head + - spouse + - child1 + - child2 + spm_unit_pre_subsidy_childcare_expenses: 12740.0 + takes_up_snap_if_eligible: true + families: + family: + members: + - head + - spouse + - child1 + - child2 + households: + household: + members: + - head + - spouse + - child1 + - child2 + state_code: CA + auto_loan_balance: 42000.0 + auto_loan_interest: 1575.0 + household_vehicles_value: 21964.705078125 + output: + # Also includes #10032's IRA 219(g) phase-out, since this is a two-cause + # CA + IRA cell. + state_income_tax_before_refundable_credits: 4441.45 + +# 2024 Schedule CA (540) instructions, line 15 (Form 540 booklet, PDF p. 66): +# "California law does not conform" to the federal limit of personal casualty +# losses to declared disasters; filers complete a second Form 4684 with +# California amounts. +- name: California allows a non-disaster casualty loss the federal return does not. + period: 2024 + input: + state_code: CA + employment_income: 100_000 + casualty_loss: 15_000 + output: + casualty_loss_deduction: 0 + # Form 4684: (15,000 - 100) - 10% x 100,000 = 4,900 + ca_itemized_deductions_pre_limitation: 4_900 + +- name: California counts the casualty loss once when the federal suspension is lifted. + period: 2024 + input: + gov.irs.deductions.itemized.casualty.active: true + state_code: CA + employment_income: 100_000 + casualty_loss: 15_000 + output: + ca_casualty_loss_deduction: 4_900 + # The federal deduction, now positive, is replaced by California's 4,900, + # not added to it. + ca_itemized_deductions_pre_limitation: 4_900 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/test_ca_itemized_conformity.py b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/test_ca_itemized_conformity.py new file mode 100644 index 00000000000..c452e1d2553 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/test_ca_itemized_conformity.py @@ -0,0 +1,256 @@ +"""California itemized deductions must remain independent of federal reforms. + +Use leaf expense facts in vectorized household situations. Paired California +and New York households also guard against changes leaking into federal +charitable or miscellaneous deductions. +""" + +from random import Random + +import numpy as np +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + + +YEARS = (2025, 2026, 2027) +CA_VARIABLES = ( + "ca_itemized_deductions_pre_limitation", + "ca_itemized_deductions", + "ca_deductions", + "ca_amti_adjustments", + "ca_pre_exemption_amti", + "ca_amti", + "ca_charitable_deduction", + "ca_misc_deduction", +) +FEDERAL_VARIABLES = ( + "charitable_deduction", + "misc_deduction", + "itemized_deductions_less_salt", +) + + +class ChangeFederalItemizedRules(Reform): + """Make federal rules conspicuously different throughout the test years.""" + + def apply(self): + def modify(parameters): + itemized = parameters.gov.irs.deductions.itemized + updates = ( + (itemized.charity.floor.applies, True), + (itemized.charity.floor.amount, 0.25), + (itemized.charity.ceiling.all, 0.10), + (itemized.charity.ceiling.non_cash, 0.10), + (itemized.charity.ceiling.non_cash_to_non_50_pct_org, 0.10), + (itemized.misc.applies, True), + (itemized.misc.floor, 0), + ) + for parameter, value in updates: + parameter.update( + start=instant("2025-01-01"), + stop=instant("2027-12-31"), + value=value, + ) + return parameters + + self.modify_parameters(modify) + + +def _generated_facts(): + """Seeded bounded facts preserve the subset relationship of noncash gifts.""" + random = Random(17076) + facts = [] + for _ in range(24): + income = random.randint(-20_000, 250_000) + cash = random.randint(0, 160_000) + noncash = random.randint(0, 160_000) + noncash_non50 = random.randint(0, noncash) + employee_expenses = random.randint(0, 300_000) + preparation_fees = random.randint(0, 6_000) + facts.append( + ( + income, + cash, + noncash, + noncash_non50, + employee_expenses, + preparation_fees, + ) + ) + return facts + + +def _all_years(value): + return {str(year): value for year in YEARS} + + +def _situation(facts): + situation = { + entity: {} + for entity in ( + "people", + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ) + } + for case_index, fact in enumerate(facts): + income, cash, noncash, noncash_non50, employee, preparation = fact + # Third row raises two independent leaf expenses at unchanged income. + for variant, state in enumerate(("CA", "NY", "CA")): + identifier = f"case_{case_index}_{variant}" + increase = 1_000 if variant == 2 else 0 + situation["people"][identifier] = { + "age": _all_years(40), + "employment_income": _all_years(income), + "charitable_cash_donations": _all_years(cash + increase), + "charitable_non_cash_donations": _all_years(noncash), + "charitable_non_cash_donations_non_50_pct_orgs": _all_years( + noncash_non50 + ), + "unreimbursed_business_employee_expenses": _all_years(employee), + "tax_preparation_fees": _all_years(preparation + increase), + } + for entity in ( + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ): + situation[entity][identifier] = {"members": [identifier]} + situation["tax_units"][identifier]["filing_status"] = _all_years("SINGLE") + situation["households"][identifier]["state_code"] = _all_years(state) + return situation + + +def test_ca_itemized_rules_are_independent_vectorized_and_monotone(): + # Anchors force both state deduction choices and a substantive federal + # reform effect, rather than letting a run of zero gifts pass vacuously. + facts = [ + (50_000, 0, 0, 0, 0, 0), + (100_000, 30_000, 0, 0, 10_000, 0), + (200_000, 0, 0, 0, 100_000, 0), + (200_000, 0, 0, 0, 300_000, 0), + (50_000, 0, 0, 0, 150_000, 0), + (20_000, 0, 0, 0, 100_000, 0), + (50_000, 0, 0, 0, 51_000, 0), + (50_000, 0, 0, 0, 50_999, 0), + (-10_000, 1_000, 1_000, 500, 1_000, 100), + (0, 1_000, 1_000, 500, 1_000, 100), + (100_000, 100, 0, 0, 1_999, 0), + (100_000, 100, 0, 0, 2_000, 0), + (100_000, 100, 0, 0, 2_001, 0), + (100_000, 49_999, 0, 0, 0, 0), + (100_000, 50_000, 0, 0, 0, 0), + (100_000, 50_001, 0, 0, 0, 0), + (100_000, 0, 30_000, 30_000, 0, 0), + (100_000, 0, 30_001, 30_001, 0, 0), + *_generated_facts(), + ] + situation = _situation(facts) + baseline = Simulation(situation=situation, start_instant="2025-01-01") + reformed = Simulation( + situation=situation, + tax_benefit_system=baseline.tax_benefit_system, + reform=ChangeFederalItemizedRules, + start_instant="2025-01-01", + ) + ca = np.arange(0, len(facts) * 3, 3) + ny = ca + 1 + increased = ca + 2 + + for year in YEARS: + for variable in CA_VARIABLES: + original = baseline.calculate(variable, year) + changed = reformed.calculate(variable, year) + np.testing.assert_allclose(original, changed, atol=0.02, rtol=0) + np.testing.assert_array_equal(original[ny], 0) + + # Changing the state never changes these federal deduction rules. + for variable in FEDERAL_VARIABLES: + result = baseline.calculate(variable, year) + np.testing.assert_allclose(result[ca], result[ny], atol=0.02, rtol=0) + + assert not np.array_equal( + baseline.calculate("charitable_deduction", year), + reformed.calculate("charitable_deduction", year), + ) + assert not np.array_equal( + baseline.calculate("misc_deduction", year), + reformed.calculate("misc_deduction", year), + ) + + positive_agi = baseline.calculate("positive_agi", year) + charity = baseline.calculate("ca_charitable_deduction", year) + misc = baseline.calculate("ca_misc_deduction", year) + total_misc = baseline.calculate("total_misc_deductions", year) + total_gifts = np.array([fact[1] + fact[2] for fact in facts]) + assert np.all(charity[ca] >= 0) + assert np.all(charity[ca] <= total_gifts + 0.02) + assert np.all(charity[ca] <= positive_agi[ca] * 0.5 + 0.02) + assert np.all(misc[ca] >= 0) + assert np.all(misc[ca] <= total_misc[ca] + 0.02) + + # An extra dollar of cash gifts or misc expenses cannot reduce its + # deduction, or increase that deduction by more than a dollar. + for result in (charity, misc): + delta = result[increased] - result[ca] + assert np.all(delta >= -0.02) + assert np.all(delta <= 1_000.02) + above_misc_floor = total_misc[ca] >= positive_agi[ca] * 0.02 + np.testing.assert_allclose( + (misc[increased] - misc[ca])[above_misc_floor], + 1_000, + atol=0.02, + rtol=0, + ) + + itemized = baseline.calculate("ca_itemized_deductions", year) + standard = baseline.calculate("ca_standard_deduction", year) + chosen = baseline.calculate("ca_deductions", year) + np.testing.assert_array_equal( + chosen[ca], np.maximum(itemized[ca], standard[ca]) + ) + assert itemized[ca[0]] < standard[ca[0]] + assert itemized[ca[1]] > standard[ca[1]] + + # Schedule P line 15 retains the signed AGI less deductions before + # adding back AMT-disallowed deductions. These wage, gift, and misc + # facts have no AMT preferences that could raise AMTI above AGI. + agi = baseline.calculate("ca_agi", year) + adjustments = baseline.calculate("ca_amti_adjustments", year) + limitation = baseline.calculate("ca_itemized_deductions_limitation", year) + pre_exemption_amti = baseline.calculate("ca_pre_exemption_amti", year) + amti = baseline.calculate("ca_amti", year) + form_amti = agi - chosen + adjustments - limitation + np.testing.assert_allclose( + pre_exemption_amti[ca], form_amti[ca], atol=0.02, rtol=0 + ) + # Every generated tax unit is single, so the separate-filer AMTI + # adjustment does not apply. + np.testing.assert_allclose(amti[ca], form_amti[ca], atol=0.02, rtol=0) + assert np.all(pre_exemption_amti[ca] <= agi[ca] + 0.02) + + excess_deductions = chosen[ca] > agi[ca] + assert np.any(excess_deductions) + assert np.any(chosen[ca] == agi[ca]) + assert np.any(np.isclose(agi[ca] - chosen[ca], 1, atol=0.02, rtol=0)) + assert chosen[ca[2]] < agi[ca[2]] + assert pre_exemption_amti[ca[2]] > 0 + + exemption = baseline.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.states.ca.tax.income.amt.exemption.amount.SINGLE + below_exemption_excess = excess_deductions & (agi[ca] < exemption) + assert np.any(below_exemption_excess) + amt = baseline.calculate("ca_amt", year) + np.testing.assert_array_equal(amt[ca[below_exemption_excess]], 0) + # High-income filers can owe AMT when disallowed deductions exceed + # income; the zero-AMT invariant requires income below the exemption. + assert excess_deductions[3] + assert amt[ca[3]] > 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/household/state_property_tax_credits/mt_property_tax_rebate_in_aggregate.yaml b/policyengine_us/tests/policy/baseline/gov/states/household/state_property_tax_credits/mt_property_tax_rebate_in_aggregate.yaml index 3abf3612f58..3c15dbf23d6 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/household/state_property_tax_credits/mt_property_tax_rebate_in_aggregate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/household/state_property_tax_credits/mt_property_tax_rebate_in_aggregate.yaml @@ -24,3 +24,45 @@ # contributor to the aggregate is the $675 property tax rebate. mt_property_tax_rebate: 675 taxsim_state_property_tax_credit: 675 + +- name: The tax year 2024 MT property tax rebate of 400 flows into the aggregate + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + # Ch. 767, L. 2025 (SB 542), sec. 3(1): $400 for tax year 2024. Before + # the rebate amount was dated, the model carried 675 into 2024. + mt_property_tax_rebate: 400 + taxsim_state_property_tax_credit: 400 + +- name: No MT property tax rebate in the aggregate for tax year 2025 + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + mt_property_tax_rebate: 0 + taxsim_state_property_tax_credit: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_gross_income.yaml index 7337123adcd..650bcdb385a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_gross_income.yaml @@ -5,3 +5,24 @@ state_code: IA output: ia_gross_income: 100_000 + +- name: Estate and trust income is included in Iowa gross income + period: 2022 + input: + irs_employment_income: 30_000 + estate_income: 5_000 + state_code: IA + output: + # 2022 IA 1040 line 10 reports federal Schedule E income, which includes + # estates and trusts: 30,000 + 5,000. + ia_gross_income: 35_000 + +- name: An estate or trust loss on federal Schedule E reduces Iowa gross income + period: 2022 + input: + irs_employment_income: 30_000 + estate_income: -5_000 + state_code: IA + output: + # Line 10 is "the income or loss from federal Schedule E": 30,000 - 5,000. + ia_gross_income: 25_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml index 8528e9d221c..e70f41c30d1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml @@ -7,6 +7,53 @@ output: id_deductions: 1_000 +- name: Standard deduction plus premiums beats a larger itemized deduction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 17_000 + output: + id_deductions: 16_100 + id_health_insurance_premiums_subtraction: 6_000 + +- name: Combined reduction tie retains the standard deduction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 18_100 + output: + id_deductions: 16_100 + id_health_insurance_premiums_subtraction: 6_000 + +- name: A larger total reduction permits itemization + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 18_101 + output: + id_deductions: 18_101 + id_health_insurance_premiums_subtraction: 4_000 + +- name: Separate filers must itemize even without premiums + period: 2026 + input: + state_code: ID + separate_filer_itemizes: true + health_insurance_premiums: 0 + standard_deduction: 1_000 + id_itemized_deductions: 500 + output: + id_deductions: 500 + - name: Does not itemize period: 2023 input: @@ -15,3 +62,36 @@ state_code: ID output: id_deductions: 1_000 + +- name: Claimant medical overlap determines the combined deduction election + # Form 39R worksheet: claimant medical deduction is $750, not $1,250. + # Itemized route: $16,750 + $2,250 = $19,000. + # Standard route: $15,750 + $3,000 = $18,750. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 16_750 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_itemizes: true + id_deductions: 16_750 + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 11_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml index bb41c382522..d9e70d30606 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml @@ -15,3 +15,109 @@ id_non_refundable_credits: 500 output: id_income_tax_before_refundable_credits: 0 + +# Audited PolicyBench households, using their supplied person-level facts. +- name: PolicyBench scenario_007 Idaho paid health insurance premiums + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 56 + employment_income: 0.0 + bank_account_assets: 71500.0 + employer_sponsored_insurance_premiums: 6309.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2080.0 + hours_worked_last_week: 50.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 2080.0 + other_medical_expenses: 150.0 + pre_subsidy_rent: 18352.94140625 + social_security_disability: 19285.0 + tax_exempt_private_pension_income: 284.1176452636719 + taxable_private_pension_income: 35280.0 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + tax_units: + tax_unit: + members: &id001 + - head + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id001 + takes_up_snap_if_eligible: true + families: + family: + members: *id001 + marital_units: + marital_unit: + members: *id001 + households: + household: + members: *id001 + state_code: ID + household_vehicles_value: 2660.0 + output: + id_income_tax_before_refundable_credits: 645.54 + state_income_tax_before_refundable_credits: 655.54 # Includes the $10 PBF tax. +- name: PolicyBench scenario_053 Idaho paid health insurance premiums + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 25 + employment_income: 66968.6796875 + bank_account_assets: 3660.0 + employer_sponsored_insurance_premiums: 3389.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 5000.0 + hourly_wage: 32.0 + hours_worked_last_week: 40.0 + is_paid_hourly: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 5000.0 + other_medical_expenses: 400.0 + over_the_counter_health_expenses: 150.0 + pre_subsidy_rent: 37200.0 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + tax_units: + tax_unit: + members: &id002 + - head + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id002 + takes_up_snap_if_eligible: true + families: + family: + members: *id002 + marital_units: + marital_unit: + members: *id002 + households: + household: + members: *id002 + state_code: ID + auto_loan_balance: 19000.0 + auto_loan_interest: 950.0 + household_vehicles_value: 7410.0 + output: + id_income_tax_before_refundable_credits: 2170.28 + state_income_tax_before_refundable_credits: 2180.28 # Includes the $10 PBF tax. diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml index b40cca82519..e931e17dbdd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml @@ -18,6 +18,76 @@ output: id_itemized_deductions: 0 +- name: Derived Idaho itemized deductions exclude independently paid dependent medical costs + # 2025 Form 39R worksheet: claimant medical deduction is $750. + # $20,000 interest + $750 medical + $2,250 premiums = $23,000 reduction. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + interest_deduction: 20_000 + salt_deduction: 0 + id_salt_deduction: 0 + standard_deduction: 15_750 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + itemized_taxable_income_deductions: 21_250 + id_itemized_deductions: 20_750 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 7_000 + +- name: Corrected Idaho medical costs also reduce the 2026 itemized limitation + # P.L. 119-21 sec. 70111: $70,000 * (1 - 2/37) = $66,216.21622. + # Subtracting $500 from the already-limited federal total would be too low. + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 100_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SINGLE + adjusted_gross_income: 800_000 + interest_deduction: 30_000 + salt_deduction: 0 + id_salt_deduction: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 40_500 + id_health_insurance_premiums_medical_deduction: 40_000 + itemized_taxable_income_deductions: 66_689.18919 + id_itemized_deductions: 66_216.21622 + id_itemizes: true + id_health_insurance_premiums_subtraction: 60_000 + - name: Idaho does not add back the federal foreign tax credit when it is claimed as a credit period: 2025 input: @@ -30,3 +100,72 @@ # the itemized deduction (already inside itemized_taxable_income_deductions), # not when it is claimed as a federal credit. id_itemized_deductions: 0 + +- name: A supplied final federal itemized total already excludes dependent-paid medical costs + # The actual $20,000 Schedule A total includes claimant medical expenses; + # subtracting the derived federal medical overstatement again is incorrect. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + itemized_taxable_income_deductions: 20_000 + id_salt_deduction: 0 + standard_deduction: 15_750 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_itemized_deductions: 20_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 7_750 + +- name: A supplied raw federal itemized total receives the 2026 limitation once + # The actual $70,000 already uses claimant medical expenses; applying the + # 2/37 limitation gives $66,216.21622 without a second medical adjustment. + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 100_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SINGLE + adjusted_gross_income: 800_000 + interest_deduction: 30_000 + total_itemized_taxable_income_deductions: 70_000 + salt_deduction: 0 + id_salt_deduction: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 40_500 + id_health_insurance_premiums_medical_deduction: 40_000 + itemized_taxable_income_deductions: 66_216.21622 + id_itemized_deductions: 66_216.21622 + id_itemizes: true + id_health_insurance_premiums_subtraction: 60_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml index a96995acc78..4c16b814021 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml @@ -265,4 +265,10 @@ additional_senior_deduction: 6_000 id_additional_senior_deduction: 6_000 id_qualified_business_income_and_federal_schedule_1a_deductions: 6_000 - id_taxable_income: 134_000 + # Idaho Code 63-3022P and 2025 Form 39R line 18 allow Medicare B premiums: + # https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/ + # https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_03-02-2026.pdf#page=11 + # Modeled premiums: 2 * $185 * 12 = $4,440, below the $15,000 medical floor. + # Taxable income: $200,000 - $60,000 - $6,000 - $4,440 = $129,560. + id_health_insurance_premiums_subtraction: 4_440 + id_taxable_income: 129_560 diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml new file mode 100644 index 00000000000..b7d9991ce35 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -0,0 +1,497 @@ +- name: Standard deduction retains all post-tax premiums + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + standard_deduction: 16_100 + id_itemized_deductions: 10_000 + medical_expense_deduction: 2_000 + output: + id_health_insurance_premiums_subtraction: 6_000 + +- name: Pretax premiums are a separate input and never enter the subtraction + # Qualified premiums: $6,000 - $1,000 Schedule 1 deduction = $5,000. + # The separate $2,000 pretax input has no effect; standard retains $5,000. + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald: 1_000 + standard_deduction: 16_100 + id_itemized_deductions: 0 + output: + id_health_insurance_premiums_subtraction: 5_000 + +- name: Itemizers allocate medical deductions to health premiums first + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 20_000 + output: + id_health_insurance_premiums_subtraction: 3_000 + +- name: Medical deduction exceeding available premiums leaves no subtraction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 10_000 + standard_deduction: 16_100 + id_itemized_deductions: 25_000 + output: + id_health_insurance_premiums_subtraction: 0 + +- name: Excess federal deductions cannot create a negative subtraction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 1_000 + self_employed_health_insurance_ald: 2_000 + output: + id_health_insurance_premiums_subtraction: 0 + +- name: Reported non-Medicare premiums and voluntary Medicare B premiums qualify + period: 2025 + input: + state_code: ID + health_insurance_premiums_without_medicare_part_b: 2_000 + medicare_enrolled: true + medicare_part_b_premium: 1_000 + standard_deduction: 15_750 + id_itemized_deductions: 0 + output: + id_health_insurance_premiums_subtraction: 3_000 + +- name: Medicare-enrolled joint filers qualify for modeled Part B premiums without premium inputs + # Idaho Code 63-3022P; 2025 Form 39R line 18 and worksheet: + # https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/ + # https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_03-02-2026.pdf#page=11 + # Each filer pays the modeled $185 monthly premium: 2 * $185 * 12 = $4,440. + # The $200,000 * 7.5% = $15,000 floor leaves no Schedule A medical overlap. + # Taxable income: $200,000 - $60,000 itemized - $6,000 senior - $4,440. + period: 2025 + input: + people: + head: + age: 70 + taxable_interest_income: 100_000 + medicare_enrolled: true + is_tax_unit_head: true + is_tax_unit_head_or_spouse: true + ssn_card_type: CITIZEN + spouse: + age: 70 + taxable_interest_income: 100_000 + medicare_enrolled: true + is_tax_unit_spouse: true + is_tax_unit_head_or_spouse: true + ssn_card_type: CITIZEN + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + itemized_taxable_income_deductions: 60_000 + id_salt_deduction: 0 + households: + household: + members: [head, spouse] + state_code: ID + output: + medicare_part_b_premium: [2_220, 2_220] + medical_expense_health_insurance_premiums: [2_220, 2_220] + medical_expense_deduction: 0 + id_qualified_health_insurance_premiums: 4_440 + id_health_insurance_premiums_medical_deduction: 0 + id_itemizes: true + id_health_insurance_premiums_subtraction: 4_440 + id_taxable_income: 129_560 + +- name: Premium subtraction is included in the existing 2021 subtraction list + period: 2021 + input: + state_code: ID + health_insurance_premiums: 2_000 + standard_deduction: 12_550 + id_itemized_deductions: 0 + output: + id_health_insurance_premiums_subtraction: 2_000 + id_subtractions: 2_000 + +- name: Subtraction is limited to Idaho + period: 2026 + input: + state_code: WA + health_insurance_premiums: 2_000 + output: + id_health_insurance_premiums_subtraction: 0 + +- name: Premiums paid by a dependent do not qualify on the parent's return + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + standard_deduction: 15_750 + id_itemized_deductions: 0 + medical_expense_deduction: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + id_qualified_health_insurance_premiums: 1_000 + id_health_insurance_premiums_subtraction: 1_000 + +- name: Both filers' premiums qualify independently of separate pretax payments + # Qualified premiums: $1,000 head + $2,000 spouse = $3,000. + # The dependent's $500 payment is excluded; both pretax inputs are separate. + # Standard deduction retains the full $3,000 subtraction. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + spouse: + age: 40 + health_insurance_premiums: 2_000 + pre_tax_health_insurance_premiums: 500 + is_tax_unit_spouse: true + dependent: + age: 20 + health_insurance_premiums: 500 + pre_tax_health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, dependent] + standard_deduction: 31_500 + id_itemized_deductions: 0 + medical_expense_deduction: 0 + households: + household: + members: [head, spouse, dependent] + state_code: ID + output: + id_qualified_health_insurance_premiums: 3_000 + id_health_insurance_premiums_subtraction: 3_000 + +- name: A dependent's federal deduction cannot reduce the filers' own premiums + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + self_employment_income: 5_000 + is_self_employed: true + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + standard_deduction: 16_100 + id_itemized_deductions: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + dependents_self_employed_health_insurance_ald: 500 + id_health_insurance_premiums_subtraction: 1_000 + +- name: Dependent-paid premiums cannot reduce the parent's itemized subtraction + # Idaho Code 63-3022P; 2025 Form 39R worksheet, PDF page 49, + # lines 1-6 and 8: $3,000 claimant costs - $2,250 floor = $750 overlap. + # Leave medical_expense_deduction derived to reproduce review r2's P2. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 7_750 + +- name: Dependent payments cannot push claimant medical expenses above the floor + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 2_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 250 + id_health_insurance_premiums_medical_deduction: 0 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_000 + +- name: Claimant medical overlap includes spouse expenses and excludes dependent expenses + # Claimants paid $5,000 premiums + $2,000 other costs; the floor is $6,000. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + other_medical_expenses: 1_000 + is_tax_unit_head: true + spouse: + age: 40 + health_insurance_premiums: 2_000 + other_medical_expenses: 1_000 + is_tax_unit_spouse: true + dependent: + age: 20 + health_insurance_premiums: 500 + other_medical_expenses: 4_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, dependent] + adjusted_gross_income: 80_000 + standard_deduction: 31_500 + id_itemized_deductions: 40_000 + households: + household: + members: [head, spouse, dependent] + state_code: ID + output: + medical_expense_deduction: 5_500 + id_health_insurance_premiums_medical_deduction: 1_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 4_000 + +- name: Derived claimant medical overlap uses premiums excluding pretax payroll payments + # Pretax payroll premiums are separate; remove only the Schedule 1 amount. + # Qualified premiums: $6,000 - $1,000 = $5,000; floor: $30,000 * 7.5% = $2,250. + # Federal medical: $6,000 + $500 - $2,250 = $4,250 (unchanged). + # Claimant overlap: min($4,250, $5,000 + $500 - $2,250) = $3,250. + # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding + # standard route $15,750 + $5,000 = $20,750; subtraction remains $1,750. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + medical_expense_deduction: 4_250 + id_qualified_health_insurance_premiums: 5_000 + id_health_insurance_premiums_medical_deduction: 3_250 + id_itemizes: true + id_health_insurance_premiums_subtraction: 1_750 + +- name: A smaller actual federal medical deduction limits claimant overlap + period: 2025 + input: + state_code: ID + health_insurance_premiums: 3_000 + adjusted_gross_income: 30_000 + medical_expense_deduction: 500 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + id_health_insurance_premiums_medical_deduction: 500 + id_health_insurance_premiums_subtraction: 2_500 + +- name: An actual claimant medical deduction can include otherwise unreported expenses + # An explicit Idaho claimant deduction can override the supplied federal + # amount when the caller has a different state-specific medical claim. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + medical_expense_deduction: 4_000 + id_health_insurance_premiums_medical_deduction: 3_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + id_health_insurance_premiums_medical_deduction: 3_000 + id_health_insurance_premiums_subtraction: 0 + +- name: A supplied Schedule A medical deduction includes unreported underlying expenses + # Form 39R worksheet lines 6 and 8 use the actual Schedule A deduction. + # The supplied $3,000 includes $2,250 of other bills omitted from inputs; + # reconstructing from premiums alone would incorrectly allow $2,250 twice. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 3_000 + adjusted_gross_income: 30_000 + medical_expense_deduction: 3_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + id_health_insurance_premiums_medical_deduction: 3_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 0 + id_taxable_income: 10_000 + +- name: A supplied medical deduction for another year cannot override claimant scope + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + medical_expense_deduction: + 2026: 3_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_health_insurance_premiums_subtraction: 2_250 + +- name: A supplied Schedule A expense subtotal includes unreported claimant bills + # Review r3's P2; Form 39R worksheet, PDF page 49, lines 1-6 and 8. + # $6,000 claimant expenses - $2,250 floor = $3,750 medical deduction, + # exhausting the $3,000 premiums even without person-level other bills. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 3_000 + adjusted_gross_income: 30_000 + itemized_medical_expenses: 6_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + medical_expense_deduction: 3_750 + id_health_insurance_premiums_medical_deduction: 3_750 + id_itemizes: true + id_health_insurance_premiums_subtraction: 0 + id_taxable_income: 10_000 + +- name: A supplied Schedule A expense subtotal already excludes disallowed premiums + # The $6,000 premium input already excludes separate pretax payroll premiums. + # Qualified premiums: $6,000 - $1,000 Schedule 1 deduction = $5,000. + # Supplied expenses: $5,000 + $500 unreported other bills = $5,500. + # Federal and claimant medical: $5,500 - $30,000 * 7.5% = $3,250. + # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding + # standard route $15,750 + $5,000 = $20,750; subtraction remains $1,750. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald: 1_000 + adjusted_gross_income: 30_000 + itemized_medical_expenses: 5_500 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + medical_expense_deduction: 3_250 + id_qualified_health_insurance_premiums: 5_000 + id_health_insurance_premiums_medical_deduction: 3_250 + id_itemizes: true + id_health_insurance_premiums_subtraction: 1_750 + +- name: Identical households have identical Idaho outputs with or without pretax premiums + # Each household has $6,000 premiums excluding pretax payroll payments. + # Qualified premiums: $6,000 - $1,000 Schedule 1 deduction = $5,000. + # Claimant overlap: $5,000 + $500 - $30,000 * 7.5% = $3,250. + # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding + # standard route $15,750 + $5,000 = $20,750; subtraction is $1,750. + # Deductions: $20,000; taxable income: $30,000 - $20,000 - $1,750 = $8,250. + period: 2025 + input: + state_code: [ID, ID] + health_insurance_premiums: [6_000, 6_000] + pre_tax_health_insurance_premiums: [0, 2_000] + self_employed_health_insurance_ald: [1_000, 1_000] + other_medical_expenses: [500, 500] + adjusted_gross_income: [30_000, 30_000] + standard_deduction: [15_750, 15_750] + id_itemized_deductions: [20_000, 20_000] + output: + id_qualified_health_insurance_premiums: [5_000, 5_000] + id_health_insurance_premiums_medical_deduction: [3_250, 3_250] + id_itemizes: [true, true] + id_health_insurance_premiums_subtraction: [1_750, 1_750] + id_deductions: [20_000, 20_000] + id_taxable_income: [8_250, 8_250] diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/income/il_aabd_expense_exemption_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/income/il_aabd_expense_exemption_person.yaml new file mode 100644 index 00000000000..6eb8ae31b47 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/income/il_aabd_expense_exemption_person.yaml @@ -0,0 +1,131 @@ +# 89 Ill. Adm. Code 113.125(a) exempts withholding taxes and Social Security +# tax as expenses of the person's own employment, so each earner deducts only +# the tax withheld from their own wages. +# The model's Illinois withholding estimate is 4.95% of the person's own AGI +# above the personal exemption; with wages as the only income, that is +# 4.95% x max(wages - 2,775, 0) in 2024. +# Social Security tax: 6.2% of wages. + +- name: Case 1, married couple with equal wages, each deducts only their own withholding. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 24_000 + person2: + age: 40 + employment_income: 24_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + # (24,000 - 2,775) x 0.0495 = 1,050.64 a year = 87.55 a month + il_withheld_income_tax: [87.55, 87.55] + # 2,000 x 0.062 = 124 + employee_social_security_tax: [124, 124] + # 87.55 + 124 = 211.55, not the couple's combined 175.11 withholding + 124 + il_aabd_expense_exemption_person: [211.55, 211.55] + +- name: Case 2, married couple with unequal wages. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 36_000 + person2: + age: 40 + employment_income: 12_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + # person1: (36,000 - 2,775) x 0.0495 = 1,644.64 a year = 137.05 a month + # person2: (12,000 - 2,775) x 0.0495 = 456.64 a year = 38.05 a month + il_withheld_income_tax: [137.05, 38.05] + # 3,000 x 0.062 = 186; 1,000 x 0.062 = 62 + employee_social_security_tax: [186, 62] + # 137.05 + 186 = 323.05; 38.05 + 62 = 100.05 + il_aabd_expense_exemption_person: [323.05, 100.05] + +- name: Case 3, spouse with wages below the Illinois personal exemption has no withholding to deduct. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 24_000 + person2: + age: 40 + employment_income: 2_400 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + # person2: max(2,400 - 2,775, 0) x 0.0495 = 0 + il_withheld_income_tax: [87.55, 0] + # 2,000 x 0.062 = 124; 200 x 0.062 = 12.40 + employee_social_security_tax: [124, 12.4] + # person2 deducts only their own 12.40, not person1's 87.55 withholding + il_aabd_expense_exemption_person: [211.55, 12.4] + +- name: Case 4, a working adult dependent does not deduct the parent's withholding. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + employment_income: 80_000 + person2: + age: 25 + is_tax_unit_dependent: true + employment_income: 4_800 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + person1_marital_unit: + members: [person1] + person2_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + # person1: (80,000 - 2,775) x 0.0495 = 3,822.64 a year = 318.55 a month + # person2 is a tax unit dependent. The withholding estimate starts from + # adjusted_gross_income_person, which leaves a dependent's income to their + # own return, so the model estimates no Illinois withholding for them. + il_withheld_income_tax: [318.55, 0] + # 6,666.67 x 0.062 = 413.33; 400 x 0.062 = 24.80 + employee_social_security_tax: [413.33, 24.8] + # person1: 318.553 + 413.333 = 731.886 + # person2: their own 24.80 Social Security tax, not the parent's 318.55 withholding + il_aabd_expense_exemption_person: [731.89, 24.8] diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/integration.yaml index 614af13230b..0b0d931bade 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/integration.yaml @@ -33,7 +33,8 @@ state_withheld_income_tax: 28.15 # computed by PE US employee_social_security_tax: [49.6, 0] # computed by PE US il_aabd_child_care_expense_exemption: [0, 0] - il_aabd_expense_exemption_person: [77.75, 28.15] # 28.15 + 49.6 + il_withheld_income_tax: [28.15, 0] # (9,600 - 2,775) x 0.0495 / 12; the child has no wages + il_aabd_expense_exemption_person: [77.75, 0] # own withholding 28.15 + own Social Security tax 49.6 il_aabd_gross_earned_income: [800, 0] il_aabd_countable_unearned_income: [560.5, 0] # computed by PE US (ssi) il_aabd_earned_income_after_exemption_person: [318.625, 0] @@ -84,7 +85,8 @@ state_withheld_income_tax: 28.15 # computed by PE US employee_social_security_tax: [49.6, 0] # computed by PE US il_aabd_child_care_expense_exemption: [0, 0] - il_aabd_expense_exemption_person: [77.75, 28.15] # 28.15 + 49.6 + il_withheld_income_tax: [28.15, 0] # (9,600 - 2,775) x 0.0495 / 12; the child has no wages + il_aabd_expense_exemption_person: [77.75, 0] # own withholding 28.15 + own Social Security tax 49.6 il_aabd_gross_earned_income: [800, 0] social_security: [0, 0] ssi: [585.5, 943] # computed by PE US @@ -419,3 +421,114 @@ il_aabd_eligible_person: [true, true] # Both eligible for AABD il_aabd_person: [441.45, 822.15] # Both get AABD il_aabd: 1_263.6 # Total household AABD + +- name: Integration 6, married couple both disabled with wages, each deducts only their own withholding. + period: 2024-01 + absolute_error_margin: 0.2 + input: + people: + person1: + age: 40 + is_disabled: true + meets_ssi_disability_criteria: true + employment_income: 9_600 # $800 monthly + takes_up_ssi_if_eligible: false + immigration_status: CITIZEN + il_aabd_is_bedfast: false + person2: + age: 40 + is_disabled: true + meets_ssi_disability_criteria: true + employment_income: 9_600 # $800 monthly + takes_up_ssi_if_eligible: false + immigration_status: CITIZEN + il_aabd_is_bedfast: false + spm_units: + spm_unit: + members: [person1, person2] + il_aabd_countable_assets: 0 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + county_str: COOK_COUNTY_IL + output: + # (9,600 - 2,775) x 0.0495 / 12 = 28.15 each + il_withheld_income_tax: [28.15, 28.15] + employee_social_security_tax: [49.6, 49.6] # 800 x 0.062 + # Each deducts their own 28.15 + 49.6, not the couple's 56.31 withholding + il_aabd_expense_exemption_person: [77.75, 77.75] + il_aabd_gross_earned_income: [800, 800] + il_aabd_countable_unearned_income: [0, 0] # SSI not taken up + # 800 - 77.75 = 722.25; minus the 25 flat exemption = 697.25; + # minus 20 + 0.5 x 60 = 50 gives 647.25 + il_aabd_earned_income_after_exemption_person: [647.25, 647.25] + il_aabd_countable_income: [647.25, 647.25] + il_aabd_need_standard_person: [822.15, 822.15] # 764.9 + 57.25 + il_aabd_non_financial_eligible_person: [true, true] + il_aabd_financial_eligible_person: [true, true] + il_aabd_eligible_person: [true, true] + il_aabd_person: [174.9, 174.9] # 822.15 - 647.25 + il_aabd: 349.81 + +- name: Integration 7, a disabled adult dependent's eligibility does not use the parent's withholding. + period: 2024-01 + absolute_error_margin: 0.2 + input: + people: + person1: + age: 50 + employment_income: 80_000 + immigration_status: CITIZEN + il_aabd_is_bedfast: false + person2: + age: 25 + is_disabled: true + meets_ssi_disability_criteria: true + is_tax_unit_dependent: true + employment_income: 14_400 # $1,200 monthly, below the SGA amount + takes_up_ssi_if_eligible: false + immigration_status: CITIZEN + il_aabd_is_bedfast: false + spm_units: + spm_unit: + members: [person1, person2] + il_aabd_countable_assets: 0 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + person1_marital_unit: + members: [person1] + person2_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: IL + county_str: COOK_COUNTY_IL + output: + # person1: (80,000 - 2,775) x 0.0495 / 12 = 318.55 + # person2 is a tax unit dependent; the withholding estimate starts from + # adjusted_gross_income_person, which leaves their income to their own return. + il_withheld_income_tax: [318.55, 0] + employee_social_security_tax: [413.33, 74.4] # 6.2% of monthly wages + # person2 deducts only their own 74.40, not the parent's 318.55 withholding + il_aabd_expense_exemption_person: [731.89, 74.4] + il_aabd_gross_earned_income: [6_666.67, 1_200] + # person2: 1,200 - 74.40 = 1,125.60; minus 25 flat = 1,100.60; minus 50 = 1,050.60 + il_aabd_countable_income: [0, 1_050.6] + il_aabd_need_standard_person: [0, 822.15] # 764.9 + 57.25 + il_aabd_non_financial_eligible_person: [false, true] + # 1,050.60 exceeds the 822.15 need standard. Deducting the parent's + # withholding as well would leave 732.05 and a 90.10 benefit. + il_aabd_financial_eligible_person: [true, false] + il_aabd_eligible_person: [false, false] + il_aabd_person: [0, 0] + il_aabd: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbi/income/il_hbi_senior_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbi/income/il_hbi_senior_countable_income.yaml new file mode 100644 index 00000000000..ca5f89af8c0 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbi/income/il_hbi_senior_countable_income.yaml @@ -0,0 +1,37 @@ +# HBIS counts income with the AABD methodology, so each senior's earned income +# is reduced only by the employment expenses of their own job +# (89 Ill. Adm. Code 113.125). + +- name: Case 1, working senior couple, each deducts only their own withholding. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 24_000 # $2,000/month + takes_up_ssi_if_eligible: false + person2: + age: 70 + employment_income: 24_000 # $2,000/month + takes_up_ssi_if_eligible: false + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + # IL withholding: (24,000 - 2,775) x 0.0495 / 12 = 87.55 each + # Social Security tax: 2,000 x 0.062 = 124 each + # Exemption: 87.55 + 124 = 211.55 each + il_aabd_expense_exemption_person: [211.55, 211.55] + # 2,000 - 211.55 - 25 (flat) - 50 (20 + 0.5 x 60) = 1,713.45 + il_hbi_senior_countable_income: [1_713.45, 1_713.45] diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/il_hbwd_premium.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/il_hbwd_premium.yaml index e4c8553e535..a45e67f3de7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/il_hbwd_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/il_hbwd_premium.yaml @@ -82,3 +82,40 @@ # Earned $250 → bracket 1 (251-500) → $6 # Unearned $250 → bracket 1 (251-500) → $19 # Total: $6 + $19 = $25 + +- name: Case 7, married couple both disabled, each premium uses only their own withholding. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + is_disabled: true + employment_income: 25_200 # $2,100/month + person2: + age: 40 + is_disabled: true + employment_income: 25_200 # $2,100/month + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + il_hbwd_eligible: [true, true] + # Each: 2,100 - (92.19 own IL withholding + 130.20 Social Security tax) + # - 25 (flat) - 50 (20 + 0.5 x 60) = 1,802.61 + il_hbwd_countable_earned_income: [1_802.61, 1_802.61] + il_hbwd_countable_unearned_income: [0, 0] + il_hbwd_premium: [44, 44] + # Earned $1,802.61 → bracket 7 (1751-2000) → $44 + # Unearned $0 → bracket 0 → $0 + # Deducting the couple's combined withholding would give 1,710.41 → $38 diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/income/earned/il_hbwd_countable_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/income/earned/il_hbwd_countable_earned_income.yaml index d15cea7883e..e462ed78ade 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/income/earned/il_hbwd_countable_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/hbwd/income/earned/il_hbwd_countable_earned_income.yaml @@ -62,10 +62,13 @@ members: [person1, person2] state_code: IL output: - # person1 (disabled): gets exemptions - # person2 (not disabled/blind): uses gross income (no exemptions) - # Combined countable $6,323 > limit $6,168.75 → NOT eligible - il_hbwd_countable_income: 6_323 + # person1 (disabled): gets exemptions, deducting only their own withholding + # IL withholding: (54,000 - 2,850) x 0.0495 / 12 = 210.99 + # Social Security tax: 4,500 x 0.062 = 279 + # 4,500 - 210.99 - 279 - 25 (flat) - 50 (20 + 0.5 x 60) = 3,935.01 + # person2 (not disabled/blind): uses gross income (no exemptions) = 2,500 + # Combined countable $6,435.01 > limit $6,168.75 → NOT eligible + il_hbwd_countable_income: 6_435.01 il_hbwd_income_eligible: [false, false] il_hbwd_eligible: [false, false] il_hbwd_person: [0, 0] @@ -290,3 +293,39 @@ # Combined countable should be just under limit il_hbwd_income_eligible: [true, true] il_hbwd_eligible: [true, false] # person2 not disabled/blind + +# Test 13: Married couple - both disabled - each deducts only their own withholding +- name: Case 13, married couple both disabled with equal wages. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + is_disabled: true + employment_income: 25_200 # $2,100/month * 12 + person2: + age: 40 + is_disabled: true + employment_income: 25_200 # $2,100/month * 12 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: IL + output: + # IL withholding: (25,200 - 2,850) x 0.0495 / 12 = 92.19 each + # Social Security tax: 2,100 x 0.062 = 130.20 each + # Exemption: 92.19 + 130.20 = 222.39 each, not the couple's 184.39 withholding + 130.20 + il_aabd_expense_exemption_person: [222.39, 222.39] + # 2,100 - 222.39 - 25 (flat) - 50 (20 + 0.5 x 60) = 1,802.61 + il_hbwd_countable_earned_income: [1_802.61, 1_802.61] + il_hbwd_countable_income: 3_605.21 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml index 4ebed014ab9..46943e5822e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml @@ -184,3 +184,97 @@ state_code: MI output: mi_home_heating_credit: 0 + +# Ruling d1143 (2026-10-09): follow MI-1040CR lines 18 and 21. +# 2025 MI-1040 book, PDF page 28 (printed page 32): +# https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=28 +# Line 18 excludes a dependent adult's pension; line 21 includes their SSI. +- name: Dependent adult pension lowers Michigan tax through SSI (d1143) + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head_without_pension: + age: 40 + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_dependent: false + takes_up_ssi_if_eligible: false + dependent_without_pension: + age: 75 + is_tax_unit_head: false + is_tax_unit_dependent: true + taxable_private_pension_income: 0 + takes_up_ssi_if_eligible: true + head_with_pension: + age: 40 + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_dependent: false + takes_up_ssi_if_eligible: false + dependent_with_pension: + age: 75 + is_tax_unit_head: false + is_tax_unit_dependent: true + taxable_private_pension_income: 2_400 + takes_up_ssi_if_eligible: true + marital_units: + head_without_pension: + members: [head_without_pension] + dependent_without_pension: + members: [dependent_without_pension] + head_with_pension: + members: [head_with_pension] + dependent_with_pension: + members: [dependent_with_pension] + tax_units: + without_pension: + members: [head_without_pension, dependent_without_pension] + heating_expenses: 0 + mi_home_heating_credit_heat_included_in_rent: false + mi_eitc: 0 + mi_homestead_property_tax_credit: 0 + with_pension: + members: [head_with_pension, dependent_with_pension] + heating_expenses: 0 + mi_home_heating_credit_heat_included_in_rent: false + mi_eitc: 0 + mi_homestead_property_tax_credit: 0 + spm_units: + without_pension: + members: [head_without_pension, dependent_without_pension] + tanf: 0 + with_pension: + members: [head_with_pension, dependent_with_pension] + tanf: 0 + families: + without_pension: + members: [head_without_pension, dependent_without_pension] + with_pension: + members: [head_with_pension, dependent_with_pension] + households: + without_pension: + members: [head_without_pension, dependent_without_pension] + state_code: MI + with_pension: + members: [head_with_pension, dependent_with_pension] + state_code: MI + output: + # Annual SSI: 12 * $967 = $11,604. Countable annual pension: + # $2,400 - 12 * $20 general exclusion = $2,160; $11,604 - $2,160 = $9,444. + ssi: [0, 11_604, 0, 9_444] + # Claimant wages + dependent SSI; the dependent's pension is excluded. + mi_household_resources: [21_604, 19_444] + adjusted_gross_income: [10_000, 10_000] + mi_pension_benefit: [0, 0] + mi_subtractions: [0, 0] + mi_taxable_income: [0, 0] + mi_income_tax_before_refundable_credits: [0, 0] + # Two exemptions: $815 - 3.5% * household resources. + mi_standard_home_heating_credit: [58.86, 134.46] + # Apply the 2025 60% funding factor: $58.86 * .60; $134.46 * .60. + mi_home_heating_credit: [35.316, 80.676] + # Only the heating credit changes: tax falls by $45.36, + # equal to $2,160 * 3.5% * 60% = $80.676 - $35.316. + mi_income_tax: [-35.316, -80.676] + state_income_tax: [-35.316, -80.676] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/household_resources_adjustments.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/household_resources_adjustments.yaml new file mode 100644 index 00000000000..bfc6dbc3877 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/household_resources_adjustments.yaml @@ -0,0 +1,21 @@ +- name: Household resources subtract the federal IRA deduction (Schedule 1 line 20) + # MI-1040CR line 30: "Enter total adjustments from your U.S. Form 1040, + # Schedule 1." A $7,000 deductible traditional IRA contribution reduces + # $50,000 of wages to $43,000 of household resources. + period: 2025 + input: + people: + head: + age: 40 + employment_income: 50_000 + traditional_ira_contributions: 7_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MI + output: + traditional_ira_deduction: 7_000 + mi_household_resources: 43_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index 74cfed89ef5..9e9345ba1a7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -8,12 +8,15 @@ # "If the total is negative enter 0." Line 30 is "total adjustments from your # U.S. Form 1040, Schedule 1", and line 31 is health insurance premiums. The # household is "a claimant and spouse" (MCL 206.508(3)), so a dependent's -# business and rental items stay off lines 16 and 17. +# income and Schedule 1 adjustments stay off the claimant's lines, except +# Social Security and SSI received for dependents (line 21), child support +# (line 22) and public assistance paid to the household (line 27). Premiums +# paid for the family (line 31) include dependents' coverage. - name: Cannot be negative period: 2024 input: - net_capital_gains: -2_000 + long_term_capital_gains: -2_000 state_code: MI health_insurance_premiums: 0 early_withdrawal_penalty: 1_000 @@ -27,7 +30,7 @@ - name: Reduced to 0 period: 2024 input: - net_capital_gains: 2_000 + long_term_capital_gains: 2_000 state_code: MI health_insurance_premiums: 3_000 early_withdrawal_penalty: 1_000 @@ -39,7 +42,7 @@ - name: Partially reduced period: 2024 input: - net_capital_gains: 10_000 + long_term_capital_gains: 10_000 state_code: MI health_insurance_premiums: 3_000 early_withdrawal_penalty: 1_000 @@ -724,3 +727,374 @@ # Schedule 1 line 11 and MI-1040CR line 30 use the 2025 cap of 300. # Line 33: 50,000 - 300 = 49,700. mi_household_resources: 49_700 + +- name: A working dependent's wages, interest and self-employment are not household resources + period: 2025 + input: + people: + person1: + age: 40 + employment_income_before_lsr: 40_000 + person2: + age: 17 + employment_income_before_lsr: 8_000 + taxable_interest_income: 200 + self_employment_income: 3_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The household is the claimant and spouse (MCL 206.508(3)); the form + # asks for "all taxable and nontaxable income you and your spouse + # received". The dependent's wages (8,000), interest (200) and Schedule C + # (3,000) are on the dependent's own return. + # Line 14: 40,000. Lines 15 and 16: 0. + # Line 30: the dependent's deduction for half of self-employment tax + # (3,000 x 0.9235 x 15.3% / 2 = 211.94) is on the dependent's own + # Schedule 1, so 0. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Both spouses' income counts and a child's does not + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 30_000 + person2: + age: 44 + employment_income_before_lsr: 20_000 + taxable_interest_income: 500 + person3: + age: 12 + taxable_interest_income: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + marital_units: + marital_unit: + members: [person1, person2] + child_marital_unit: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: MI + output: + # Line 14: 30,000 + 20,000 = 50,000. Line 15: the spouse's 500; the + # child's 1,000 is the child's own income. + # Line 33: 50,500. + mi_household_resources: 50_500 + +- name: Social Security survivors benefits paid for a child count on line 21 + period: 2025 + input: + people: + person1: + age: 38 + employment_income_before_lsr: 30_000 + person2: + age: 10 + social_security_survivors: 9_000 + taxable_interest_income: 400 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 30,000. Line 15: 0; the child's 400 of interest is the + # child's own income. + # Line 21: "Include death benefits and amounts received for minor + # children or other dependent adults who live with you": 9,000. + # Line 33: 30,000 + 9,000 = 39,000. + mi_household_resources: 39_000 + +- name: SSI and child support received for a child count on lines 21 and 22 + period: 2025 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 20_000 + person2: + age: 8 + ssi: 6_000 + child_support_received: 4_800 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 20,000. Line 21: SSI received for a minor child, 6,000. + # Line 22: "child support and all payments received as a foster + # parent", 4,800. + # Line 33: 20,000 + 6,000 + 4,800 = 30,800. + mi_household_resources: 30_800 + +- name: A dependent adult's Social Security counts but the dependent's pension does not + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 50_000 + person2: + age: 72 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + social_security_retirement: 15_000 + taxable_pension_income: 6_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + parent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 50,000. Line 18: 0; the dependent parent's pension is the + # parent's own income. + # Line 21: amounts received for "other dependent adults who live with + # you", 15,000. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A dependent's capital loss does not reduce the claimant's line 19 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: 2_000 + person2: + age: 16 + short_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 19: net capital gains "from your U.S. Schedule D", the claimant's + # 2,000. The dependent's -5,000 is on the dependent's own Schedule D. + # Line 33: 50,000 + 2,000 = 52,000. + mi_household_resources: 52_000 + +- name: A dependent's early withdrawal penalty is not on the claimant's line 30 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + early_withdrawal_penalty: 1_000 + person2: + age: 16 + early_withdrawal_penalty: 500 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1": the + # claimant's penalty on early withdrawal of savings (Schedule 1 line 18), + # 1,000. The dependent's 500 is on the dependent's own Schedule 1. + # Line 33: 50,000 - 1,000 = 49,000. + mi_household_resources: 49_000 + +- name: A Family Independence Program grant for a parent and two children counts in full on line 27 + period: 2025 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 10_000 + person2: + age: 6 + person3: + age: 4 + tax_units: + tax_unit: + members: [person1, person2, person3] + spm_units: + spm_unit: + members: [person1, person2, person3] + tanf: 6_000 + households: + household: + members: [person1, person2, person3] + state_code: MI + output: + # Line 14: 10,000. Line 27: "the total payments made to your household + # by MDHHS", the whole 6,000 grant, including the children's share. + # Line 33: 10,000 + 6,000 = 16,000. + mi_household_resources: 16_000 + +- name: Premiums paid for a child's coverage reduce household resources on line 31 + period: 2025 + input: + people: + person1: + age: 40 + employment_income_before_lsr: 40_000 + health_insurance_premiums: 2_400 + person2: + age: 9 + health_insurance_premiums: 1_200 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 31: "insurance premiums you paid for yourself and your family", + # 2,400 + 1,200 = 3,600, reading the child's premium as one the claimant + # paid for the child's coverage. + # Line 33: 40,000 - 3,600 = 36,400. + mi_household_resources: 36_400 + +- name: A dependent's alimony payment is not on the claimant's line 30 + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 40_000 + person2: + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + alimony_expense: 2_000 + divorce_year: 2015 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + dependent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 40,000. Line 30: the dependent's deductible alimony (a 2015 + # divorce) is on the dependent's own Schedule 1, so 0. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Guaranteed income pilot payments count only for the claimant and spouse + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 40_000 + gi_cash_assistance: 1_000 + person2: + age: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + gi_cash_assistance: 7_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + dependent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 40,000. The claimant's own pilot payment, 1,000, counts. The + # dependent's 7,000 is the dependent's own income: nothing shows it is a + # public assistance payment to the claimant's household (line 27). + # Line 33: 41,000. + mi_household_resources: 41_000 + +- name: A supplied net capital gains amount counts on line 19 + period: 2025 + input: + employment_income_before_lsr: 10_000 + net_capital_gains: 5_000 + state_code: MI + output: + # A tax unit net_capital_gains input is the filer's Schedule D amount. + # Line 14: 10,000. Line 19: 5,000. + filer_loss_limited_net_capital_gains: 5_000 + mi_household_resources: 15_000 + +- name: A supplied net capital loss is limited to $3,000 on line 19 + period: 2025 + input: + employment_income_before_lsr: 10_000 + net_capital_gains: -5_000 + state_code: MI + output: + # Line 19: "losses cannot exceed $3,000", so -3,000. + # Line 33: 10,000 - 3,000 = 7,000. + filer_loss_limited_net_capital_gains: -3_000 + mi_household_resources: 7_000 + +- name: Supplied Schedule 1 adjustment amounts come off line 30 + period: 2025 + input: + employment_income_before_lsr: 10_000 + self_employment_tax_ald: 1_000 + self_employed_pension_contribution_ald: 1_000 + state_code: MI + output: + # Tax unit inputs for the deduction for self-employment tax and the SEP + # deduction are the filer's Schedule 1 amounts. + # Line 33: 10,000 - 1,000 - 1,000 = 8,000. + mi_household_resources: 8_000 + +- name: A supplied self-employed health insurance deduction comes off line 30 + period: 2025 + input: + employment_income_before_lsr: 10_000 + self_employed_health_insurance_ald: 1_000 + state_code: MI + output: + # Line 33: 10,000 - 1,000 = 9,000. + mi_household_resources: 9_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_amt_taxable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_amt_taxable_income.yaml index 3a65303cfad..4310e3a2c90 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_amt_taxable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_amt_taxable_income.yaml @@ -19,3 +19,29 @@ state_code: MN output: mn_amt_taxable_income: 200_000 - 250 - 3_000 + +# 2024 Schedule M1MT, line 12 (PDF p. 1): "Casualty and theft losses (from line +# 19 of Schedule M1SA)", Minnesota's own amount from Schedule M1CAT. +- name: MN AMT taxable income subtracts the Minnesota casualty loss + period: 2024 + input: + adjusted_gross_income: 200_000 + mn_itemizing: true + casualty_loss: 40_000 + state_code: MN + output: + # The federal deduction is suspended. + casualty_loss_deduction: 0 + # M1CAT: (40,000 - 100) - 10% x 200,000 = 19,900 + mn_casualty_loss_deduction: 19_900 + mn_amt_taxable_income: 200_000 - 19_900 + +- name: MN AMT taxable income leaves the casualty loss out for a non-itemizer + period: 2024 + input: + adjusted_gross_income: 200_000 + mn_itemizing: false + casualty_loss: 40_000 + state_code: MN + output: + mn_amt_taxable_income: 200_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_casualty_loss_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_casualty_loss_deduction.yaml new file mode 100644 index 00000000000..a051c1798fa --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_casualty_loss_deduction.yaml @@ -0,0 +1,181 @@ +# Minn. Stat. 290.0122, subd. 8: losses under 26 U.S.C. 165(c)(3), "including +# the provisions of section 165(h) ... but disregarding paragraph (h)(5)". +# 2024 Schedule M1SA instructions, line 19 (PDF p. 7): a loss is deductible +# only to the extent each loss "is more than $100" and the year's losses +# "reduced by $100" are "more than 10% of the amount on line 1 of Form M1". +# 2024 Schedule M1CAT (PDF p. 1): line 11 "Enter $100"; line 18 "Enter the +# amount from line 1 of Form M1" (federal adjusted gross income); line 19 +# "Multiply line 18 by 10%"; line 20 subtracts line 19 from line 17, not below +# 0, and goes to M1SA line 19. The 2018 M1CAT has the same $100 reduction and +# 10% floor; its line 20 goes to M1SA line 18, and its line 18 takes Schedule +# M1NC line 38 (federal adjustments) from filers of that schedule, which the +# model does not have. These cases are ordinary losses: +# the 2020 Schedule M1SA's net qualified disaster loss (federal Form 4684 line +# 15, $500 and no 10% floor) is not modeled. +# A joint return is one individual for the $100 rule (165(h)(4)(B)). The model +# records one loss amount per person, not separate casualty events, so a +# return's losses are treated as one casualty. A dependent's loss belongs on +# the dependent's own return, as the dependent's income does. + +- name: Case 1, the $100 reduction applies before the 10 percent floor. + period: 2024 + input: + state_code: MN + employment_income: 100_000 + casualty_loss: 15_000 + output: + # M1CAT: line 12 = 15,000 - 100 = 14,900; line 19 = 10% x 100,000 + # = 10,000; line 20 = 14,900 - 10,000. + mn_casualty_loss_deduction: 4_900 + # Minnesota allows the loss though the federal deduction is suspended. + casualty_loss_deduction: 0 + +- name: Case 2, a loss $100 above the floor nets to zero. + period: 2024 + input: + state_code: MN + employment_income: 100_000 + casualty_loss: 10_100 + output: + # (10,100 - 100) - 10% x 100,000 = 10,000 - 10,000 + mn_casualty_loss_deduction: 0 + +- name: Case 3, a loss $101 above the floor leaves $1. + period: 2024 + input: + state_code: MN + employment_income: 100_000 + casualty_loss: 10_101 + output: + # (10,101 - 100) - 10% x 100,000 = 10,001 - 10,000 + mn_casualty_loss_deduction: 1 + +- name: Case 4, a loss of exactly $100 is fully absorbed by the $100 reduction. + period: 2024 + input: + state_code: MN + casualty_loss: 100 + output: + mn_casualty_loss_deduction: 0 + +- name: Case 5, Publication 547 Example 1 with $80,000 of AGI. + period: 2024 + input: + state_code: MN + employment_income: 80_000 + casualty_loss: 14_800 + output: + # IRS Publication 547 (2017, PDF p. 10), Example 1: 14,800 - 100 = 14,700; + # less 10% of 80,000 = 6,700. + mn_casualty_loss_deduction: 6_700 + +- name: Case 6, a negative federal AGI gives no floor, not a larger loss. + period: 2024 + input: + state_code: MN + adjusted_gross_income: -20_000 + casualty_loss: 15_000 + output: + # M1CAT line 18 has no "enter 0" rule; the floor is held at zero so the + # deduction never exceeds the reduced loss: 15,000 - 100. + mn_casualty_loss_deduction: 14_900 + +- name: Case 7, a joint return reduces the spouses' pooled losses by $100 once. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 60_000 + casualty_loss: 10_000 + person2: + age: 40 + employment_income: 40_000 + casualty_loss: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MN + output: + filing_status: JOINT + # (10,000 + 5,000 - 100) - 10% x 100,000 = 14,900 - 10,000 + mn_casualty_loss_deduction: 4_900 + +- name: Case 8, a dependent's loss is left to the dependent's own return. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 100_000 + casualty_loss: 15_000 + person2: + age: 10 + casualty_loss: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MN + output: + is_tax_unit_dependent: [false, true] + # (15,000 - 100) - 10% x 100,000 = 14,900 - 10,000 + mn_casualty_loss_deduction: 4_900 + +- name: Case 9, a loss only the dependent had gives the parent nothing. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 100_000 + person2: + age: 10 + casualty_loss: 15_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MN + output: + is_tax_unit_dependent: [false, true] + mn_casualty_loss_deduction: 0 + +- name: Case 10, the 2018 Schedule M1CAT already allows a non-disaster loss. + period: 2018 + input: + state_code: MN + employment_income: 50_000 + casualty_loss: 8_000 + output: + # For a filer with no Schedule M1NC: (8,000 - 100) - 10% x 50,000 + # = 7,900 - 5,000 + mn_casualty_loss_deduction: 2_900 + casualty_loss_deduction: 0 + +- name: Case 11, the deduction is zero outside Minnesota. + period: 2024 + input: + state_code: WI + employment_income: 100_000 + casualty_loss: 15_000 + output: + mn_casualty_loss_deduction: 0 + +- name: Case 12, lifting the federal suspension does not change Minnesota's deduction. + period: 2024 + input: + gov.irs.deductions.itemized.casualty.active: true + state_code: MN + employment_income: 100_000 + casualty_loss: 15_000 + output: + # Minnesota's deduction does not read the federal switch. + mn_casualty_loss_deduction: 4_900 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_itemized_deductions.yaml index 632dba542f1..acb1fa03b73 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/mn_itemized_deductions.yaml @@ -39,7 +39,9 @@ adjusted_gross_income: 1_206_050 interest_deduction: 10_000 medical_expense_deduction: 400 - casualty_loss_deduction: 200 + # Minnesota's own casualty loss (Schedule M1SA line 19) is exempt from the + # reduction, as medical expenses are. + mn_casualty_loss_deduction: 200 state_code: MN output: mn_itemized_deductions: (10_000 + 400 + 200) - 0.80 * 10_000 @@ -90,3 +92,60 @@ state_code: MN output: mn_itemized_deductions: 10_000 + +# Schedule M1SA line 19 takes the casualty and theft loss from Schedule M1CAT: +# each casualty less $100, then less 10% of Form M1 line 1 (federal AGI). Minn. +# Stat. 290.0122, subd. 8, disregards the federal limit to declared disasters. +- name: 2024 MN itemized deductions include a non-disaster casualty loss + absolute_error_margin: 0.01 + period: 2024 + input: + filing_status: SINGLE + adjusted_gross_income: 100_000 + interest_deduction: 10_000 + casualty_loss: 15_000 + state_code: MN + output: + # The federal deduction is suspended. + casualty_loss_deduction: 0 + # M1CAT: (15,000 - 100) - 10% x 100,000 = 4,900 + mn_casualty_loss_deduction: 4_900 + # M1SA line 25: 10,000 interest + 4,900 casualty; AGI is under the + # $232,500 reduction threshold. + mn_itemized_deductions: 14_900 + +# M1SA line 26 Worksheet B, step 2, takes lines 4, 13 and 19 out before the +# 80% reduction (Minn. Stat. 290.0122, subd. 2(d)(3)). +- name: 2024 MN casualty loss is exempt from the high-income reduction + absolute_error_margin: 0.01 + period: 2024 + input: + filing_status: SINGLE + adjusted_gross_income: 2_000_000 + interest_deduction: 10_000 + casualty_loss: 250_000 + state_code: MN + output: + # M1CAT: (250,000 - 100) - 10% x 2,000,000 = 49,900 + mn_casualty_loss_deduction: 49_900 + # AGI is over $1,053,750, so the reduction is 80% of the deductions + # other than the casualty loss: 0.8 x 10,000 = 8,000. + # 10,000 + 49,900 - 8,000. Were the loss reduced too, the result would be + # 59,900 - 0.8 x 59,900 = 11,980. + mn_itemized_deductions: 51_900 + +- name: 2024 MN counts the casualty loss once when the federal suspension is lifted + absolute_error_margin: 0.01 + period: 2024 + input: + gov.irs.deductions.itemized.casualty.active: true + filing_status: SINGLE + adjusted_gross_income: 100_000 + interest_deduction: 10_000 + casualty_loss: 15_000 + state_code: MN + output: + mn_casualty_loss_deduction: 4_900 + # The federal deduction, now positive inside itemized_deductions_less_salt, + # is replaced by Minnesota's 4,900, not added to it. + mn_itemized_deductions: 14_900 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.yaml new file mode 100644 index 00000000000..cfa6d3c978a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.yaml @@ -0,0 +1,24 @@ +- name: Line 3 adds back the federal IRA deduction, not total contributions + # 2025 M1PR instructions, line 3 (PDF page 7): "the sum of your deductions + # for your qualified retirement plan contributions from lines 16 and 20 of + # federal Schedule 1". An active participant with $100,000 of wages is + # fully phased out of the IRA deduction, so line 3 is $0. The line 10 + # retirement subtraction still uses the $7,000 contributed. + period: 2025 + input: + people: + head: + age: 40 + employment_income: 100_000 + ira_active_participant: true + traditional_ira_contributions: 7_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MN + output: + traditional_ira_deduction: 0 + mn_homestead_credit_refund_household_income: 93_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml index 19d71f5778f..585f37ffda5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml @@ -94,3 +94,25 @@ # 2,119.43) plus health insurance 3,000. The spouse has no adjustment. ms_agi_adjustments: [5_119.43, 0] ms_agi: [24_880.57, 20_000] + +- name: Estate and trust income is included in Mississippi AGI + period: 2025 + input: + irs_employment_income: 30_000 + estate_income: 5_000 + state_code: MS + output: + # Form 80-105 line 41 reports federal Schedule E income, which includes + # trusts: 30,000 + 5,000, with no adjustments. + ms_agi: 35_000 + +- name: An estate or trust loss on federal Schedule E reduces Mississippi AGI + period: 2025 + input: + irs_employment_income: 30_000 + estate_income: -5_000 + state_code: MS + output: + # Line 41 is "the income or loss from activities reported on Federal + # Schedule E": 30,000 - 5,000. + ms_agi: 25_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml index 9e4f2540b71..472a0218dd0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml @@ -200,8 +200,16 @@ members: [person1] state_code: MT output: - # (863.08 - (gross_hh - 6,300) x 0.05) x 1.0, capped at $1,000 = $133. - mt_elderly_homeowner_or_renter_credit: 133.33 + # (863.08 - (gross_hh - 6,300) x 0.05) x 1.0, capped at $1,000. + # 2021 instructions, line 7: "Refundable credits include the federal and + # Montana Earned Income Tax Credit, the federal Child Tax Credits, + # Economic Impact Payments". Federal EITC (ARPA, no qualifying child) + # 1_502 - 15.3% x (20_892.60 - 11_610) = 81.76; Montana EITC 3% = 2.45; + # Economic Impact Payment 1_400. Line 10 = 20_892.60 + 1_484.21 = + # 22_376.81; line 12 = 16_076.81; line 14 = 803.84; line 19 = 863.08 - + # 803.84 = 59.24. The earlier expected value, 133.33, counted only the + # Montana EITC. + mt_elderly_homeowner_or_renter_credit: 59.24 - name: MT EHRC uses the $12,600 exclusion from 2022 (HB 191) period: 2022 @@ -223,3 +231,352 @@ # Same household, larger exclusion ($12,600) leaves a smaller expected # contribution, so the credit is higher: $539.67. mt_elderly_homeowner_or_renter_credit: 539.67 + +- name: 2023 Elderly Homeowner/Renter Credit Schedule worked case, a capital loss does not reduce gross household income + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: -3_000 + social_security_retirement: 10_000 + real_estate_taxes: 2_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Line 1 federal AGI 17_000 (Social Security untaxed: 17_000 + 5_000 is + # under the 25_000 base); line 5 untaxed Social Security 10_000; line 9 + # losses 3_000; line 10 gross household income 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + # Line 12 = 30_000 - 12_600 = 17_400; line 13 multiplier 0.05; + # line 14 = 870. + mt_elderly_homeowner_or_renter_credit_net_household_income: 870 + # Line 18 = 2_000; line 19 = 1_130; line 20 = min(1_130, 1_150); + # line 21 = 100% (under 35_000); line 22 = 1_130. + mt_elderly_homeowner_or_renter_credit: 1_130 + +- name: 2024 Schedule 2EC worked case, a dependent's wages count toward gross household income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 16 + employment_income: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 5 pensions 20_000; line 17 other household members' income + # 10_000 (the dependent's wages); line 18 gross household income 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + # Line 19 12_600; line 20 = 30_000 - 12_600 = 17_400; line 21 = 0.05; + # line 22 = 870. + mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] + # Line 23 = 2_000; line 26 = 2_000; line 27 = 2_000 - 870 = 1_130; + # line 28 = min(1_130, 1_150) = 1_130; line 29 = 100% (line 18 is under + # 35_000); line 30 = 1_130. Without the dependent's wages, gross + # household income would be 20_000 and the credit the 1_150 cap. + mt_elderly_homeowner_or_renter_credit: [1_130, 0] + +- name: 2024 Schedule 2EC worked case, a household member filing their own return counts + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 45 + taxable_interest_income: 10_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # The claimant's adult daughter lives in the household and files her + # own return. § 15-30-2337(7)(a): a household is "an association of + # persons who live in the same dwelling"; the 2024 instructions: "You do + # not need to be related to be in the same household." Line 5 = 20_000; + # line 17 = 10_000 (her interest); line 18 = 30_000; line 22 = 870; + # line 27 = 2_000 - 870 = 1_130; line 30 = 1_130. Her tax unit has no + # one aged 62 or older, so it claims nothing. + mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] + mt_elderly_homeowner_or_renter_credit: [1_130, 0] + +- name: 2024 Schedule 2EC worked case, a dependent's wages move the credit into the 40 percent band + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 17 + employment_income: 16_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 18 = 20_000 + 16_000 = 36_000; line 20 = 23_400; line 22 = + # 23_400 x 0.05 = 1_170; line 27 = 2_000 - 1_170 = 830; line 28 = 830; + # line 29 = 40% (36_000 is in the 35_000 to 37_500 band); + # line 30 = 830 x 0.4 = 332. + mt_elderly_homeowner_or_renter_credit_net_household_income: [1_170, 0] + mt_elderly_homeowner_or_renter_credit: [332, 0] + +- name: 2024 Schedule 2EC, a dependent's wages bring gross household income to 45,000 + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 17 + employment_income: 25_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 18 = 45_000. The claimant must have gross household income "less + # than $45,000"; line 29 = 0% at 45_000 and above. + mt_elderly_homeowner_or_renter_credit: [0, 0] + +- name: 2023 Elderly Homeowner/Renter Credit Schedule worked case, a non-filing dependent's wages count + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 16 + employment_income: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 1 federal AGI 20_000 (the head's return). The dependent's 10_000 + # of wages is under the 13_850 filing threshold for dependents; the + # 2023 instructions say members of the household not filing a Form 2 or + # federal Form 1040 "must report all wages" on line 8. Line 10 = 30_000; + # line 12 = 17_400; line 13 = 0.05; line 14 = 870; line 15 = 2_000; + # line 18 = 2_000; line 19 = 1_130; line 20 = 1_130; line 21 = 100%; + # line 22 = 1_130. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] + mt_elderly_homeowner_or_renter_credit: [1_130, 0] + +- name: 2024 Schedule 2EC worked case, a household member's federal EITC counts as a refundable credit received + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 40 + employment_income: 10_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # The claimant's adult son lives in the household and files his own + # return. Line 5 pensions 20_000. Line 8, refundable credits received by + # all household members: his federal EITC 632 (Rev. Proc. 2023-34, no + # qualifying child, wages between 8_260 and 10_330) and Montana EITC + # 10% x 632 = 63.20, 695.20 in all. Line 17 his wages 10_000. Line 18 + # gross household income 30_695.20; line 20 = 30_695.20 - 12_600 = + # 18_095.20; line 21 = 0.05; line 22 = 904.76; line 27 = 2_000 - 904.76 + # = 1_095.24; line 28 = 1_095.24 (under 1_150); line 29 = 100%; line 30 = + # 1_095.24. Counting only his Montana EITC gave 1_126.84. + mt_elderly_homeowner_or_renter_credit_net_household_income: [904.76, 0] + mt_elderly_homeowner_or_renter_credit: [1_095.24, 0] + +- name: 2024 Schedule 2EC worked case, a parent's federal EITC and refundable child tax credit count + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 30 + employment_income: 10_000 + person3: + age: 5 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2, person3] + households: + household: + members: [person1, person2, person3] + state_name: MT + output: + # The claimant's daughter and grandchild live in the household; the + # daughter files her own return. Line 8: federal EITC 34% x 10_000 = + # 3_400, refundable part of the federal child tax credit + # min(1_700, 15% x (10_000 - 2_500)) = 1_125, Montana EITC 340; 4_865 in + # all. Line 17 her wages 10_000. Line 18 = 20_000 + 4_865 + 10_000 = + # 34_865; line 20 = 22_265; line 22 = 22_265 x 0.05 = 1_113.25; line 27 = + # 2_000 - 1_113.25 = 886.75; line 29 = 100% (under 35_000); line 30 = + # 886.75. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 14_865, 0] + mt_elderly_homeowner_or_renter_credit_net_household_income: [1_113.25, 0, 0] + mt_elderly_homeowner_or_renter_credit: [886.75, 0, 0] + +- name: 2024 Schedule 2EC worked case, federal credits move gross household income into the 30 percent band + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 30 + employment_income: 12_000 + person3: + age: 5 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2, person3] + households: + household: + members: [person1, person2, person3] + state_name: MT + output: + # Line 8: federal EITC 34% x 12_000 = 4_080 (under the 12_390 earned + # income amount), refundable child tax credit + # min(1_700, 15% x 9_500) = 1_425, Montana EITC 408; 5_913 in all. + # Line 18 = 20_000 + 5_913 + 12_000 = 37_913; line 20 = 25_313; + # line 22 = 1_265.65; line 27 = 734.35; line 29 = 30% (37_501 to 40_000); + # line 30 = 734.35 x 0.3 = 220.31. Counting only the Montana EITC, line + # 18 was 32_408 and the credit 1_009.60. + mt_elderly_homeowner_or_renter_credit: [220.31, 0, 0] + +- name: 2025 Schedule 2EC worked case, the credit and the property tax rebate received in 2025 count + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + taxable_pension_income: 20_000 + real_estate_taxes: + 2024: 1_000 + 2025: 1_000 + mt_elderly_homeowner_or_renter_credit_received: 1_150 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 400 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2024: MT + 2025: MT + output: + # Line 5 pensions 20_000. Line 8: "your elderly homeowner/renter credit + # received in 2025", 1_150 (the 2024 credit), and "the 2024 Montana + # property tax rebate", min(400, 1_000) = 400; 1_550 in all. Line 18 = + # 21_550; line 20 = 8_950; line 21 = 0.039 (8_000 to 9_000); line 22 = + # 349.05; line 27 = 1_000 - 349.05 = 650.95; line 30 = 650.95. Without + # line 8, line 22 would be 7_400 x 0.035 = 259 and the credit 741. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 21_550 + mt_elderly_homeowner_or_renter_credit_net_household_income: 349.05 + mt_elderly_homeowner_or_renter_credit: 650.95 + +- name: 2023 Elderly Homeowner/Renter Credit Schedule worked case, a household member's federal EITC counts on line 7 + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: + 2022: 2_000 + 2023: 2_000 + person2: + age: 40 + employment_income: 10_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_code: + 2022: MT + 2023: MT + output: + # Line 1 federal AGI of all household members 30_000. Line 7, the + # refundable credits received by the members of the household: the + # son's federal EITC, Rev. Proc. 2022-38, no qualifying child, + # 600 - 7.65% x (10_000 - 9_800) = 584.70, and Montana EITC 3%, 17.54; + # 602.24 in all. No actual property-rebate receipt is supplied, so + # none counts. Line 10 = 30_602.24; line 12 = 18_002.24; line 13 = + # 0.05; line 14 = 900.11; line 19 = 2_000 - 900.11 = 1_099.89; line 20 + # = 1_099.89; line 21 = 100%; line 22 = 1_099.89. + mt_elderly_homeowner_or_renter_credit_net_household_income: [900.11, 0] + mt_elderly_homeowner_or_renter_credit: [1_099.89, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_dependent_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_dependent_deductions.yaml new file mode 100644 index 00000000000..a0bd2ad43a7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_dependent_deductions.yaml @@ -0,0 +1,195 @@ +- name: Dependent Schedule C income keeps the dependent's half-SE-tax deduction + period: 2023 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + dependent: + age: 16 + is_tax_unit_dependent: true + self_employment_income: 15_000 + tax_units: + tax_unit: + members: [claimant, dependent] + households: + household: + members: [claimant, dependent] + state_name: MT + output: + # 2023 Schedule SE p.1: 15,000 * .9235 * .153 / 2 = 1,059.71625. + # 2023 MT instructions p.51, line 1: include each member's federal AGI. + # Dependent AGI without losses = 15,000 - 1,059.71625 = 13,940.28375; + # GHI = 20,000 + 13,940.28375 = 33,940.28375. + self_employment_tax_ald_person: [0, 1_059.71625] + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 13_940.28375] + # 2,000 - (33,940.28375 - 12,600) * .05 = 932.9858125. + # GHI is below 35,000, so the credit multiplier is 100%. + mt_elderly_homeowner_or_renter_credit: [932.9858125, 0] + +- name: The statutory AGI definition also preserves dependent deductions in 2024 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + dependent: + age: 16 + is_tax_unit_dependent: true + self_employment_income: 15_000 + tax_units: + tax_unit: + members: [claimant, dependent] + households: + household: + members: [claimant, dependent] + state_name: MT + output: + # HB191 section 1(9), PDF p.2: federal AGI without losses remains the + # income definition. 15,000 - 15,000 * .9235 * .153 / 2 = 13,940.28375. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 13_940.28375] + # 2,000 - (20,000 + 13,940.28375 - 12,600) * .05 = 932.9858125. + mt_elderly_homeowner_or_renter_credit: [932.9858125, 0] + +- name: Own personal deductions count once and parental HSA deductions stay with the parent + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + mt_refundable_credits_before_renter_credit: 0 + dependent: + age: 16 + is_tax_unit_dependent: true + employment_income: 10_000 + self_employment_income: 15_000 + traditional_ira_contributions: 1_000 + early_withdrawal_penalty: 200 + self_employed_health_insurance_premiums: 500 + self_employed_pension_contributions: 600 + long_term_capital_gains: -3_000 + rental_income: -2_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [claimant, dependent] + health_savings_account_ald: 1_000 + income_tax_refundable_credits: 0 + households: + household: + members: [claimant, dependent] + state_name: MT + output: + # Parent AGI: 20,000 - own HSA deduction 1,000 = 19,000. + # Dependent: 10,000 + 15,000 - 1,059.71625 - 1,000 - 200 - 500 - 600 + # = 21,640.28375. Capital/rental losses do not reduce MT income. + # The parent's HSA deduction must not be subtracted from the child again. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [19_000, 21_640.28375] + +- name: Parent-owned exclusions recorded on a dependent are not deducted twice + period: 2024 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + mt_refundable_credits_before_renter_credit: 0 + dependent: + age: 16 + is_tax_unit_dependent: true + employment_income: 10_000 + qualified_adoption_assistance_expense: 500 + us_bonds_for_higher_ed: 700 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [claimant, dependent] + income_tax_refundable_credits: 0 + households: + household: + members: [claimant, dependent] + state_name: MT + output: + # The established filer_amounts_recorded_on_dependents convention puts + # 500 + 700 = 1,200 on the parent's return: 20,000 - 1,200 = 18,800. + # Neither is the dependent's own deduction: their income stays 10,000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [18_800, 10_000] + +- name: Dependent educator and IRA deductions retain their statutory annual caps + period: 2023 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + mt_refundable_credits_before_renter_credit: 0 + dependent: + age: 19 + is_tax_unit_dependent: true + employment_income: 10_000 + traditional_ira_contributions: 10_000 + educator_expense: 1_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [claimant, dependent] + income_tax_refundable_credits: 0 + households: + household: + members: [claimant, dependent] + state_name: MT + output: + # https://www.irs.gov/pub/irs-prior/p590a--2023.pdf#page=11: + # IRA deduction = min(10,000 contributions, 10,000 compensation, + # 6,500 annual limit) = 6,500; supplied actual excess is not deductible. + traditional_ira_deduction: [0, 6_500] + # https://www.irs.gov/pub/irs-prior/i1040gi--2023.pdf#page=89: + # Eligible educator deduction = min(1,000 expenses, 300 cap) = 300. + educator_expense_ald_person: [0, 300] + # Dependent AGI without losses = 10,000 - 6,500 - 300 = 3,200. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 3_200] + +- name: Own compensation caps the IRA deduction without flooring valid negative AGI + period: 2024 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + mt_refundable_credits_before_renter_credit: 0 + dependent: + age: 19 + is_tax_unit_dependent: true + employment_income: 2_000 + traditional_ira_contributions: 10_000 + educator_expense: 1_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [claimant, dependent] + income_tax_refundable_credits: 0 + households: + household: + members: [claimant, dependent] + state_name: MT + output: + # Pub.590-A (2023) pp.2,11: 2024 annual limit is 7,000; compensation + # is only 2,000, so min(10,000, 2,000, 7,000) = 2,000 is deductible. + traditional_ira_deduction: [0, 2_000] + # The $300 educator deduction is valid separately from compensation: + # 2,000 - 2,000 - 300 = -300. This is an adjustment, not a loss. + educator_expense_ald_person: [0, 300] + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, -300] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_federal_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_federal_refundable_credits.yaml new file mode 100644 index 00000000000..5f2f32ff9fe --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_federal_refundable_credits.yaml @@ -0,0 +1,95 @@ +- name: Case 1, a single parent's federal EITC and refundable CTC are counted once, on the head. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + employment_income: 10_000 + person2: + age: 5 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Rev. Proc. 2023-34, one qualifying child: 34% x 10_000 = 3_400 (under + # the 12_390 earned income amount). Refundable CTC: no tax before credits + # (10_000 is under the standard deduction), so + # min(1_700, 15% x (10_000 - 2_500)) = 1_125. + eitc: 3_400 + refundable_ctc: 1_125 + # 3_400 + 1_125 = 4_525, on the head only. + mt_elderly_homeowner_or_renter_credit_federal_refundable_credits: [4_525, 0] + +- name: Case 2, a joint return's federal refundable credits go to the head, not the spouse or child. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + employment_income: 15_000 + person2: + age: 33 + person3: + age: 4 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Rev. Proc. 2023-34, one child: 15_000 is above the 12_390 earned income + # amount and under the 29_640 joint phaseout threshold, so the maximum + # 4_213. Refundable CTC: min(1_700, 15% x (15_000 - 2_500) = 1_875) + # = 1_700. Total 5_913. + mt_elderly_homeowner_or_renter_credit_federal_refundable_credits: [5_913, 0, 0] + +- name: Case 3, the 2021 Economic Impact Payment counts with the federal EITC. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + employment_income: 20_892.60 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + # 2021 Elderly Homeowner/Renter Credit Schedule instructions, line 7: + # "Refundable credits include the federal and Montana Earned Income Tax + # Credit, the federal Child Tax Credits, Economic Impact Payments". + # 2021 childless EITC (ARPA): 1_502 - 15.3% x (20_892.60 - 11_610) + # = 81.76. Recovery rebate credit (third-round payment): 1_400. + eitc: 81.76 + mt_elderly_homeowner_or_renter_credit_federal_refundable_credits: 1_481.76 + +- name: Case 4, zero outside Montana. + period: 2024 + input: + people: + person1: + age: 30 + employment_income: 10_000 + person2: + age: 5 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: ID + output: + mt_elderly_homeowner_or_renter_credit_federal_refundable_credits: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml index cee870a82af..ec00f8c2fad 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml @@ -37,3 +37,302 @@ # § 15-30-2337(9)(a)(viii) requires all SS to be counted, even the # portion excluded from federal AGI. mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + +- name: A capital loss deducted in federal AGI is added back + period: 2023 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: -3_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # § 15-30-2337(9)(a): federal AGI "without regard to loss". 2023 Elderly + # Homeowner/Renter Credit Schedule: + # line 1 federal AGI 20_000 - 3_000 = 17_000; line 9 adds back the 3_000 + # capital loss; line 10 = 20_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 20_000 + +- name: A business loss deducted in federal AGI is added back once for the return + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + mt_refundable_credits_before_renter_credit: 0 + person2: + age: 68 + self_employment_income: -5_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Federal AGI 20_000 - 5_000 = 15_000; the 5_000 loss is added back, so + # gross household income is 20_000 (2024 Schedule 2EC line 18). Net household + # income: (20_000 - 12_600) * 0.035 = 259, allocated to the head. + mt_elderly_homeowner_or_renter_credit_net_household_income: [259, 0] + +- name: A dependent's wages count in gross household income + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 16 + employment_income: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # § 15-30-2337(4): "all income received by all individuals of a + # household". The dependent's wages are on their own return, not in the + # head's federal AGI. 2024 Schedule 2EC: line 5 pensions 20_000 (head); + # line 17 income of other household members 10_000 (dependent's wages); + # line 18 gross household income 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: A dependent's Social Security counts once, in full + period: 2024 + input: + people: + person1: + age: 70 + person2: + age: 17 + employment_income: 25_000 + social_security_survivors: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # On the dependent's own return, provisional income is + # 25_000 + 10_000 / 2 = 30_000, so 50% x (30_000 - 25_000) = 2_500 of the + # benefits is taxable (IRC 86) and is part of dependent_gross_income. + dependent_taxable_social_security: [0, 2_500] + # § 15-30-2337(9)(a)(viii) counts all Social Security, and 2024 + # Schedule 2EC line 6 takes "the total taxable and nontaxable" benefits: + # wages 25_000 + Social Security 10_000 = 35_000, not 37_500. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [0, 35_000] + +- name: A dependent's capital loss does not reduce gross household income + period: 2024 + input: + people: + person1: + age: 70 + person2: + age: 16 + employment_income: 10_000 + long_term_capital_gains: -3_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # § 15-30-2337(9)(a): income "without regard to loss". 2024 Schedule 2EC + # line 7: "do not include any capital losses". Wages 10_000 count; the + # 3_000 loss does not reduce them. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [0, 10_000] + +- name: A household member's federal and Montana EITC count once, on their own return + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 40 + employment_income: 10_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # 2024 Schedule 2EC line 8: "Combine all the refundable credits received + # by all household members in 2024. Refundable credits include the + # federal and Montana earned income tax credits". Rev. Proc. 2023-34, no + # qualifying child: 10_000 is above the 8_260 earned income amount and + # under the 10_330 phaseout threshold, so the maximum 632; the Montana + # EITC is 10% of it, 63.20. The member's 10_000 + 632 + 63.20. + mt_elderly_homeowner_or_renter_credit_federal_refundable_credits: [0, 632] + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_695.20] + +- name: A parent's federal EITC and refundable child tax credit count in gross household income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 30 + employment_income: 10_000 + person3: + age: 5 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2, person3] + households: + household: + members: [person1, person2, person3] + state_name: MT + output: + # 2024 Schedule 2EC line 8 counts "the refundable portion of the federal + # child tax credit". Federal EITC 34% x 10_000 = 3_400; refundable CTC + # min(1_700, 15% x 7_500) = 1_125; Montana EITC 340. The parent: + # 10_000 + 3_400 + 1_125 + 340 = 14_865. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 14_865, 0] + +- name: The elderly homeowner/renter credit received during the year counts + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + mt_elderly_homeowner_or_renter_credit_received: 1_150 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # 2024 Schedule 2EC line 8: "including your elderly homeowner/renter + # credit received in 2024". 20_000 + 1_150. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 21_150 + +- name: The rebate for 2023 property taxes, received in 2024, counts + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: + 2023: 2_000 + 2024: 2_000 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 675 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2023: MT + 2024: MT + output: + # 2024 Schedule 2EC line 8 lists "the 2023 Montana property tax rebate": + # min(675, 2_000) = 675. 20_000 + 675. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 20_675 + +- name: Actual rebate cash received in 2023 counts under the broad income definition + period: 2023 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: + 2022: 2_000 + 2023: 2_000 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 675 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2022: MT + 2023: MT + output: + # HB191 section1(9), PDFp2: all nontaxable income; ARM42.4.301(2)(c): + # state refundable credits received. The illustrative 2023 list does + # not exempt the rebate. Pension 20_000 + actual cash 675 = 20_675. + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 675 + mt_elderly_homeowner_or_renter_credit_gross_household_income: 20_675 + +- name: Federal refundable credits count from 2017 + period: 2017 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 10_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # ARM 42.4.301(2)(b), effective 11/10/2017: "federal refundable credits + # received"; 2017 Form 2EC worksheet item 4: "Any federal or state + # refundable credits". Rev. Proc. 2016-55, no qualifying child: + # 510 - 7.65% x (10_000 - 8_340) = 383.01. + eitc: 383.01 + mt_elderly_homeowner_or_renter_credit_gross_household_income: 10_383.01 + +- name: Federal refundable credits do not count before 2017 + period: 2016 + input: + people: + person1: + age: 40 + employment_income: 10_000 + mt_refundable_credits_before_renter_credit: 0 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # The 2004-2017 ARM 42.4.301(2)(b) counted federal refunds only "to the + # extent that the amount recovered reduced the claimant's Montana income + # tax in a prior year". + mt_elderly_homeowner_or_renter_credit_gross_household_income: 10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.yaml index dcc4e975a04..052498f9036 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.yaml @@ -40,3 +40,29 @@ output: # (48,000 - 6,300) x 0.05 with the 2021 exclusion of $6,300. mt_elderly_homeowner_or_renter_credit_net_household_income: [2_085, 0] + +- name: Gross household income includes members outside the claimant's tax unit + period: 2024 + input: + people: + person1: + mt_elderly_homeowner_or_renter_credit_gross_household_income: 20_000 + mt_elderly_homeowner_or_renter_credit_eligible: true + person2: + mt_elderly_homeowner_or_renter_credit_gross_household_income: 10_000 + mt_elderly_homeowner_or_renter_credit_eligible: false + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # 2024 Schedule 2EC line 18 = 20_000 + 10_000 (line 17, the other member) + # = 30_000; line 20 = 30_000 - 12_600 = 17_400; line 21 = 0.05; + # line 22 = 870, on the claimant's row. person2's tax unit has no + # claimant, so 0. + mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received.yaml new file mode 100644 index 00000000000..dc2ddf161cf --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received.yaml @@ -0,0 +1,139 @@ +- name: Case 1, the head reports actual rebate cash received in 2024. + period: 2024 + input: + people: + person1: + age: 70 + real_estate_taxes: + 2023: 2_000 + 2024: 2_100 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 675 + person2: + age: 68 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: + 2023: MT + 2024: MT + output: + # 2024 Schedule 2EC line 8: "the 2023 Montana property tax rebate", + # min(675, 2_000) = 675. + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: [675, 0] + +- name: Case 2, the person reports a smaller actual rebate receipt. + period: 2024 + input: + people: + person1: + age: 70 + real_estate_taxes: + 2023: 500 + 2024: 900 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2023: MT + 2024: MT + output: + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 500 + +- name: Case 3, the 2024 rebate of 400 is received in 2025. + period: 2025 + input: + people: + person1: + age: 70 + real_estate_taxes: + 2024: 2_000 + 2025: 2_000 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 400 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2024: MT + 2025: MT + output: + # 2025 Schedule 2EC line 8: "the 2024 Montana property tax rebate"; + # Ch. 767, L. 2025, sec. 3(1): $400 or the property taxes paid, if less. + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 400 + +- name: Case 4, no actual cash receipt reported in 2026 defaults to zero. + period: 2026 + input: + people: + person1: + age: 70 + real_estate_taxes: + 2025: 2_000 + 2026: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2025: MT + 2026: MT + output: + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 0 + +- name: Case 5, no reported actual receipt defaults to zero. + period: 2024 + input: + people: + person1: + age: 70 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + # No actual cash receipt was reported; default zero. + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 0 + +- name: Case 6, a prior tax bill does not establish rebate eligibility or receipt. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: + 2024: 1_000 + 2025: 1_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2024: MT + 2025: MT + output: + # Reviewer: six months owning/occupying in 2024 fails SB542 section1(3), + # PDFp2 (minimum seven months); section3(5), PDFp3 also requires a claim. + # No actual receipt: 1_000 - (20_000 - 12_600) * .035 = 741. + # Inferring 400 from the old tax bill incorrectly gave 727. + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 0 + mt_elderly_homeowner_or_renter_credit_gross_household_income: 20_000 + mt_elderly_homeowner_or_renter_credit: 741 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.yaml index b2c717f534e..ea970786fd0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.yaml @@ -21,3 +21,29 @@ state_code: MT output: mt_property_tax_rebate: 675 + +- name: The tax year 2024 rebate is 400 + period: 2024 + input: + real_estate_taxes: 2_000 + state_code: MT + output: + # Ch. 767, L. 2025 (SB 542), sec. 3(1): "$400 or the amount of total + # property taxes paid, whichever is less, for tax year 2024". + mt_property_tax_rebate: 400 + +- name: The tax year 2024 rebate is the property tax when that is less than 400 + period: 2024 + input: + real_estate_taxes: 300 + state_code: MT + output: + mt_property_tax_rebate: 300 + +- name: No rebate for tax year 2025 + period: 2025 + input: + real_estate_taxes: 2_000 + state_code: MT + output: + mt_property_tax_rebate: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.yaml index df49679c68b..4cdfcd2395d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.yaml @@ -19,8 +19,8 @@ state_code: MT output: # One qualifying individual caps expenses at 2_400. - # Head receives half: 2_400 * 0.5 = 1_200. - mt_child_dependent_care_expense_deduction: [1_200, 0] + # A single filer enters the full line 7 amount: 2_400. + mt_child_dependent_care_expense_deduction: [2_400, 0] - name: Care expense greater than maximum amount with moderate AGI and 1 child period: 2021 @@ -43,8 +43,8 @@ state_code: MT output: # 2_400 - 0.5 * (20_000 - 18_000) = 1_400. - # Head receives half: 1_400 * 0.5 = 700. - mt_child_dependent_care_expense_deduction: [700, 0] + # A single filer enters the full line 7 amount: 1_400. + mt_child_dependent_care_expense_deduction: [1_400, 0] - name: Care expense greater than maximum amount with high AGI and 1 child period: 2021 @@ -92,8 +92,8 @@ state_code: MT output: # Two qualifying individuals cap expenses at 3_600. - # Head receives half: 3_600 * 0.5 = 1_800. - mt_child_dependent_care_expense_deduction: [1_800, 0, 0] + # A single filer enters the full line 7 amount: 3_600. + mt_child_dependent_care_expense_deduction: [3_600, 0, 0] - name: Care expense greater than maximum amount with moderate AGI and 2 children period: 2021 @@ -119,8 +119,8 @@ state_code: MT output: # 3_600 - 0.5 * (22_000 - 18_000) = 1_600. - # Head receives half: 1_600 * 0.5 = 800. - mt_child_dependent_care_expense_deduction: [800, 0, 0] + # A single filer enters the full line 7 amount: 1_600. + mt_child_dependent_care_expense_deduction: [1_600, 0, 0] - name: Care expense greater than maximum amount with high AGI and 2 children period: 2021 @@ -174,8 +174,8 @@ state_code: MT output: # Three qualifying individuals cap expenses at 4_800. - # Head receives half: 4_800 * 0.5 = 2_400. - mt_child_dependent_care_expense_deduction: [2_400, 0, 0, 0] + # A single filer enters the full line 7 amount: 4_800. + mt_child_dependent_care_expense_deduction: [4_800, 0, 0, 0] - name: Care expense greater than maximum amount with moderate AGI and 3 children period: 2021 @@ -204,8 +204,8 @@ state_code: MT output: # 4_800 - 0.5 * (24_000 - 18_000) = 1_800. - # Head receives half: 1_800 * 0.5 = 900. - mt_child_dependent_care_expense_deduction: [900, 0, 0, 0] + # A single filer enters the full line 7 amount: 1_800. + mt_child_dependent_care_expense_deduction: [1_800, 0, 0, 0] - name: Care expense greater than maximum amount with high AGI and 3 children period: 2021 @@ -255,8 +255,8 @@ members: [person1, person2] state_code: MT output: - # Head receives half: 2_000 * 0.5 = 1_000. - mt_child_dependent_care_expense_deduction: [1_000, 0] + # A single filer enters the full line 7 amount: 2_000. + mt_child_dependent_care_expense_deduction: [2_000, 0] - name: Care expense less than maximum amount with high AGI and 1 child period: 2021 @@ -303,8 +303,8 @@ members: [person1, person2, person3] state_code: MT output: - # Head receives half: 3_000 * 0.5 = 1_500. - mt_child_dependent_care_expense_deduction: [1_500, 0, 0] + # A single filer enters the full line 7 amount: 3_000. + mt_child_dependent_care_expense_deduction: [3_000, 0, 0] - name: Care expense less than maximum amount with high AGI and 2 children period: 2021 @@ -357,8 +357,8 @@ members: [person1, person2, person3, person4] state_code: MT output: - # Head receives half: 4_000 * 0.5 = 2_000. - mt_child_dependent_care_expense_deduction: [2_000, 0, 0, 0] + # A single filer enters the full line 7 amount: 4_000. + mt_child_dependent_care_expense_deduction: [4_000, 0, 0, 0] - name: Care expense less than maximum amount with high AGI and 3 children period: 2021 @@ -441,8 +441,8 @@ state_code: MT output: # One qualifying individual caps expenses at 2_400 (Form 2441-M line 2). - # Head receives half: 2_400 * 0.5 = 1_200. - mt_child_dependent_care_expense_deduction: [1_200, 0] + # A single filer enters the full line 7 amount: 2_400. + mt_child_dependent_care_expense_deduction: [2_400, 0] - name: Case 18, capable disabled adult care expenses are excluded. period: 2021 @@ -470,8 +470,8 @@ state_code: MT output: # Only the child qualifies, so 2_000 of adult care expenses are excluded. - # Head receives half of the 1_000 childcare expense deduction. - mt_child_dependent_care_expense_deduction: [500, 0, 0] + # A single filer enters the full 1_000 childcare expense deduction. + mt_child_dependent_care_expense_deduction: [1_000, 0, 0] - name: Case 19, a child and an incapacitated adult dependent both count toward the tier. period: 2021 @@ -500,8 +500,8 @@ output: # Two qualifying individuals cap expenses at 3_600 (Form 2441-M line 2). # Childcare 3_000 + adult care 1_000 = 4_000, capped at 3_600. - # Head receives half: 3_600 * 0.5 = 1_800. - mt_child_dependent_care_expense_deduction: [1_800, 0, 0] + # A single filer enters the full line 7 amount: 3_600. + mt_child_dependent_care_expense_deduction: [3_600, 0, 0] - name: Case 20, adult care expenses cannot exceed the one-qualifying-individual tier cap. period: 2021 @@ -525,8 +525,8 @@ state_code: MT output: # One qualifying individual caps expenses at 2_400 (Form 2441-M line 2). - # Head receives half: 2_400 * 0.5 = 1_200. - mt_child_dependent_care_expense_deduction: [1_200, 0] + # A single filer enters the full line 7 amount: 2_400. + mt_child_dependent_care_expense_deduction: [2_400, 0] - name: Case 21, incapacitated spouse care expenses qualify. period: 2021 @@ -606,8 +606,8 @@ output: # One qualifying individual caps expenses at 2_400 (Form 2441-M line 2). # Childcare 1_500 + care expenses 1_000 = 2_500, capped at 2_400. - # Head receives half: 2_400 * 0.5 = 1_200. - mt_child_dependent_care_expense_deduction: [1_200, 0] + # A single filer enters the full line 7 amount: 2_400. + mt_child_dependent_care_expense_deduction: [2_400, 0] - name: Case 24, combined childcare and adult care expenses are capped by the three-qualifying-individual tier. period: 2021 @@ -643,3 +643,109 @@ # Childcare 4_000 + adult care 2_000 = 6_000, capped at 4_800. # The deduction is divided equally between spouses: 4_800 * 0.5 = 2_400. mt_child_dependent_care_expense_deduction: [2_400, 2_400, 0, 0] + +- name: Case 25, the phase-out uses the couple's combined Montana AGI, then each column gets half + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + mt_agi_indiv: 20_000 + person2: + is_tax_unit_spouse: true + mt_agi_indiv: 0 + person3: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + tax_unit_childcare_expenses: 3_000 + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Form 2441-M (2022): line 2 = min(3_000, 2_400) = 2_400; line 3 adds + # columns A and B of Form 2 line 14 = 20_000 + 0 = 20_000; line 5 = 2_000; + # line 6 = 1_000; line 7 = 1_400. Filing separately on the same form, each + # spouse enters one-half of line 7 (700). Applying the phase-out to each + # spouse's own AGI would give 700 and 1_200. + mt_child_dependent_care_expense_deduction_eligible_expenses: 2_400 + mt_child_dependent_care_expense_deduction: [700, 700, 0] + +- name: Case 26, a joint return uses the return's Montana AGI and claims the whole deduction + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + person2: + is_tax_unit_spouse: true + person3: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + tax_unit_childcare_expenses: 3_000 + mt_agi_joint: 20_000 + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Form 2441-M lines 2-7 with line 3 = 20_000: 2_400 - 0.5 * 2_000 = 1_400, + # entered in full on the joint return. + mt_child_dependent_care_expense_deduction_joint: 1_400 + +- name: Case 27, the joint deduction is repealed beginning in 2024 + period: 2024 + input: + people: + person1: + is_tax_unit_head: true + person2: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2] + tax_unit_childcare_expenses: 3_000 + mt_agi_joint: 10_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_child_dependent_care_expense_deduction_joint: 0 + +- name: Case 28, a married person filing on a separate form cannot take the deduction + period: 2022 + input: + people: + person1: + is_tax_unit_head: true + mt_agi_indiv: 15_000 + person2: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2] + filing_status: SEPARATE + tax_unit_childcare_expenses: 2_000 + mt_agi_joint: 15_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # MCA 15-30-2131(1)(c)(vi)(A): "married couples shall file a joint return + # or file separately on the same form". 2022 instructions, line 14: "If + # you are married and filing separately on separate forms, or your spouse + # is not filing, you cannot qualify for this deduction." + mt_child_dependent_care_expense_deduction: [0, 0] + mt_child_dependent_care_expense_deduction_joint: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml new file mode 100644 index 00000000000..f6f0e87b609 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml @@ -0,0 +1,88 @@ +# 2022 Form 2441-M, lines 2-7 and separate-spouse allocation (PDF p. 1): +# https://revenue.mt.gov/files/Forms/Child-and-Dependent-Care-Expense-Deduction-Form-2441-M/2022_Child_and_Dependent_Care_Expense_Deduction_Form_2441-M.pdf#page=1 +# Form 2, Itemized Deductions Schedule lines 8 and 14, and Standard Deduction +# Worksheet lines 1-6 (PDF p. 7): +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 +# Instructions for Form 2 line 15 require both spouses to use the same method +# (PDF p. 16); the model retains the standard deduction when totals tie: +# https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=16 + +- name: Combined-AGI care deduction leaves standard deductions at the itemization boundary + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_dependent: false + mt_agi_indiv: 20_000 + home_mortgage_interest: 4_860 + spouse: + age: 39 + is_tax_unit_dependent: false + mt_agi_indiv: 0 + child: + age: 5 + is_tax_unit_dependent: true + mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + state_filing_status_if_married_filing_separately_on_same_return: SEPARATE + tax_unit_childcare_expenses: 3_000 + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + # Care = min(3_000, 2_400) - 0.5 * (20_000 + 0 - 18_000) + # = 1_400; each spouse gets 700. Itemized total = 4_860 + 700 + 700 + # = 6_260. Standard = max(20% * 20_000, 2_260) + 2_260 = 6_260. + # The unchanged tie rule selects standard deductions. Phasing out on + # each spouse's own AGI would incorrectly give 700 + 1_200 care and + # itemized total 6_760, crossing the choice boundary. + mt_child_dependent_care_expense_deduction: [700, 700, 0] + mt_itemized_deductions_indiv: [5_560, 700, 0] + mt_standard_deduction_indiv: [4_000, 2_260, 0] + mt_tax_unit_itemizes: false + mt_deductions_indiv: [4_000, 2_260, 0] + +- name: One more dollar of mortgage interest crosses the derived childcare itemization boundary + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_dependent: false + mt_agi_indiv: 20_000 + home_mortgage_interest: 4_861 + spouse: + age: 39 + is_tax_unit_dependent: false + mt_agi_indiv: 0 + child: + age: 5 + is_tax_unit_dependent: true + mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + state_filing_status_if_married_filing_separately_on_same_return: SEPARATE + tax_unit_childcare_expenses: 3_000 + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + # The same 1_400 care deduction gives itemized total + # 4_861 + 700 + 700 = 6_261, exceeding the unchanged 6_260 standard. + mt_child_dependent_care_expense_deduction: [700, 700, 0] + mt_itemized_deductions_indiv: [5_561, 700, 0] + mt_standard_deduction_indiv: [4_000, 2_260, 0] + mt_tax_unit_itemizes: true + mt_deductions_indiv: [5_561, 700, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.yaml index e0fed58cbeb..941a60e6c1b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.yaml @@ -1,39 +1,46 @@ -- name: Deductions split evenly +# 2022 Form 2 instructions, Itemized Deductions Schedule, line 4 (PDF p. 33): +# withholding is reported by the spouse who earned the income, and each +# spouse filing separately is limited to 5,000. + +- name: Each spouse deducts their own withholding estimate period: 2021 input: people: person1: - mt_agi_indiv: 500 + mt_withheld_income_tax: 3_000 + adjusted_gross_income_person: 50_000 person2: - mt_agi_indiv: 500 + mt_withheld_income_tax: 1_000 + adjusted_gross_income_person: 50_000 tax_units: tax_unit: members: [person1, person2] - mt_federal_income_tax_deduction_for_federal_itemization: 3_000 state_filing_status_if_married_filing_separately_on_same_return: SEPARATE households: household: members: [person1, person2] state_code: MT output: - mt_federal_income_tax_deduction_for_federal_itemization_indiv: [1_500, 1_500] + # Prorating the couple's 4_000 by Montana AGI would give 2_000 / 2_000. + mt_federal_income_tax_deduction_for_federal_itemization_indiv: [3_000, 1_000] -- name: Deductions capped at max amount individually +- name: Each spouse is capped at 5,000 on their own amount period: 2021 input: people: person1: - mt_agi_indiv: 100 + mt_withheld_income_tax: 7_000 + adjusted_gross_income_person: 60_000 person2: - mt_agi_indiv: 900 + mt_withheld_income_tax: 1_000 + adjusted_gross_income_person: 40_000 tax_units: tax_unit: members: [person1, person2] - mt_federal_income_tax_deduction_for_federal_itemization: 10_000 state_filing_status_if_married_filing_separately_on_same_return: SEPARATE households: household: members: [person1, person2] state_code: MT output: - mt_federal_income_tax_deduction_for_federal_itemization_indiv: [1_000, 5_000] + mt_federal_income_tax_deduction_for_federal_itemization_indiv: [5_000, 1_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml index 85519179428..5c2836f99a1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml @@ -7,7 +7,7 @@ mt_salt_deduction: 5_000 mt_medical_expense_deduction_joint: 1_000 casualty_loss_deduction: 400 - mt_child_dependent_care_expense_deduction: 123 + mt_child_dependent_care_expense_deduction_joint: 123 mt_federal_income_tax_deduction_for_federal_itemization: 2_000 state_code: MT output: @@ -22,7 +22,7 @@ mt_salt_deduction: 10_000 mt_medical_expense_deduction_joint: 3_000 casualty_loss_deduction: 0 - mt_child_dependent_care_expense_deduction: 0 + mt_child_dependent_care_expense_deduction_joint: 0 mt_federal_income_tax_deduction_for_federal_itemization: 2_000 state_code: MT output: @@ -37,7 +37,7 @@ mt_salt_deduction: 1_035 mt_medical_expense_deduction_joint: 3_000 casualty_loss_deduction: 745 - mt_child_dependent_care_expense_deduction: 1_004 + mt_child_dependent_care_expense_deduction_joint: 1_004 mt_federal_income_tax_deduction_for_federal_itemization: 2_000 state_code: MT output: diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_federal_income_tax_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_federal_income_tax_deduction_indiv.yaml index 4b0d588ce5e..a6633222517 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_federal_income_tax_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_federal_income_tax_deduction_indiv.yaml @@ -1,39 +1,150 @@ -- name: Deductions split evenly +# 2022 Form 2 instructions, Itemized Deductions Schedule, line 4 (PDF p. 33): +# "If you are married filing separately with your spouse, the federal income +# tax withheld should be reported by the spouse who earned the income." and +# "If your filing status is Married Filing Separately, you are each limited to +# the $5,000 federal tax deduction and you cannot arbitrarily allocate this +# deduction between spouses." + +- name: Federal income tax is attributed by each spouse's share of federal AGI period: 2021 + absolute_error_margin: 0.01 input: people: person1: - mt_agi_indiv: 500 + adjusted_gross_income_person: 60_000 person2: - mt_agi_indiv: 500 + adjusted_gross_income_person: 40_000 tax_units: tax_unit: members: [person1, person2] - mt_federal_income_tax_deduction_unit: 3_000 + income_tax_before_refundable_credits: 3_000 state_filing_status_if_married_filing_separately_on_same_return: SEPARATE households: household: members: [person1, person2] state_code: MT output: - mt_federal_income_tax_deduction_indiv: [1_500, 1_500] + mt_federal_income_tax_deduction_share: [0.6, 0.4] + mt_federal_income_tax_deduction_indiv: [1_800, 1_200] -- name: Deductions capped at max amount individually - period: 2021 +- name: Each spouse's own federal tax is capped at 5,000, not a share of the joint cap + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + adjusted_gross_income_person: 80_000 + person2: + adjusted_gross_income_person: 20_000 + tax_units: + tax_unit: + members: [person1, person2] + income_tax_before_refundable_credits: 30_000 + state_filing_status_if_married_filing_separately_on_same_return: SEPARATE + households: + household: + members: [person1, person2] + state_code: MT + output: + # Federal tax attributed 24_000 / 6_000; each spouse is limited to 5_000. + # Capping the couple at 10_000 first and then splitting 80% / 20% would + # give 5_000 / 2_000. + mt_federal_income_tax_deduction_indiv: [5_000, 5_000] + +- name: Montana-only subtractions do not shift federal tax between spouses + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + adjusted_gross_income_person: 50_000 + person2: + adjusted_gross_income_person: 50_000 + mt_subtractions: 20_000 + tax_units: + tax_unit: + members: [person1, person2] + income_tax_before_refundable_credits: 6_000 + state_filing_status_if_married_filing_separately_on_same_return: SEPARATE + households: + household: + members: [person1, person2] + state_code: MT + output: + # The federal tax was paid on 50_000 of federal income each. Prorating by + # Montana AGI (50_000 / 30_000) would give 3_750 / 2_250. + mt_federal_income_tax_deduction_indiv: [3_000, 3_000] + +- name: A dependent's income does not draw a share of the parents' federal tax + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + adjusted_gross_income_person: 40_000 + person2: + adjusted_gross_income_person: 0 + person3: + is_tax_unit_dependent: true + adjusted_gross_income_person: 10_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + income_tax_before_refundable_credits: 2_000 + state_filing_status_if_married_filing_separately_on_same_return: SEPARATE + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Only the parents' 40_000 + 0 AGI allocates their federal tax: + # 2_000 * 40_000 / 40_000 = 2_000 for the head. Including the + # dependent's 10_000 would incorrectly give 1_600 / 0 / 400. + mt_federal_income_tax_deduction_share: [1, 0, 0] + mt_federal_income_tax_deduction_indiv: [2_000, 0, 0] + +- name: No positive filer income retains main's zero federal tax allocation + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + adjusted_gross_income_person: 0 + person2: + adjusted_gross_income_person: 0 + tax_units: + tax_unit: + members: [person1, person2] + income_tax_before_refundable_credits: 6_000 + state_filing_status_if_married_filing_separately_on_same_return: SEPARATE + households: + household: + members: [person1, person2] + state_code: MT + output: + # Form 2 line 4a (source cited above) does not specify how to allocate + # federal tax when neither spouse has income. Preserve main's zero + # allocation: 6_000 * 0 = 0 for each spouse. Assigning all tax to the head + # would invent a new fallback and produce min(6_000, 5_000) = 5_000. + mt_federal_income_tax_deduction_share: [0, 0] + mt_federal_income_tax_deduction_indiv: [0, 0] + +- name: The deduction is repealed from 2024 + period: 2024 input: people: person1: - mt_agi_indiv: 100 + adjusted_gross_income_person: 60_000 person2: - mt_agi_indiv: 900 + adjusted_gross_income_person: 40_000 tax_units: tax_unit: members: [person1, person2] - mt_federal_income_tax_deduction_unit: 10_000 + income_tax_before_refundable_credits: 3_000 state_filing_status_if_married_filing_separately_on_same_return: SEPARATE households: household: members: [person1, person2] state_code: MT output: - mt_federal_income_tax_deduction_indiv: [1_000, 5_000] + mt_federal_income_tax_deduction_indiv: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml index 8289ef0c24c..2e4c3acee9a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml @@ -7,7 +7,7 @@ mt_salt_deduction: 5_000 mt_medical_expense_deduction_joint: 1_000 casualty_loss_deduction: 400 - mt_child_dependent_care_expense_deduction: 123 + mt_child_dependent_care_expense_deduction_joint: 123 mt_federal_income_tax_deduction_unit: 2_000 state_code: MT output: @@ -22,7 +22,7 @@ mt_salt_deduction: 10_000 mt_medical_expense_deduction_joint: 3_000 casualty_loss_deduction: 0 - mt_child_dependent_care_expense_deduction: 0 + mt_child_dependent_care_expense_deduction_joint: 0 mt_federal_income_tax_deduction_unit: 2_000 state_code: MT output: @@ -37,7 +37,7 @@ mt_salt_deduction: 1_035 mt_medical_expense_deduction_joint: 3_000 casualty_loss_deduction: 745 - mt_child_dependent_care_expense_deduction: 1_004 + mt_child_dependent_care_expense_deduction_joint: 1_004 mt_federal_income_tax_deduction_unit: 2_000 state_code: MT output: diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index df9e02675bc..f683598aa05 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -1,4 +1,4 @@ -- name: Amount only distributed to the head +- name: Amount only distributed to the head period: 2021 absolute_error_margin: 1 input: @@ -6,16 +6,19 @@ person1: is_tax_unit_head: true other_medical_expenses: 1_000 - mt_agi_indiv: 400 person2: is_tax_unit_head: false other_medical_expenses: 1_000 - mt_agi_indiv: 600 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 1_000 households: household: members: [person1, person2] state_code: MT output: + # 2,000 - 7.5% x 1,000 = 1,925 mt_medical_expense_deduction_joint: [1_925, 0] - name: No income @@ -26,11 +29,13 @@ person1: is_tax_unit_head: true other_medical_expenses: 1_000 - mt_agi_indiv: 0 person2: is_tax_unit_head: false other_medical_expenses: 1_000 - mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 0 households: household: members: [person1, person2] @@ -46,14 +51,110 @@ person1: is_tax_unit_head: true other_medical_expenses: 0 - mt_agi_indiv: 1_000 person2: is_tax_unit_head: false other_medical_expenses: 0 - mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 1_000 households: household: members: [person1, person2] state_code: MT output: mt_medical_expense_deduction_joint: [0, 0] + +- name: Spouse's loss reduces the joint return's Montana AGI before the floor + period: 2022 + absolute_error_margin: 1 + input: + people: + head: + age: 40 + employment_income: 100_000 + other_medical_expenses: 10_000 + spouse: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Form 2 page 1, line 14: 100,000 - 20,000 = 80,000, reported in one column. + mt_agi_joint: 80_000 + # Itemized Deductions Schedule line 1: 10,000 - 7.5% x 80,000 = 4,000. + # Flooring each spouse's Montana AGI at zero first would give + # 10,000 - 7.5% x 90,000 = 3,250. + mt_medical_expense_deduction_joint: [4_000, 0] + +- name: Last year of the Montana schedule floors on Montana AGI, not the federal deduction + period: 2023 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 40_000 + medical_expense_deduction: 9_999 + households: + household: + members: [person1] + state_code: MT + output: + # 10,000 - 7.5% x 40,000 = 7,000 + mt_medical_expense_deduction_joint: 7_000 + +- name: From 2024 the federal medical expense deduction carries through + period: 2024 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 50_000 + medical_expense_deduction: 2_500 + households: + household: + members: [person1] + state_code: MT + output: + mt_medical_expense_deduction_joint: 2_500 + +- name: From 2024 the floor uses federal AGI, not Montana AGI + period: 2024 + absolute_error_margin: 1 + input: + people: + person1: + age: 70 + medicare_enrolled: false + employment_income: 100_000 + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + # The $5,500 subtraction for taxpayers 65 and older reduces Montana AGI + # but not federal AGI. + adjusted_gross_income: 100_000 + mt_agi_joint: 94_500 + # Form 2 line 2, Worksheet A, takes federal Schedule A: + # 10,000 - 7.5% x 100,000 = 2,500. + mt_medical_expense_deduction_joint: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml index fc733856faf..68ba1b89870 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml @@ -2,7 +2,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 12_950 + mt_agi_joint: 12_950 filing_status: SINGLE output: mt_standard_deduction_joint: 2_590 @@ -11,7 +11,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 8_000 + mt_agi_joint: 8_000 filing_status: SURVIVING_SPOUSE output: mt_standard_deduction_joint: 2_140 @@ -20,7 +20,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 30_000 + mt_agi_joint: 30_000 filing_status: SEPARATE output: mt_standard_deduction_joint: 4_830 @@ -29,7 +29,7 @@ period: 2022 input: state_code: MT - mt_agi_indiv: 25_180 + mt_agi_joint: 25_180 filing_status: HEAD_OF_HOUSEHOLD output: mt_standard_deduction_joint: 5_036 @@ -38,7 +38,7 @@ period: 2022 input: state_code: MT - mt_agi_indiv: 15_000 + mt_agi_joint: 15_000 filing_status: JOINT output: mt_standard_deduction_joint: 4_520 @@ -50,17 +50,15 @@ people: person1: is_tax_unit_head: true - mt_agi_indiv: 5_000 person2: is_tax_unit_head: false - mt_agi_indiv: 100_000 person3: is_tax_unit_head: false - mt_agi_indiv: 0 tax_units: tax_unit: members: [person1, person2, person3] filing_status: JOINT + mt_agi_joint: 105_000 households: household: members: [person1, person2, person3] @@ -75,17 +73,15 @@ people: person1: is_tax_unit_head: true - mt_agi_indiv: 20_000 person2: is_tax_unit_head: false - mt_agi_indiv: 10_000 person3: is_tax_unit_head: false - mt_agi_indiv: 0 tax_units: tax_unit: members: [person1, person2, person3] filing_status: JOINT + mt_agi_joint: 30_000 households: household: members: [person1, person2, person3] @@ -97,7 +93,32 @@ period: 2024 input: state_code: MT - mt_agi_indiv: 12_950 + mt_agi_joint: 12_950 filing_status: JOINT output: mt_standard_deduction_joint: 29_200 + +- name: Spouse's loss reduces the joint return's Montana AGI + period: 2022 + absolute_error_margin: 1 + input: + people: + head: + age: 40 + employment_income: 50_000 + spouse: + age: 40 + self_employment_income: -10_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Form 2 page 1, line 14: 50,000 - 10,000 = 40,000, reported in one column. + mt_agi_joint: 40_000 + # Standard Deduction Worksheet: 20% x 40,000 = 8,000, between the $4,520 + # minimum and the $10,180 maximum for a joint return. + mt_standard_deduction_joint: [8_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/integration.yaml index 1427235ceaa..687a3ecabbf 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/integration.yaml @@ -564,9 +564,12 @@ output: # $2,500 per-return cap split $1,250 per spouse (was double-counted to $5,000) mt_income_tax_rebate: [1_250, 1_250] - # Files separately: 5,099.51 - 1,250 + 3,719.51 - 1,250 = 6,319.02 + # Each spouse deducts their own federal income tax up to $5,000 (2021 + # federal tax 21,175 attributed 90/160 and 70/160), so both itemize 5,000 + # over the 4,830 standard deduction. Taxable income 82,420 and 62,420. + # Files separately: 5,087.78 - 1,250 + 3,707.78 - 1,250 = 6,295.56 # (old double-count subtracted $2,500 per column, understating tax by $2,500) - mt_income_tax: 6_319.02 + mt_income_tax: 6_295.56 - name: MT property tax rebate is not a Form 2 credit and is not tripled (issue 9347) absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/joint_agi_deductions_integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/joint_agi_deductions_integration.yaml new file mode 100644 index 00000000000..8181f5f68f9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/joint_agi_deductions_integration.yaml @@ -0,0 +1,406 @@ +# Return-level deduction propagation, with nonmedical deductions and credits zero. +# Sources: 2022 Form 2 p. 1 (exemptions), p. 7 (deductions), p. 8 (tax table): +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=1 +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=8 +# The marginal tax schedule retains main's cent precision (offset 602.50); +# the printed 2022 tax table rounds its offset to 603. This PR preserves it. +# 2024 instructions p. 15, Worksheet A (adjusted federal deductions): +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15 +# 2024 ordinary tax rates, instructions p. 22 (printed p. 14): +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=22 +# 2024 joint federal standard deduction, IRS instructions p. 6: +# https://www.irs.gov/pub/irs-prior/i1040gi--2024.pdf#page=6 +# 2024 federal Schedule A p. 1, lines 1-4 (7.5% medical floor): +# https://www.irs.gov/pub/irs-prior/f1040sa--2024.pdf#page=1 +# 2024 Form 2 p. 2 (ordinary income and capital-gain threshold/rates): +# https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=2 + +- name: 2022 pooled medical deduction at separate tax selection boundary 2812 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + adjusted_gross_income_person: 90_000 + other_medical_expenses: 29_980 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + # Controlled alternative tax isolates the filing selection boundary. + # This is not a derived separate-return household tax expectation. + mt_income_tax_indiv: 2812 + spouse: + age: 40 + adjusted_gross_income_person: -10_000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + mt_income_tax_indiv: 0 + tax_units: + tax_unit: + members: [head, spouse] + adjusted_gross_income: 80_000 + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_agi_joint: 80_000 + # 29,980 - 7.5% * (90,000 - 10,000) = 23,980. + # The old unpooled floor gave 23,230 and joint tax 2,863.625. + mt_medical_expense_deduction_joint: [23_980, 0] + mt_itemized_deductions_joint: [23_980, 0] + mt_itemized_deductions_for_federal_itemization_joint: [23_980, 0] + # max(joint 23,980, separate 29,980 - 7.5% * 90,000) = 23,980. + state_itemized_deductions: 23_980 + # 20% * 80,000 = 16,000, capped at the joint maximum 10,180. + state_standard_deduction: 10_180 + # 80,000 - 23,980 - 2 * 2,710 exemptions = 50,600. + mt_taxable_income_joint: [50_600, 0] + # 734 + 6.75% * (50,600 - 19,800) = 2,813. + mt_regular_income_tax_joint: 2_813 + mt_income_tax_joint: 2_813 + # Separate filing requires strictly lower tax; ties retain joint filing. + mt_files_separately: true + mt_income_tax: 2812 + +- name: 2022 pooled medical deduction at separate tax selection boundary 2813 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + adjusted_gross_income_person: 90_000 + other_medical_expenses: 29_980 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + # Controlled alternative tax isolates the filing selection boundary. + # This is not a derived separate-return household tax expectation. + mt_income_tax_indiv: 2813 + spouse: + age: 40 + adjusted_gross_income_person: -10_000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + mt_income_tax_indiv: 0 + tax_units: + tax_unit: + members: [head, spouse] + adjusted_gross_income: 80_000 + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_agi_joint: 80_000 + # 29,980 - 7.5% * (90,000 - 10,000) = 23,980. + # The old unpooled floor gave 23,230 and joint tax 2,863.625. + mt_medical_expense_deduction_joint: [23_980, 0] + mt_itemized_deductions_joint: [23_980, 0] + mt_itemized_deductions_for_federal_itemization_joint: [23_980, 0] + # max(joint 23,980, separate 29,980 - 7.5% * 90,000) = 23,980. + state_itemized_deductions: 23_980 + # 20% * 80,000 = 16,000, capped at the joint maximum 10,180. + state_standard_deduction: 10_180 + # 80,000 - 23,980 - 2 * 2,710 exemptions = 50,600. + mt_taxable_income_joint: [50_600, 0] + # 734 + 6.75% * (50,600 - 19,800) = 2,813. + mt_regular_income_tax_joint: 2_813 + mt_income_tax_joint: 2_813 + # Separate filing requires strictly lower tax; ties retain joint filing. + mt_files_separately: false + mt_income_tax: 2813 + +- name: 2022 pooled medical deduction at separate tax selection boundary 2814 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + adjusted_gross_income_person: 90_000 + other_medical_expenses: 29_980 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + # Controlled alternative tax isolates the filing selection boundary. + # This is not a derived separate-return household tax expectation. + mt_income_tax_indiv: 2814 + spouse: + age: 40 + adjusted_gross_income_person: -10_000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + mt_income_tax_indiv: 0 + tax_units: + tax_unit: + members: [head, spouse] + adjusted_gross_income: 80_000 + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_agi_joint: 80_000 + # 29,980 - 7.5% * (90,000 - 10,000) = 23,980. + # The old unpooled floor gave 23,230 and joint tax 2,863.625. + mt_medical_expense_deduction_joint: [23_980, 0] + mt_itemized_deductions_joint: [23_980, 0] + mt_itemized_deductions_for_federal_itemization_joint: [23_980, 0] + # max(joint 23,980, separate 29,980 - 7.5% * 90,000) = 23,980. + state_itemized_deductions: 23_980 + # 20% * 80,000 = 16,000, capped at the joint maximum 10,180. + state_standard_deduction: 10_180 + # 80,000 - 23,980 - 2 * 2,710 exemptions = 50,600. + mt_taxable_income_joint: [50_600, 0] + # 734 + 6.75% * (50,600 - 19,800) = 2,813. + mt_regular_income_tax_joint: 2_813 + mt_income_tax_joint: 2_813 + # Separate filing requires strictly lower tax; ties retain joint filing. + mt_files_separately: false + mt_income_tax: 2813 + +- name: 2022 pooled standard deduction propagates to state deductions and final tax + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + spouse: + age: 40 + self_employment_income: -10_000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + tax_units: + tax_unit: + members: [head, spouse] + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + adjusted_gross_income: 40_000 + mt_agi_joint: 40_000 + # 20% * (50,000 - 10,000) = 8,000, within [4,520, 10,180]. + mt_standard_deduction_joint: [8_000, 0] + # max(joint 8,000, separate 5,090 + 2,260) = 8,000. + state_standard_deduction: 8_000 + state_itemized_deductions: 0 + # 40,000 - 8,000 - 2 * 2,710 = 26,580. + mt_taxable_income_joint: [26_580, 0] + # 734 + 6.75% * (26,580 - 19,800) = 1,191.65. + mt_income_tax_joint: 1_191.65 + mt_files_separately: false + mt_income_tax: 1_191.65 + +- name: 2024 medical floor propagates through capital gains with wages 70100 and expenses 35000 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 70100 + long_term_capital_gains: 20_000 + other_medical_expenses: 35000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + spouse: + age: 40 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + tax_units: + tax_unit: + members: [head, spouse] + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + adjusted_gross_income: 90100 + # 35000 - 7.5% * 90100 = 28242.5 (federal AGI). + medical_expense_deduction: 28242.5 + mt_medical_expense_deduction_joint: [28242.5, 0] + mt_itemized_deductions_joint: [28242.5, 0] + mt_itemized_deductions_for_federal_itemization_joint: [28242.5, 0] + state_itemized_deductions: 28242.5 + state_standard_deduction: 29_200 + # 90100 - max(28242.5, 29,200) = 60900; no exemptions from 2024. + mt_taxable_income_joint: [60900, 0] + # Ordinary income = 60900 - 20,000 = 40900; + # remaining lower-rate room = max(41,000 - 40900, 0) = 100. + mt_capital_gains_tax_applicable_threshold_joint: 100 + # 3% * min(100, 20,000) + 4.1% * (20,000 - min(100, 20,000)). + mt_capital_gains_tax_joint: 818.9 + # 4.7% * min(40900, 41,000) + 5.9% * max(40900 - 41,000, 0). + mt_regular_income_tax_joint: 1922.3 + # 1922.3 + 818.9 = 2741.2. + mt_income_tax_joint: 2741.2 + mt_files_separately: false + mt_income_tax: 2741.2 + +- name: 2024 medical floor propagates through capital gains with wages 70200 and expenses 35000 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 70200 + long_term_capital_gains: 20_000 + other_medical_expenses: 35000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + spouse: + age: 40 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + tax_units: + tax_unit: + members: [head, spouse] + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + adjusted_gross_income: 90200 + # 35000 - 7.5% * 90200 = 28235 (federal AGI). + medical_expense_deduction: 28235 + mt_medical_expense_deduction_joint: [28235, 0] + mt_itemized_deductions_joint: [28235, 0] + mt_itemized_deductions_for_federal_itemization_joint: [28235, 0] + state_itemized_deductions: 28235 + state_standard_deduction: 29_200 + # 90200 - max(28235, 29,200) = 61000; no exemptions from 2024. + mt_taxable_income_joint: [61000, 0] + # Ordinary income = 61000 - 20,000 = 41000; + # remaining lower-rate room = max(41,000 - 41000, 0) = 0. + mt_capital_gains_tax_applicable_threshold_joint: 0 + # 3% * min(0, 20,000) + 4.1% * (20,000 - min(0, 20,000)). + mt_capital_gains_tax_joint: 820 + # 4.7% * min(41000, 41,000) + 5.9% * max(41000 - 41,000, 0). + mt_regular_income_tax_joint: 1927 + # 1927 + 820 = 2747. + mt_income_tax_joint: 2747 + mt_files_separately: false + mt_income_tax: 2747 + +- name: 2024 medical floor propagates through capital gains with wages 70300 and expenses 35000 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 70300 + long_term_capital_gains: 20_000 + other_medical_expenses: 35000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + spouse: + age: 40 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + tax_units: + tax_unit: + members: [head, spouse] + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + adjusted_gross_income: 90300 + # 35000 - 7.5% * 90300 = 28227.5 (federal AGI). + medical_expense_deduction: 28227.5 + mt_medical_expense_deduction_joint: [28227.5, 0] + mt_itemized_deductions_joint: [28227.5, 0] + mt_itemized_deductions_for_federal_itemization_joint: [28227.5, 0] + state_itemized_deductions: 28227.5 + state_standard_deduction: 29_200 + # 90300 - max(28227.5, 29,200) = 61100; no exemptions from 2024. + mt_taxable_income_joint: [61100, 0] + # Ordinary income = 61100 - 20,000 = 41100; + # remaining lower-rate room = max(41,000 - 41100, 0) = 0. + mt_capital_gains_tax_applicable_threshold_joint: 0 + # 3% * min(0, 20,000) + 4.1% * (20,000 - min(0, 20,000)). + mt_capital_gains_tax_joint: 820 + # 4.7% * min(41100, 41,000) + 5.9% * max(41100 - 41,000, 0). + mt_regular_income_tax_joint: 1932.9 + # 1932.9 + 820 = 2752.9. + mt_income_tax_joint: 2752.9 + mt_files_separately: false + mt_income_tax: 2752.9 + +- name: 2024 medical floor propagates through capital gains with wages 60000 and expenses 40000 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 60000 + long_term_capital_gains: 20_000 + other_medical_expenses: 40000 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + spouse: + age: 40 + mt_withheld_income_tax: 0 + mt_non_refundable_credits: 0 + mt_refundable_credits: 0 + tax_units: + tax_unit: + members: [head, spouse] + income_tax_before_refundable_credits: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + adjusted_gross_income: 80000 + # 40000 - 7.5% * 80000 = 34000 (federal AGI). + medical_expense_deduction: 34000 + mt_medical_expense_deduction_joint: [34000, 0] + mt_itemized_deductions_joint: [34000, 0] + mt_itemized_deductions_for_federal_itemization_joint: [34000, 0] + state_itemized_deductions: 34000 + state_standard_deduction: 29_200 + # 80000 - max(34000, 29,200) = 46000; no exemptions from 2024. + mt_taxable_income_joint: [46000, 0] + # Ordinary income = 46000 - 20,000 = 26000; + # remaining lower-rate room = max(41,000 - 26000, 0) = 15000. + mt_capital_gains_tax_applicable_threshold_joint: 15000 + # 3% * min(15000, 20,000) + 4.1% * (20,000 - min(15000, 20,000)). + mt_capital_gains_tax_joint: 655 + # 4.7% * min(26000, 41,000) + 5.9% * max(26000 - 41,000, 0). + mt_regular_income_tax_joint: 1222 + # 1222 + 655 = 1877. + mt_income_tax_joint: 1877 + mt_files_separately: false + mt_income_tax: 1877 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/gross_income/nj_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/gross_income/nj_gross_income.yaml index 982a865f105..abb9e4da450 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/gross_income/nj_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/gross_income/nj_gross_income.yaml @@ -198,3 +198,25 @@ # 50_000 wages + 10_000 Roth conversion = 60_000 nj_gross_income: 60_000 nj_income_tax: 1_767.25 + +- name: NJ gross income includes income derived through estates or trusts + period: 2025 + input: + employment_income: 50_000 + estate_income: 8_000 + state_code: NJ + output: + # N.J.S. 54A:5-1(h), reported on NJ-1040 line 26: 50,000 + 8,000. + nj_gross_income: 58_000 + +- name: NJ same category rule - an estate or trust loss does not offset partnership income + period: 2025 + input: + employment_income: 50_000 + partnership_s_corp_income: 3_000 + estate_income: -8_000 + state_code: NJ + output: + # N.J.S. 54A:5-2: the category (h) net loss is disregarded, so + # 50,000 + 3,000 + max(-8,000, 0) = 53,000. + nj_gross_income: 53_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.yaml index bdf9ebe89c6..490a7797821 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.yaml @@ -1,5 +1,6 @@ -- name: New Jersey 2024 worker contributions are FLI only, at 0.09%. +- name: New Jersey 2024 worker contributions include FLI, UI, and workforce development. period: 2024 + absolute_error_margin: 0.01 input: employment_income: 60_000 state_code: NJ @@ -9,11 +10,13 @@ nj_employee_temporary_disability_insurance_contribution: 0 # 0.09% * 60,000 = 54.00. nj_employee_family_leave_insurance_contribution: 54.00 - nj_employee_state_payroll_tax: 54.00 - employee_state_payroll_tax: 54.00 + # FLI plus 0.425% of the 42,300 UI/workforce wage base. + nj_employee_state_payroll_tax: 233.775 + employee_state_payroll_tax: 233.775 - name: New Jersey 2024 FLI contribution stops at the 161,400 taxable wage base. period: 2024 + absolute_error_margin: 0.01 input: employment_income: 200_000 state_code: NJ @@ -22,8 +25,9 @@ # 0.09% * 161,400 = 145.26. nj_employee_family_leave_insurance_contribution: 145.26 -- name: New Jersey 2025 worker contributions are 0.23% TDI and 0.33% FLI. +- name: New Jersey 2025 worker contributions include TDI, FLI, UI, and workforce development. period: 2025 + absolute_error_margin: 0.01 input: employment_income: 60_000 state_code: NJ @@ -32,12 +36,13 @@ nj_employee_temporary_disability_insurance_contribution: 138.00 # 0.33% * 60,000 = 198.00. nj_employee_family_leave_insurance_contribution: 198.00 - # 138.00 + 198.00 = 336.00. - nj_employee_state_payroll_tax: 336.00 - employee_state_payroll_tax: 336.00 + # TDI and FLI plus 0.425% of the 43,300 UI/workforce wage base. + nj_employee_state_payroll_tax: 520.025 + employee_state_payroll_tax: 520.025 - name: New Jersey 2025 TDI and FLI contributions stop at the 165,400 taxable wage base. period: 2025 + absolute_error_margin: 0.01 input: employment_income: 200_000 state_code: NJ @@ -48,5 +53,216 @@ nj_employee_temporary_disability_insurance_contribution: 380.42 # 0.33% * 165,400 = 545.82. nj_employee_family_leave_insurance_contribution: 545.82 - # 380.42 + 545.82 = 926.24. - nj_employee_state_payroll_tax: 926.24 + # TDI and FLI plus 0.425% of the 43,300 UI/workforce wage base. + nj_employee_state_payroll_tax: 1_110.265 + +- name: NJ worker contributions below the UI wage base + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NJ + employment_income: 10000 + output: + nj_employee_state_payroll_tax: 84.5 + employee_state_payroll_tax: 84.5 + employee_payroll_tax: 849.5 +- name: NJ worker UI and workforce contributions stop at their own wage base + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NJ + employment_income: 100000 + output: + nj_employee_state_payroll_tax: 610.4 + employee_state_payroll_tax: 610.4 + employee_payroll_tax: 8260.4 +- name: NJ worker UI and workforce include federal pre-tax payroll deductions + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NJ + employment_income: 10000 + traditional_401k_contributions_desired: 2000 + pre_tax_health_insurance_premiums: 1000 + health_savings_account_payroll_contributions: 500 + output: + nj_employee_state_payroll_tax: 84.5 + employee_state_payroll_tax: 84.5 +- name: NJ worker UI and workforce use the 2015 wage base + period: 2015 + absolute_error_margin: 0.001 + input: + state_code: NJ + employment_income: 100000 + nj_employee_temporary_disability_insurance_contribution: 0 + nj_employee_family_leave_insurance_contribution: 0 + output: + nj_employee_state_payroll_tax: 136.0 + employee_state_payroll_tax: 136.0 +- name: NJ worker UI and workforce use the 2022 wage base + period: 2022 + absolute_error_margin: 0.001 + input: + state_code: NJ + employment_income: 100000 + nj_employee_temporary_disability_insurance_contribution: 0 + nj_employee_family_leave_insurance_contribution: 0 + output: + nj_employee_state_payroll_tax: 169.15 + employee_state_payroll_tax: 169.15 +- name: NJ worker UI and workforce use the 2023 wage base + period: 2023 + absolute_error_margin: 0.001 + input: + state_code: NJ + employment_income: 100000 + nj_employee_temporary_disability_insurance_contribution: 0 + nj_employee_family_leave_insurance_contribution: 0 + output: + nj_employee_state_payroll_tax: 174.675 + employee_state_payroll_tax: 174.675 +- name: NJ worker UI and workforce use the 2024 wage base + period: 2024 + absolute_error_margin: 0.001 + input: + state_code: NJ + employment_income: 100000 + nj_employee_temporary_disability_insurance_contribution: 0 + nj_employee_family_leave_insurance_contribution: 0 + output: + nj_employee_state_payroll_tax: 179.775 + employee_state_payroll_tax: 179.775 +- name: NJ worker UI and workforce use the 2025 wage base + period: 2025 + absolute_error_margin: 0.001 + input: + state_code: NJ + employment_income: 100000 + nj_employee_temporary_disability_insurance_contribution: 0 + nj_employee_family_leave_insurance_contribution: 0 + output: + nj_employee_state_payroll_tax: 184.025 + employee_state_payroll_tax: 184.025 + +- name: PolicyBench scenario 008 - New Jersey worker UI and workforce deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 42 + employment_income: 0.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + over_the_counter_health_expenses: 200.0 + self_employment_income: 4000.0 + taxable_interest_income: 15.0 + spouse: + age: 41 + employment_income: 26800.0 + attends_eligible_educational_institution_for_american_opportunity_credit: true + bank_account_assets: 69500.0 + has_american_opportunity_credit_1098_t_or_exception: true + has_american_opportunity_credit_institution_ein: true + is_blind: true + is_disabled: true + is_enrolled_at_least_half_time_for_american_opportunity_credit: true + is_pursuing_credential_for_american_opportunity_credit: true + is_tax_unit_head: false + is_tax_unit_spouse: true + over_the_counter_health_expenses: 300.0 + pre_subsidy_rent: 6949.41162109375 + taxable_interest_income: 100.0 + weeks_unemployed: 5.0 + child1: + age: 17 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + over_the_counter_health_expenses: 50.0 + child2: + age: 14 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + over_the_counter_health_expenses: 50.0 + child3: + age: 12 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + over_the_counter_health_expenses: 50.0 + child4: + age: 10 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + over_the_counter_health_expenses: 50.0 + child5: + age: 5 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + over_the_counter_health_expenses: 50.0 + child6: + age: 1 + employment_income: 0.0 + is_tax_unit_head: false + is_tax_unit_spouse: false + over_the_counter_health_expenses: 50.0 + marital_units: + couple: + members: + - head + - spouse + child1: + members: + - child1 + child2: + members: + - child2 + child3: + members: + - child3 + child4: + members: + - child4 + child5: + members: + - child5 + child6: + members: + - child6 + tax_units: + tax_unit: + members: &id001 + - head + - spouse + - child1 + - child2 + - child3 + - child4 + - child5 + - child6 + families: + family: + members: *id001 + spm_units: + spm_unit: + members: *id001 + receives_housing_assistance: true + households: + household: + members: *id001 + state_code: NJ + output: + spm_unit_payroll_tax: 2276.66 + nj_employee_state_payroll_tax: + - 0 + - 226.46 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/nm_modified_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/nm_modified_gross_income.yaml index 9e37896d514..06c92d3744d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/nm_modified_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/nm_modified_gross_income.yaml @@ -90,3 +90,23 @@ state_code: NM output: nm_modified_gross_income: 0 + +- name: NM modified gross income includes income from an estate or trust + period: 2025 + input: + state_code: NM + irs_employment_income: 20_000 + estate_income: 5_000 + output: + # NMSA 1978 § 7-2-2(L)(15): 20,000 + 5,000. + nm_modified_gross_income: 25_000 + +- name: NM modified gross income is undiminished by an estate or trust loss + period: 2025 + input: + state_code: NM + irs_employment_income: 20_000 + estate_income: -5_000 + output: + # § 7-2-2(L): "undiminished by losses". max(-5,000, 0) = 0, so 20,000. + nm_modified_gross_income: 20_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml index 828be8b2af2..1c8eb981454 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml @@ -127,3 +127,75 @@ ny_drive_clean_vehicle_electric_range: 200 output: ny_drive_clean_rebate: 0 + + +# NYSERDA Drive Clean Implementation Manual, section 4, page 8: +# https://www.nyserda.ny.gov/-/media/Project/Nyserda/Files/Programs/Drive-Clean-NY/implementation-manual.pdf#page=8 +# First-of-month rolling years exercise the annual formula on either side +# of the July 1 transition. Core 3.32.15 rejects caching mid-month annual periods; +# exact June 30 parameter lookups are covered in test_ny_drive_clean_rebate_schedule.py. +- name: June 1 rolling year retains the 20-mile rebate + period: year:2021-06-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 20 + output: + # $30,000 < $60,000 cap; table gives $1,100 (July reduction: 1,100 - 500 = 600). + ny_drive_clean_rebate: 1_100 + +- name: July 1 rolling year applies the reduced 20-mile rebate + period: year:2021-07-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 20 + output: + # $30,000 < $42,000 cap; table gives $500 (1,100 - 600 = 500). + ny_drive_clean_rebate: 500 + +- name: June 1 rolling year retains the 40-mile rebate + period: year:2021-06-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 40 + output: + # $30,000 < $60,000 cap; table gives $1,700. + ny_drive_clean_rebate: 1_700 + +- name: July 1 rolling year applies the reduced 40-mile rebate + period: year:2021-07-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 40 + output: + # $30,000 < $42,000 cap; table gives $1,000 (1,700 - 700 = 1,000). + ny_drive_clean_rebate: 1_000 + +- name: June 1 rolling year retains the higher MSRP cap + period: year:2021-06-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 50_000 + ny_drive_clean_vehicle_electric_range: 200 + output: + # $50,000 < $60,000 cap, so $2,000 by range (July reduction: 2,000 - 500 = 1,500). + ny_drive_clean_rebate: 2_000 + +- name: July 1 rolling year applies the lower MSRP cap + period: year:2021-07-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 50_000 + ny_drive_clean_vehicle_electric_range: 200 + output: + # $50,000 > $42,000 cap, so flat $500 (2,000 - 1,500 = 500). + ny_drive_clean_rebate: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml index 07de472c2ad..25f730d5927 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml @@ -10,7 +10,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 340_000 @@ -26,7 +26,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 350_000 @@ -45,7 +45,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 408_000 @@ -61,7 +61,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 500_000 @@ -80,7 +80,7 @@ input: ny_itemized_deductions_max: 20_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 5_000_000 @@ -102,7 +102,7 @@ # remaining 60,000 is subject to the limitation. ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 25_000 + ny_medical_expense_deduction: 25_000 investment_interest_expense: 10_000 ny_casualty_loss_deduction: 5_000 adjusted_gross_income: 5_000_000 @@ -123,7 +123,7 @@ # the federal Line 40 worksheet; the Pease base should ignore it. ny_itemized_deductions_max: 130_000 ny_college_tuition_deduction: 30_000 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 500_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml new file mode 100644 index 00000000000..1d13a25a085 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml @@ -0,0 +1,158 @@ +# NY Form IT-196 lines 1-4: medical and dental expenses (line 1) less 10% of +# federal AGI (line 2, Form IT-201 or IT-203 line 19, or line 19a, recomputed +# federal AGI, in 2020-2022; line 3 multiplies by 10% on every IT-196 from +# 2018 through 2025). NY Tax Law § 615(a) computes NY itemized deductions as +# federal deductions existed immediately before Public Law 115-97, so NY +# keeps pre-TCJA 26 U.S.C. 213(a)'s 10% floor, not the federal 7.5% floor. +# Through 2017, Form IT-201-D line 1 took the federal Schedule A line 4 +# amount, so NY followed the federal floor. + +- name: Expenses above 10% of AGI are deductible; the federal floor is lower. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 12_000 + output: + # Line 1 = 12,000; line 3 = 100,000 * 0.10 = 10,000; line 4 = 2,000. + ny_medical_expense_deduction: 2_000 + # Federal Schedule A: 12,000 - 100,000 * 0.075 = 4,500. + medical_expense_deduction: 4_500 + +- name: Expenses between 7.5% and 10% of AGI give no NY deduction. + period: 2025 + input: + state_code: NY + employment_income: 80_000 + other_medical_expenses: 7_000 + output: + # Line 3 = 80,000 * 0.10 = 8,000 is more than line 1 (7,000), so line 4 + # is blank. + ny_medical_expense_deduction: 0 + # Federal: 7,000 - 80,000 * 0.075 = 1,000. + medical_expense_deduction: 1_000 + +- name: Expenses exactly at 10% of AGI give no NY deduction. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 10_000 + output: + # Line 4 = 10,000 - 10,000 = 0. + ny_medical_expense_deduction: 0 + +- name: One dollar above the 10% floor is deductible. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 10_001 + output: + ny_medical_expense_deduction: 1 + +- name: Negative AGI gives a zero floor, so all expenses are deductible. + period: 2025 + input: + state_code: NY + adjusted_gross_income: -20_000 + other_medical_expenses: 3_000 + output: + # positive_agi = max(-20,000, 0) = 0, so line 3 = 0 and line 4 = 3,000. + ny_medical_expense_deduction: 3_000 + +- name: First IT-196 year (2018) uses the 10% floor; federal 2018 used 7.5%. + period: 2018 + input: + state_code: NY + adjusted_gross_income: 50_000 + other_medical_expenses: 8_000 + output: + # 2018 Form IT-196 line 3: 50,000 * 0.10 = 5,000; line 4 = 3,000. + ny_medical_expense_deduction: 3_000 + # TCJA section 11027 floor for 2018: 8,000 - 50,000 * 0.075 = 4,250. + medical_expense_deduction: 4_250 + +- name: 2017 follows the federal 7.5% floor (IT-201-D line 1 used federal Schedule A). + period: 2017 + input: + state_code: NY + adjusted_gross_income: 100_000 + other_medical_expenses: 9_000 + output: + # 2017 IT-201-D line 1 = federal Schedule A line 4; 2017 Schedule A + # line 3 multiplies AGI by 7.5%: 9,000 - 7,500 = 1,500. + ny_medical_expense_deduction: 1_500 + medical_expense_deduction: 1_500 + +- name: 2016 follows the federal 10% floor. + period: 2016 + input: + state_code: NY + adjusted_gross_income: 100_000 + other_medical_expenses: 12_000 + output: + # 2016 Schedule A line 3 multiplies AGI by 10% for filers under 65: + # 12,000 - 10,000 = 2,000. + ny_medical_expense_deduction: 2_000 + medical_expense_deduction: 2_000 + +- name: 2026 keeps the 10% floor. + period: 2026 + input: + state_code: NY + adjusted_gross_income: 200_000 + other_medical_expenses: 25_000 + output: + # 25,000 - 200,000 * 0.10 = 5,000 (federal: 25,000 - 15,000 = 10,000). + ny_medical_expense_deduction: 5_000 + medical_expense_deduction: 10_000 + +- name: Filers outside New York get no NY medical expense deduction. + period: 2025 + input: + state_code: CA + employment_income: 100_000 + other_medical_expenses: 12_000 + output: + ny_medical_expense_deduction: 0 + +- name: NY itemized deductions use the 10% medical floor. + period: 2025 + input: + state_code: NY + employment_income: 80_000 + real_estate_taxes: 12_000 + other_medical_expenses: 9_000 + output: + # NY medical: 9,000 - 80,000 * 0.10 = 1,000 (federal floor would give + # 9,000 - 6,000 = 3,000). + ny_medical_expense_deduction: 1_000 + # Real estate taxes 12,000 + NY medical 1,000. Federal AGI is below the + # Line 40 applicable amount and NY AGI is not over 100,000, so neither + # reduction applies. + ny_itemized_deductions_max: 13_000 + ny_itemized_deductions_phase_out: 0 + ny_itemized_deductions_reduction: 0 + ny_itemized_deductions: 13_000 + +- name: Line 40 worksheet excludes the same NY medical deduction it includes. + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: NY + filing_status: SINGLE + adjusted_gross_income: 5_000_000 + real_estate_taxes: 150_000 + other_medical_expenses: 600_000 + output: + # NY medical: 600,000 - 5,000,000 * 0.10 = 100,000. + ny_medical_expense_deduction: 100_000 + # Worksheet line 1 = 150,000 + 100,000 = 250,000. + ny_itemized_deductions_max: 250_000 + # Line 2 = NY medical 100,000; line 3 = 150,000; line 4 = 0.80 * 150,000 + # = 120,000. Line 8 = 0.03 * (5,000,000 - 340,700) = 139,779. + # Reduction = min(120,000, 139,779) = 120,000. Excluding the federal + # medical deduction (225,000) instead would leave line 3 = 25,000 and a + # 20,000 reduction. + ny_itemized_deductions_phase_out: 120_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml index 502aab68f83..c37afdd3437 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml @@ -78,9 +78,10 @@ casualty_loss: 15_000 output: # Charitable: 5,000 - # Medical: 10,000 - (100,000 * 0.075) = 2,500 + # NY Medical (Form IT-196 lines 1-4): 10,000 - (100,000 * 0.10) = 0 + # (the federal 7.5% floor would give 2,500) # NY SALT: 12,000 real estate only (sales tax 3,000 added back per s.615(c)(1)) # NY Misc: 4,000 - (100,000 * 0.02) = 2,000 # NY Casualty: 15,000 - (100,000 * 0.10) = 5,000 - # Total: 5,000 + 2,500 + 12,000 + 2,000 + 5,000 = 26,500 - ny_itemized_deductions_max: 26_500 + # Total: 5,000 + 0 + 12,000 + 2,000 + 5,000 = 24,000 + ny_itemized_deductions_max: 24_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_gross_income.yaml index bf873261215..72d1c4817d2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_gross_income.yaml @@ -27,3 +27,33 @@ state_code: OK output: ok_gross_income: 38_566 + +- name: OK gross income includes estate and trust income, which can end the sales tax credit + period: 2025 + input: + age: 40 + irs_employment_income: 18_000 + estate_income: 3_000 + eitc: 0 + state_code: OK + output: + # Form 538-S line 11: 18,000 + 3,000 = 21,000, over the 20,000 limit + # for a filer with no dependents who is under 65 and not disabled. + ok_gross_income: 21_000 + ok_stc: 0 + +- name: OK gross income leaves out an estate or trust loss + period: 2025 + input: + age: 40 + irs_employment_income: 18_000 + estate_income: -3_000 + # The loss lowers federal AGI into the childless EITC range, and line 9 + # counts the EITC; set it to zero to isolate line 11. + eitc: 0 + state_code: OK + output: + # Line 11 takes gross (positive) income only: 18,000 + max(-3,000, 0). + # 18,000 is within the 20,000 limit, so the credit is 40 for one person. + ok_gross_income: 18_000 + ok_stc: 40 diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/eligibility/or_liheap_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/eligibility/or_liheap_eligible.yaml new file mode 100644 index 00000000000..d1b3ed8f290 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/eligibility/or_liheap_eligible.yaml @@ -0,0 +1,45 @@ +# Page numbers in the comments are those of the FY2026 Oregon Energy Assistance +# Intake, Operations and Policy Manual (energy burden on page 61 and in the +# table on pages 93-94, income limits on page 77): +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=93 +# OAR 813-200-0020(1)(b) makes "a demonstrated energy burden" a condition of +# eligibility: +# https://www.law.cornell.edu/regulations/oregon/Or-Admin-Code-SS-813-200-0020 +- name: Case 1, energy burden with heat paid directly, heat included in rent, subsidized housing and public housing. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR] + age: [38, 38, 38, 38, 38, 38, 38] + # About 30 hours a week near the Portland-metro minimum wage ($16.80 an + # hour from July 2026). + employment_income: [26_000, 26_000, 26_000, 26_000, 26_000, 26_000, 26_000] + ssi: [0, 0, 0, 0, 0, 0, 0] + tanf: [0, 0, 0, 0, 0, 0, 0] + heating_type: [ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY] + # About $125 a month where the household pays its own electric bill. + pre_subsidy_electricity_expense: [0, 0, 0, 1_500, 1_500, 1_500, 0] + heat_expense_included_in_rent: [false, true, true, false, false, true, true] + receives_housing_assistance: [false, false, true, true, false, true, false] + is_in_public_housing: [false, false, false, false, false, false, true] + output: + # Wages of 26,000 are within the size 1 limit of $38,385 (page 77) for + # every household, so eligibility turns on the energy burden table + # (page 93) and OAR 813-200-0020(1)(b): + # 1. no heating bill and heat not in rent: no energy burden to + # demonstrate ("All households must demonstrate an energy burden", + # page 61), ineligible; + # 2. "Energy Costs included in rent (non-subsidized)": energy burden + # "Yes", eligible; + # 3. "Applicant lives in Subsidized Housing and heat is included in + # rent": energy burden "No", ineligible; + # 4. "Applicant lives in Subsidized Housing and pays a vendor directly + # for utility costs": energy burden "Yes", eligible; + # 5. "Applicant pays a vendor directly for utilities": energy burden + # "Yes", eligible; + # 6. subsidized housing with heat included in rent stays ineligible + # (page 61 and row 3 of page 93) even though the household pays a + # separate electric bill: the "pays a vendor directly" row is read + # as the heating bill, so a non-heating bill creates no energy burden; + # 7. public housing is subsidized housing, so heat included in rent is + # again "No", ineligible. + or_liheap_eligible: [false, true, false, true, true, false, false] diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/eligibility/or_liheap_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/eligibility/or_liheap_income_limit.yaml new file mode 100644 index 00000000000..61a754aa0ac --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/eligibility/or_liheap_income_limit.yaml @@ -0,0 +1,50 @@ +# Sources for the expected values in this file, "60% of State Median Income by +# Household Size", Annual Gross Income column, in the Oregon Energy Assistance +# Intake, Operations and Policy Manuals: +# FY2026, page 77: +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=77 +# PY2025, page 57: +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=57 +# PY2027, page 71: +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=71 +- name: Case 1, published FY2026 annual income limits for the odd household sizes 1 to 11. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR] + spm_unit_size: [1, 3, 5, 7, 9, 11] + output: + # FY2026 page 77: size 1 $38,385, size 3 $62,006, size 5 $85,627, size 7 + # $99,652, size 9 $104,081, size 11 $108,510. + or_liheap_income_limit: [38_385, 62_006, 85_627, 99_652, 104_081, 108_510] + +- name: Case 2, published PY2025 annual income limits for the odd household sizes 1 to 11. + period: 2025 + input: + state_code: [OR, OR, OR, OR, OR, OR] + spm_unit_size: [1, 3, 5, 7, 9, 11] + output: + # PY2025 page 57: size 1 $36,811, size 3 $59,464, size 5 $82,117, size 7 + # $95,567, size 9 $99,814, size 11 $104,062. + or_liheap_income_limit: [36_811, 59_464, 82_117, 95_567, 99_814, 104_062] + +- name: Case 3, published PY2027 annual income limits at the sizes whose 60 percent product is a whole dollar. + period: 2027 + input: + state_code: [OR, OR, OR, OR, OR, OR] + spm_unit_size: [2, 3, 6, 8, 10, 12] + output: + # PY2027 page 71: size 2 $52,734, size 3 $65,142, size 6 $102,366, size 8 + # $107,019, size 10 $111,672, size 12 $116,325. The FY2027 four-person + # median is $129,250, so these products are exact dollars (129,250 x 0.68 + # x 0.6 = 52,734) and a float32 ceiling would add $1 to each. + or_liheap_income_limit: [52_734, 65_142, 102_366, 107_019, 111_672, 116_325] + +- name: Case 4, published FY2026 annual income limits for the even household sizes 2 to 12. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR] + spm_unit_size: [2, 4, 6, 8, 10, 12] + output: + # FY2026 page 77: size 2 $50,196, size 4 $73,817, size 6 $97,438, size 8 + # $101,867, size 10 $106,296, size 12 $110,725. + or_liheap_income_limit: [50_196, 73_817, 97_438, 101_867, 106_296, 110_725] diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/income/or_liheap_countable_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/income/or_liheap_countable_earned_income.yaml new file mode 100644 index 00000000000..362feb2b23c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/income/or_liheap_countable_earned_income.yaml @@ -0,0 +1,60 @@ +# Page numbers in the comments are those of the FY2026 Oregon Energy Assistance +# Intake, Operations and Policy Manual (earned income on page 39, +# self-employment and other business income on page 45, earnings of minors +# and of high-school students on pages 41 and 53): +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=41 +- name: Case 1, earnings of a minor and of a high-school student are excluded, an 18-year-old out of school counts, and a business loss does not offset wages. + period: 2026 + input: + people: + person1: + age: 17 + employment_income: 1_000 + self_employment_income: 100 + person2: + age: 18 + is_in_secondary_school: true + employment_income: 2_000 + self_employment_income: 200 + person3: + age: 19 + employment_income: 3_000 + self_employment_income: 300 + sstb_self_employment_income: 400 + farm_operations_income: 500 + person4: + age: 40 + employment_income: 4_000 + self_employment_income: -5_000 + sstb_self_employment_income: 500 + farm_operations_income: 600 + person5: + age: 18 + # About 15 hours a week near the standard minimum wage ($15.55 an hour + # from July 2026), not enrolled in high school. + employment_income: 2_500 + partnership_s_corp_income: 700 + spm_units: + spm_unit: + members: [person1, person2, person3, person4, person5] + households: + household: + members: [person1, person2, person3, person4, person5] + state_code: OR + output: + # person1: "Income Earned by minors", household members under eighteen, + # is not counted (pages 41 and 53), 0. + # person2: income earned by household members eighteen and over who are + # enrolled in high school is not counted (pages 41 and 53), 0. + # person3: wages 3,000 (page 39) + self-employment income, "income from a + # business, less business expenses" (page 45), 300 + 400 + farm 500 = + # 4,200. + # person4: wages 4,000 (page 39) + specified-service self-employment 500 + + # farm 600 (page 45) = 5,100. The 5,000 business loss is floored at zero, + # a modeling convention: the manual does not say that a current-period + # loss offsets other earnings. + # person5: earnings of a member "eighteen (18) and over" count unless the + # member is enrolled in high school (pages 41 and 53): wages 2,500 (page + # 39) + partnership and S-corporation business income 700 (page 45) = + # 3,200. + or_liheap_countable_earned_income: [0, 0, 4_200, 5_100, 3_200] diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/income/or_liheap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/income/or_liheap_countable_income.yaml new file mode 100644 index 00000000000..8a9c57b5d55 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/income/or_liheap_countable_income.yaml @@ -0,0 +1,153 @@ +# Page numbers in the comments are those of the FY2026 Oregon Energy Assistance +# Intake, Operations and Policy Manual (income and income exclusion chart on +# pages 37-48, proof of income on pages 49-51, private disability insurance +# examples on pages 55-56): +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=50 +# General assistance is a checked source in section 1.9 of the FFY2026 state +# plan, page 6: +# https://liheapch.acf.gov/docs/2026/state-plans/OR_Plan_2026.pdf#page=6 +- name: Case 1, interest counts only above $200 and every listed unearned source counts. + period: 2026 + input: + state_code: [OR, OR] + or_liheap_countable_earned_income: [0, 0] + # The only difference between the two households: interest at the $200 + # threshold and one dollar over it. + interest_income: [200, 201] + ordinary_dividend_income: [100, 100] + rental_income: [300, 300] + social_security: [1_000, 1_000] + railroad_benefits: [120, 120] + tanf: [500, 500] + pension_income: [200, 200] + unemployment_compensation: [150, 150] + child_support_received: [250, 250] + workers_compensation: [100, 100] + disability_benefits: [75, 75] + veterans_benefits: [50, 50] + alimony_income: [25, 25] + strike_benefits: [10, 10] + ssi: [600, 600] + general_assistance: [80, 80] + estate_income: [40, 40] + military_retirement_pay: [90, 90] + output: + # Each source is marked "Considered Income? Yes" in the chart: dividends + # 100 (page 39) + rental income 300 (page 44) + Social Security 1,000 + # (page 46) + Railroad Retirement 120 (page 44) + TANF 500 (page 47) + + # pensions 200 (page 44) + unemployment insurance 150 (page 48) + child + # support 250 (page 38) + workers compensation 100 (page 49) + private + # disability insurance 75 (page 39) + veterans benefits 50 (page 48) + + # alimony 25 (page 37) + strike benefits 10 (page 47) + SSI 600 (page 47) + # + general assistance 80 (state plan section 1.9) + trust and estate + # income 40 (page 48) + military retirement pay 90, a retirement payment + # (page 44) = 3,690. + # Interest is counted "only if amount is over $200" (pages 43 and 50): + # $200 adds nothing, $201 is counted in full, 3,690 + 201 = 3,891. + or_liheap_countable_income: [3_690, 3_891] + +- name: Case 2, the manual's private disability insurance examples count the insurer's payment plus Social Security once. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: [OR, OR] + or_liheap_countable_earned_income: [0, 0] + # Example A (page 55): net monthly insurer benefit $319.39 x 12 = 3,832.68. + # Example B (page 55): monthly benefit paid $709.33 x 12 = 8,511.96. + disability_benefits: [3_832.68, 8_511.96] + # Example A (page 55): Social Security $847.00 x 12 = 10,164. + # Example B (page 55): Social Security $825.00 + $414.00 = $1,239.00 x 12 + # = 14,868. + social_security: [10_164, 14_868] + ssi: [0, 0] + tanf: [0, 0] + output: + # Example A (page 55): "the maximum the client will ever receive from both + # sources is $1,166.39" a month, x 12 = 13,996.68. + # Example B (page 56): "$1,948.33 ($1,786.15 + $162.18)" a month, + # x 12 = 23,379.96. + or_liheap_countable_income: [13_996.68, 23_379.96] + +- name: Case 3, an Oregon TANF entitlement counts only when it is taken up. + period: 2026 + input: + state_code: [OR, OR] + age: [40, 40] + or_tanf: [1_200, 1_200] + takes_up_tanf_if_eligible: [false, true] + ssi: [0, 0] + output: + # TANF is "Considered Income? Yes" (page 47) and the proof of income is + # documentation "showing amount of assistance", so the model counts TANF + # actually received: the first household does not take up its $1,200 + # entitlement, 0; the second receives it, 1,200. + or_liheap_countable_income: [0, 1_200] + +- name: Case 4, rental and estate losses are floored at zero like business losses. + period: 2026 + input: + state_code: [OR, OR] + or_liheap_countable_earned_income: [45_000, 45_000] + rental_income: [-8_000, 2_000] + estate_income: [-1_000, 500] + ssi: [0, 0] + tanf: [0, 0] + output: + # Landlords complete the self-employment form (page 35) and a loss in one + # source does not offset other income, so the first household's $8,000 + # rental loss and $1,000 trust loss count as 0: 45,000. The second + # household counts both: 45,000 + rental 2,000 (page 44) + trust and + # estate income 500 (page 48) = 47,500. + or_liheap_countable_income: [45_000, 47_500] + +- name: Case 5, the $200 interest test applies to each member separately. + period: 2026 + input: + people: + person1: + age: 40 + interest_income: 150 + ssi: 0 + person2: + age: 38 + interest_income: 150 + ssi: 0 + spm_units: + spm_unit: + members: [person1, person2] + tanf: 0 + households: + household: + members: [person1, person2] + state_code: OR + output: + # Each member's $150 of interest is at or below $200 (pages 43 and 50), so + # neither is counted although the household's interest totals $300: a + # per-member reading, which the manual does not specify. + or_liheap_countable_income: 0 + +- name: Case 6, each member's rental loss is floored before the household sum. + period: 2026 + input: + people: + person1: + age: 40 + rental_income: 6_000 + ssi: 0 + person2: + age: 38 + rental_income: -6_000 + ssi: 0 + spm_units: + spm_unit: + members: [person1, person2] + tanf: 0 + households: + household: + members: [person1, person2] + state_code: OR + output: + # Each member's rental income is floored at zero before the household sum + # (page 35 sends landlords to the self-employment form), so the second + # member's $6,000 loss does not offset the first member's $6,000 of rent. + or_liheap_countable_income: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/integration.yaml new file mode 100644 index 00000000000..ffa79bfc7a3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/integration.yaml @@ -0,0 +1,119 @@ +# Page numbers in the comments are those of the PY2026 Energy Assistance Intake, +# Operations and Policy Manual (income limits on page 77, Region 1 matrix on +# pages 78-79, Region 2 matrix on pages 80-81): +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=77 +- name: Case 1, adult net earnings and child interest count but child wages do not. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 12_000 + self_employment_income: 2_400 + pension_income: 600 + ssi: 0 + general_assistance: 0 + child: + age: 17 + # About 15 hours a week near the standard minimum wage that applies in + # Deschutes County ($15.55 an hour from July 2026). + employment_income: 12_100 + interest_income: 201 + ssi: 0 + general_assistance: 0 + spm_units: + unit: + members: + - adult + - child + heating_type: ELECTRICITY + # About $150 a month for an electrically heated home. + pre_subsidy_electricity_expense: 1_800 + tanf: 0 + households: + home: + members: + - adult + - child + state_code: OR + county: DESCHUTES_COUNTY_OR + output: + # Adult: wages 12,000 (page 39) + net self-employment 2,400 (pages 35 + # and 45) = 14,400. + # Child: income earned by members under 18 is not counted (pages 41 + # and 53), so the 12,100 of wages counts as 0. + or_liheap_countable_earned_income: + - 14_400 + - 0 + # 14,400 + pension 600 (page 44) + interest 201, counted because it is + # over $200 a year (pages 43 and 50) = 15,201. + or_liheap_countable_income: 15_201 + # 60% of state median income, size 2: $50,196 (page 77). + or_liheap_income_limit: 50_196 + # Deschutes County is in the Region 2 list (page 80). + or_liheap_region: 2 + # Size 2, second row "$12,550 $25,098" (page 80). Counting the child's + # wages would give 27,301 and the third row instead. + or_liheap_income_band: 2 + # Pays its own electric heating bill and 15,201 is within $50,196. + or_liheap_eligible: true + # Region 2, size 2, "$12,550 $25,098", Electricity $505 (page 80). + or_liheap: 505 +- name: Case 2, adult secondary-school earnings are excluded for a nonsubsidized heat-in-rent household. + period: 2026 + input: + state_code: OR + county: MULTNOMAH_COUNTY_OR + age: 18 + is_in_secondary_school: true + # About 15 hours a week near the Portland-metro minimum wage ($16.80 an + # hour from July 2026). + employment_income: 13_100 + ssi: 0 + tanf: 0 + general_assistance: 0 + heat_expense_included_in_rent: true + heating_type: ELECTRICITY + output: + # Income earned by a member aged 18 or over who is enrolled in high + # school is not counted (pages 41 and 53); there is no other income. + or_liheap_countable_income: 0 + # 60% of state median income, size 1: $38,385 (page 77). + or_liheap_income_limit: 38_385 + # Size 1, first row "$0 $9,597" (page 78). Counting the 13,100 of wages + # would give the second row instead. + or_liheap_income_band: 1 + # Energy costs included in rent in non-subsidized housing are eligible + # (page 93) and 0 is within $38,385. + or_liheap_eligible: true + # Region 1 (Multnomah, page 78), size 1, "$0 $9,597", Electricity $465 + # (page 78). + or_liheap: 465 +- name: Case 3, public-housing household paying its own heat can receive a standard grant. + period: 2026 + input: + state_code: OR + county: MULTNOMAH_COUNTY_OR + age: 40 + pension_income: 12_000 + ssi: 0 + tanf: 0 + general_assistance: 0 + is_in_public_housing: true + heating_type: NATURAL_GAS + # About $80 a month for gas heat. + gas_expense: 960 + output: + # Pension 12,000 is counted (page 44); there is no other income. + or_liheap_countable_income: 12_000 + # Multnomah County is in the Region 1 list (page 78). + or_liheap_region: 1 + # Size 1, second row "$9,598 $19,193" (page 78). + or_liheap_income_band: 2 + # A subsidized-housing resident who pays a vendor directly for utilities + # is eligible (page 93); only subsidized housing with heat included in + # rent is excluded (page 61). 12,000 is within the size 1 limit of + # $38,385 (page 77). + or_liheap_eligible: true + # Region 1, size 1, "$9,598 $19,193", Natural Gas $520 (page 78). + or_liheap: 520 diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/or_liheap.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/or_liheap.yaml new file mode 100644 index 00000000000..61b113dc114 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/or/ohcs/liheap/or_liheap.yaml @@ -0,0 +1,600 @@ +# Page numbers in the comments are those of the FY2026 Oregon Energy Assistance +# Intake, Operations and Policy Manual (income limits on page 77, Region 1 +# matrix on pages 78-79, Region 2 matrix on pages 80-81, payment types on +# pages 61-62, energy burden table on pages 93-94); the manual is linked in +# the comment above Case 19. +# +# Cases 1 to 10 sweep the published payment matrices for the even household +# sizes 2, 4, 6, 8, 10 and 12; Cases 12 and 21 test the odd sizes 1, 3 and 5 +# at their printed band tops. Each size is tested at the first dollar of its four +# printed income rows, in this order: +# size 2: "$0", "$12,550", "$25,099", "$37,647" (pages 78 and 80) +# size 4: "$0", "$18,455", "$36,909", "$55,363" (pages 78 and 80) +# size 6: "$0", "$24,361", "$48,720", "$73,079" (pages 78 and 80) +# size 8: "$0", "$25,468", "$50,934", "$76,401" (pages 79 and 81) +# size 10: "$0", "$26,575", "$53,149", "$79,722" (pages 79 and 81) +# size 12: "$0", "$27,682", "$55,363", "$83,044" (pages 79 and 81) +# Multnomah County is in the Region 1 list (page 78) and Deschutes County in +# the Region 2 list (page 80). +- name: Case 1, Region 1 electricity payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY] + # About $125 a month for electric heat. + pre_subsidy_electricity_expense: [1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 1 matrix, Electricity column, four rows per size: + # size 2 (page 78): $595, $465, $385, $275 + # size 4 (page 78): $690, $540, $450, $320 + # size 6 (page 78): $750, $590, $490, $350 + # size 8 (page 79): $750, $590, $490, $350 + # size 10 (page 79): $750, $590, $490, $350 + # size 12 (page 79): $750, $590, $490, $350 + or_liheap: [595, 465, 385, 275, 690, 540, 450, 320, 750, 590, 490, 350, 750, 590, 490, 350, 750, 590, 490, 350, 750, 590, 490, 350] + +- name: Case 2, Region 1 heating oil payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL] + # About 600 gallons of heating oil a year. + fuel_oil_expense: [2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 1 matrix, Heating Oil column, four rows per size: + # size 2 (page 78): $750, $750, $640, $460 + # size 4 (page 78): $750, $750, $700, $505 + # size 6 (page 78): $750, $750, $740, $535 + # size 8 (page 79): $750, $750, $740, $535 + # size 10 (page 79): $750, $750, $740, $535 + # size 12 (page 79): $750, $750, $740, $535 + or_liheap: [750, 750, 640, 460, 750, 750, 700, 505, 750, 750, 740, 535, 750, 750, 740, 535, 750, 750, 740, 535, 750, 750, 740, 535] + +- name: Case 3, Region 1 propane payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE] + # About $150 a month for propane heat. + bottled_gas_expense: [1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 1 matrix, Liquid Gas (propane) column, four rows per size: + # size 2 (page 78): $750, $655, $545, $390 + # size 4 (page 78): $750, $725, $605, $435 + # size 6 (page 78): $750, $750, $645, $460 + # size 8 (page 79): $750, $750, $645, $460 + # size 10 (page 79): $750, $750, $645, $460 + # size 12 (page 79): $750, $750, $645, $460 + or_liheap: [750, 655, 545, 390, 750, 725, 605, 435, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460] + +- name: Case 4, Region 1 natural gas payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS] + # About $80 a month for gas heat. + gas_expense: [960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 1 matrix, Natural Gas column, four rows per size: + # size 2 (page 78): $750, $605, $505, $360 + # size 4 (page 78): $750, $670, $555, $400 + # size 6 (page 78): $750, $710, $590, $425 + # size 8 (page 79): $750, $710, $590, $425 + # size 10 (page 79): $750, $710, $590, $425 + # size 12 (page 79): $750, $710, $590, $425 + or_liheap: [750, 605, 505, 360, 750, 670, 555, 400, 750, 710, 590, 425, 750, 710, 590, 425, 750, 710, 590, 425, 750, 710, 590, 425] + +- name: Case 5, Region 1 wood payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD] + # About three cords of firewood a year. + wood_expense: [900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 1 matrix, Wood/Pellets column, four rows per size: + # size 2 (page 78): $750, $655, $545, $390 + # size 4 (page 78): $750, $725, $605, $435 + # size 6 (page 78): $750, $750, $645, $460 + # size 8 (page 79): $750, $750, $645, $460 + # size 10 (page 79): $750, $750, $645, $460 + # size 12 (page 79): $750, $750, $645, $460 + or_liheap: [750, 655, 545, 390, 750, 725, 605, 435, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460] + +- name: Case 6, Region 2 electricity payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY] + # About $125 a month for electric heat. + pre_subsidy_electricity_expense: [1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 2 matrix, Electricity column, four rows per size: + # size 2 (page 80): $645, $505, $420, $300 + # size 4 (page 80): $740, $580, $480, $345 + # size 6 (page 80): $750, $630, $525, $375 + # size 8 (page 81): $750, $630, $525, $375 + # size 10 (page 81): $750, $630, $525, $375 + # size 12 (page 81): $750, $630, $525, $375 + or_liheap: [645, 505, 420, 300, 740, 580, 480, 345, 750, 630, 525, 375, 750, 630, 525, 375, 750, 630, 525, 375, 750, 630, 525, 375] + +- name: Case 7, Region 2 heating oil payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL, FUEL_OIL] + # About 600 gallons of heating oil a year. + fuel_oil_expense: [2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400, 2_400] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 2 matrix, Heating Oil column, four rows per size: + # size 2 (page 80): $750, $750, $640, $460 + # size 4 (page 80): $750, $750, $700, $505 + # size 6 (page 80): $750, $750, $740, $535 + # size 8 (page 81): $750, $750, $740, $535 + # size 10 (page 81): $750, $750, $740, $535 + # size 12 (page 81): $750, $750, $740, $535 + or_liheap: [750, 750, 640, 460, 750, 750, 700, 505, 750, 750, 740, 535, 750, 750, 740, 535, 750, 750, 740, 535, 750, 750, 740, 535] + +- name: Case 8, Region 2 propane payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE, PROPANE] + # About $150 a month for propane heat. + bottled_gas_expense: [1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800, 1_800] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 2 matrix, Liquid Gas (propane) column, four rows per size: + # size 2 (page 80): $750, $655, $545, $390 + # size 4 (page 80): $750, $725, $605, $435 + # size 6 (page 80): $750, $750, $645, $460 + # size 8 (page 81): $750, $750, $645, $460 + # size 10 (page 81): $750, $750, $645, $460 + # size 12 (page 81): $750, $750, $645, $460 + or_liheap: [750, 655, 545, 390, 750, 725, 605, 435, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460] + +- name: Case 9, Region 2 natural gas payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS] + # About $80 a month for gas heat. + gas_expense: [960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 2 matrix, Natural Gas column, four rows per size: + # size 2 (page 80): $670, $520, $435, $310 + # size 4 (page 80): $750, $585, $485, $350 + # size 6 (page 80): $750, $630, $525, $375 + # size 8 (page 81): $750, $630, $525, $375 + # size 10 (page 81): $750, $630, $525, $375 + # size 12 (page 81): $750, $630, $525, $375 + or_liheap: [670, 520, 435, 310, 750, 585, 485, 350, 750, 630, 525, 375, 750, 630, 525, 375, 750, 630, 525, 375, 750, 630, 525, 375] + +- name: Case 10, Region 2 wood payments for even household sizes at every income band. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [2, 2, 2, 2, 4, 4, 4, 4, 6, 6, 6, 6, 8, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12, 12] + heating_type: [WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD, WOOD] + # About three cords of firewood a year. + wood_expense: [900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900] + or_liheap_countable_income: [0, 12_550, 25_099, 37_647, 0, 18_455, 36_909, 55_363, 0, 24_361, 48_720, 73_079, 0, 25_468, 50_934, 76_401, 0, 26_575, 53_149, 79_722, 0, 27_682, 55_363, 83_044] + output: + # Region 2 matrix, Wood/Pellets column, four rows per size: + # size 2 (page 80): $750, $655, $545, $390 + # size 4 (page 80): $750, $725, $605, $435 + # size 6 (page 80): $750, $750, $645, $460 + # size 8 (page 81): $750, $750, $645, $460 + # size 10 (page 81): $750, $750, $645, $460 + # size 12 (page 81): $750, $750, $645, $460 + or_liheap: [750, 655, 545, 390, 750, 725, 605, 435, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460, 750, 750, 645, 460] + +- name: Case 11, the published income limit includes the dollar above the last matrix row. + period: 2026 + input: + state_code: [OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [2, 2, 2] + heating_type: [ELECTRICITY, ELECTRICITY, ELECTRICITY] + # About $125 a month for electric heat. + pre_subsidy_electricity_expense: [1_500, 1_500, 1_500] + # The last size 2 matrix row ends at $50,195 (page 78) while the size 2 + # income limit is $50,196 (page 77): the top of the row, the limit itself + # and one dollar above the limit. + or_liheap_countable_income: [50_195, 50_196, 50_197] + output: + # Size 2 limit $50,196 (page 77): $50,195 and $50,196 are within it, + # $50,197 is not. + or_liheap_eligible: [true, true, false] + # Region 1, size 2, "$37,647 $50,195", Electricity $275 (page 78). The + # eligible household at exactly $50,196 is paid from that same last row. + # The ineligible household receives $0. + or_liheap: [275, 275, 0] + +- name: Case 12, income bands include their printed tops and change at the next dollar. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + heating_type: [NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS] + # About $80 a month for gas heat. + gas_expense: [960, 960, 960, 960, 960, 960, 960] + # Size 1 rows (page 78): "$0 $9,597", "$9,598 $19,193", "$19,194 $28,789", + # "$28,790 $38,385". Tested at $0, at each printed row top and at the + # first dollar of the following row. + or_liheap_countable_income: [0, 9_597, 9_598, 19_193, 19_194, 28_789, 28_790] + output: + # Region 1, size 1, Natural Gas column (page 78): $670 for "$0 $9,597", + # $520 for "$9,598 $19,193", $435 for "$19,194 $28,789" and $310 for + # "$28,790 $38,385". + or_liheap: [670, 670, 520, 520, 435, 435, 310] + +- name: Case 13, supported and unsupported heating fuels. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + heating_type: [ELECTRICITY, FUEL_OIL, PROPANE, NATURAL_GAS, WOOD, KEROSENE, COAL, SOLAR, OTHER, UNSPECIFIED, NONE] + # Each household pays the bill of its own fuel. Kerosene is billed as + # heating oil and a grid-tied solar home pays an electricity bill. + pre_subsidy_electricity_expense: [1_500, 0, 0, 0, 0, 0, 0, 1_200, 0, 0, 0] + fuel_oil_expense: [0, 2_400, 0, 0, 0, 1_200, 0, 0, 0, 0, 0] + bottled_gas_expense: [0, 0, 1_800, 0, 0, 0, 0, 0, 0, 0, 0] + gas_expense: [0, 0, 0, 960, 0, 0, 0, 0, 0, 0, 0] + wood_expense: [0, 0, 0, 0, 900, 0, 0, 0, 0, 0, 0] + coal_expense: [0, 0, 0, 0, 0, 0, 800, 0, 0, 0, 0] + other_heating_fuel_expense: [0, 0, 0, 0, 0, 0, 0, 0, 1_000, 0, 0] + # The household that did not report its fuel has heat included in rent; + # the last household has no heating at all. + heat_expense_included_in_rent: [false, false, false, false, false, false, false, false, false, true, false] + or_liheap_countable_income: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + output: + # Paying a vendor directly or having energy costs included in + # non-subsidized rent is an energy burden (page 93), and $0 is within the + # size 1 limit of $38,385 (page 77). A home with no heating has no + # energy burden to demonstrate (page 61). + or_liheap_eligible: [true, true, true, true, true, true, true, true, true, true, false] + # Region 2, size 1, "$0 $9,597" (page 80): Electricity $515, Heating Oil + # $750, Liquid Gas $715, Natural Gas $560, Wood/Pellets $715. The program + # is fuel blind (page 5), so kerosene is paid from the Heating Oil column, + # $750, and a grid-tied solar home from the Electricity column, $515. Coal + # and other fuels have no matrix column, $0, a coverage gap rather than a + # denial. An unspecified fuel pays $0 even with heat included in rent + # because the amount depends on the fuel column; the UNSPECIFIED default + # is tracked in issue 9754. No heating, ineligible, $0. + or_liheap: [515, 750, 715, 560, 715, 750, 0, 515, 0, 0, 0] + +- name: Case 14, a roomer applying separately with heat in rent receives half the regular payment. + period: 2026 + input: + state_code: OR + county: MULTNOMAH_COUNTY_OR + age: 28 + # About 10 hours a week near the Portland-metro minimum wage ($16.80 an + # hour from July 2026). + employment_income: 8_600 + ssi: 0 + tanf: 0 + # The rented room is heated by the building's electric heat. + heating_type: ELECTRICITY + heat_expense_included_in_rent: true + or_liheap_dwelling_type: ROOMER_BOARDER_OWNER + output: + # Wages are the only income (page 39). + or_liheap_countable_income: 8_600 + # Size 1, first row "$0 $9,597" (page 78). + or_liheap_income_band: 1 + # Energy costs included in non-subsidized rent are an energy burden + # (page 93) and 8,600 is within the size 1 limit of $38,385 (page 77). + or_liheap_eligible: true + # Region 1, size 1, "$0 $9,597", Electricity $465 (page 78). A roomer or + # boarder who applies separately as an individual household receives + # "50% of a regular payment based on the Benefit Matrix" (pages 61-62): + # $465 x 0.5 = $232.50. + or_liheap: 232.5 + +- name: Case 15, an unknown county and a household above the published size range are unsupported. + period: 2026 + input: + state_code: [OR, OR] + county: [UNKNOWN, MULTNOMAH_COUNTY_OR] + spm_unit_size: [1, 13] + age: [40, 40] + employment_income: [30_000, 40_000] + ssi: [0, 0] + tanf: [0, 0] + heating_type: [ELECTRICITY, ELECTRICITY] + # About $125 a month for electric heat. + pre_subsidy_electricity_expense: [1_500, 1_500] + output: + # Both pay an electric heating bill (page 93). 30,000 is within the + # size 1 limit of $38,385 (page 77). 40,000 is far within the size 13 + # limit: page 77 gives $110,725 for size 12 plus an increment for each + # additional member (printed as $2,115, while the table's own steps above + # size 6 are $2,214), so the limit is about $112,900 either way. + or_liheap_eligible: [true, true] + # A county reported as UNKNOWN is in neither region list (pages 78 and + # 80), and the matrices end at size 12 (pages 79 and 81), so the model has + # no published amount to pay: $0 in both cases, a coverage gap rather than + # a denial. A household that omits the county input is Case 22. + or_liheap: [0, 0] + +- name: Case 16, a small gas bill does not cap the standard payment. + period: 2026 + input: + state_code: OR + county: MULTNOMAH_COUNTY_OR + age: 35 + # About 9 hours a week near the Portland-metro minimum wage ($16.80 an + # hour from July 2026). + employment_income: 8_000 + ssi: 0 + tanf: 0 + heating_type: NATURAL_GAS + # About $25 a month of gas in a small, well-insulated apartment. + gas_expense: 300 + output: + # Wages are the only income (page 39). + or_liheap_countable_income: 8_000 + # Size 1, first row "$0 $9,597" (page 78). + or_liheap_income_band: 1 + # Pays a vendor directly (page 93) and 8,000 is within the size 1 limit + # of $38,385 (page 77). + or_liheap_eligible: true + # Region 1, size 1, "$0 $9,597", Natural Gas $670 (page 78). The standard + # payment is "determined by the LIHEAP/OEAP Benefits Matrix" (page 61) + # and is not limited to the household's $300 bill. + or_liheap: 670 + +- name: Case 17, a Washington household receives no Oregon benefit. + period: 2026 + input: + state_code: WA + age: 40 + employment_income: 30_000 + ssi: 0 + tanf: 0 + heating_type: NATURAL_GAS + # About $80 a month for gas heat. + gas_expense: 960 + output: + # The manual governs the Oregon program administered by OHCS (page 1), so + # a household outside Oregon receives nothing and every Oregon variable + # keeps its default. + or_liheap: 0 + +- name: Case 18, one simulation with an Oregon household and a Washington household pays only the Oregon household. + period: 2026 + input: + people: + person1: + age: 30 + # Full-time work near the Portland-metro minimum wage ($16.80 an hour from July 2026). + employment_income: 34_500 + ssi: 0 + person2: + age: 45 + employment_income: 30_000 + ssi: 0 + spm_units: + spm_unit1: + members: [person1] + heating_type: NATURAL_GAS + # About $80 a month for gas heat. + gas_expense: 960 + tanf: 0 + spm_unit2: + members: [person2] + heating_type: ELECTRICITY + # About $125 a month for electric heat. + pre_subsidy_electricity_expense: 1_500 + tanf: 0 + households: + household1: + members: [person1] + state_code: OR + county: MULTNOMAH_COUNTY_OR + household2: + members: [person2] + state_code: WA + output: + # Unit 1: wages are the only income and earned income is counted (page 39). + # Unit 2 is outside Oregon, so every Oregon variable keeps its default. + or_liheap_countable_income: [34_500, 0] + # Unit 1: size 1, fourth row "$28,790 $38,385" (page 78). + # Unit 2: default 0, which the payment lookup must tolerate. + or_liheap_income_band: [4, 0] + # Unit 1: pays a gas bill and 34,500 is within the size 1 limit of + # $38,385 (page 77). Unit 2: not an Oregon household. + or_liheap_eligible: [true, false] + # Unit 1: Region 1 (Multnomah, page 78), size 1, "$28,790 $38,385", + # Natural Gas $310 (page 78). Unit 2: no Oregon benefit. + or_liheap: [310, 0] + +# PY2026 manual, page 44: rental-property income and pensions paid to surviving +# dependents count separately from wages. Page 77 gives the $38,385 size-one limit. +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=44 +- name: Case 19, farm rental income puts a gas-heating household over the income limit. + period: 2026 + input: + state_code: OR + county: MULTNOMAH_COUNTY_OR + age: 45 + employment_income: 30_000 + farm_rent_income: 10_000 + rental_income: 0 + ssi: 0 + tanf: 0 + heating_type: NATURAL_GAS + gas_expense: 1_200 + output: + # $30,000 wages + $10,000 farm rents exceeds $38,385. + or_liheap_countable_income: 40_000 + or_liheap_income_limit: 38_385 + or_liheap_eligible: false + or_liheap: 0 + +- name: Case 20, survivor pension income puts a gas-heating household over the income limit. + period: 2026 + input: + state_code: OR + county: MULTNOMAH_COUNTY_OR + age: 45 + employment_income: 30_000 + survivor_benefits: 10_000 + pension_income: 0 + ssi: 0 + tanf: 0 + heating_type: NATURAL_GAS + gas_expense: 1_200 + output: + # $30,000 wages + $10,000 survivor pension exceeds $38,385. + or_liheap_countable_income: 40_000 + or_liheap_income_limit: 38_385 + or_liheap_eligible: false + or_liheap: 0 + +- name: Case 21, odd household sizes change band at the printed tops, and wood differs from propane only in the last size 1 row. + period: 2026 + input: + state_code: [OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR] + spm_unit_size: [3, 3, 3, 3, 3, 3, 5, 5, 5, 5, 5, 5, 1, 1] + heating_type: [NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, NATURAL_GAS, PROPANE, WOOD] + # About $80 a month for gas heat, $150 a month for propane heat and three + # cords of firewood a year. + gas_expense: [960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 960, 0, 0] + bottled_gas_expense: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1_800, 0] + wood_expense: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 900] + # Size 3 rows (page 78): "$0 $15,502", "$15,503 $31,003", "$31,004 $46,504", + # "$46,505 $62,006"; size 5 rows (page 78): "$0 $21,407", "$21,408 $42,814", + # "$42,815 $64,220", "$64,221 $85,627". Each printed top and the next + # dollar, then size 1 at $30,000 in its last row "$28,790 $38,385". + or_liheap_countable_income: [15_502, 15_503, 31_003, 31_004, 46_504, 46_505, 21_407, 21_408, 42_814, 42_815, 64_220, 64_221, 30_000, 30_000] + output: + or_liheap_income_band: [1, 2, 2, 3, 3, 4, 1, 2, 2, 3, 3, 4, 4, 4] + # Region 1 Natural Gas column (page 78): size 3 $750, $640, $535, $385; + # size 5 $750, $690, $575, $415. Size 1, last row (page 78): Liquid Gas + # $335, Wood/Pellets $350. + or_liheap: [750, 640, 640, 535, 535, 385, 750, 690, 690, 575, 575, 415, 335, 350] + +- name: Case 22, a household without a county input is placed in Baker County and paid Region 2 rates. + period: 2026 + input: + state_code: [OR, OR] + spm_unit_size: [1, 1] + heating_type: [ELECTRICITY, NATURAL_GAS] + # About $125 a month for electric heat and $80 a month for gas heat. + pre_subsidy_electricity_expense: [1_500, 0] + gas_expense: [0, 960] + or_liheap_countable_income: [0, 0] + output: + # With no county input the model falls back to the first Oregon county in + # the enum, Baker County, which is in the Region 2 list (page 80). + or_liheap_region: [2, 2] + # Region 2, size 1, "$0 $9,597" (page 80): Electricity $515, Natural Gas + # $560. + or_liheap: [515, 560] + +# PY2025 manual (effective October 1, 2024), income limits on PDF page 57 and +# matrices on pages 58-59: +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58 +- name: Case 23, program year 2025 pays the PY2025 matrix, whose heating oil column differs by region. + period: 2025 + input: + state_code: [OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [1, 1, 1, 1] + heating_type: [ELECTRICITY, NATURAL_GAS, FUEL_OIL, FUEL_OIL] + # About $125 a month for electric heat, $80 a month for gas heat and 600 + # gallons of heating oil a year. + pre_subsidy_electricity_expense: [1_500, 0, 0, 0] + gas_expense: [0, 960, 0, 0] + fuel_oil_expense: [0, 0, 2_400, 2_400] + or_liheap_countable_income: [9_400, 0, 20_000, 20_000] + output: + # PY2025 size 1 limit $36,811 (page 57). + or_liheap_income_limit: [36_811, 36_811, 36_811, 36_811] + # PY2025 size 1 rows "$0 $9,203", "$9,204 $18,406", "$18,407 $27,609" + # (page 58). + or_liheap_income_band: [2, 1, 3, 3] + # Region 1 (page 58): Electricity $295 in the second row, Natural Gas $500 + # in the first row, Heating Oil $680 in the third row. Region 2 (page 59): + # Heating Oil $745 in the third row. + or_liheap: [295, 500, 680, 745] + +# PY2027 manual (effective October 1, 2026), income limits on PDF page 71 and +# matrices on pages 72-75: +# https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72 +- name: Case 24, program year 2027 pays the PY2027 matrix with its new band tops. + period: 2027 + input: + state_code: [OR, OR, OR, OR] + county: [MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, MULTNOMAH_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [1, 1, 1, 1] + heating_type: [ELECTRICITY, ELECTRICITY, NATURAL_GAS, ELECTRICITY] + # About $125 a month for electric heat and $80 a month for gas heat. + pre_subsidy_electricity_expense: [1_500, 1_500, 0, 1_500] + gas_expense: [0, 0, 960, 0] + # The first PY2027 size 1 row ends at $10,082 (page 72). + or_liheap_countable_income: [10_000, 10_083, 0, 10_000] + output: + # PY2027 size 1 limit $40,326 (page 71). + or_liheap_income_limit: [40_326, 40_326, 40_326, 40_326] + # PY2027 size 1 rows "$0 $10,082", "$10,083 $20,163" (page 72). + or_liheap_income_band: [1, 2, 1, 1] + # Region 1 (page 72): Electricity $475 then $370, Natural Gas $660. + # Region 2 (page 74): Electricity $510. + or_liheap: [475, 370, 660, 510] + +- name: Case 25, program year 2027 pays the size 12 rows of the Region 2 matrix. + period: 2027 + input: + state_code: [OR, OR, OR, OR, OR, OR] + county: [DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR, DESCHUTES_COUNTY_OR] + spm_unit_size: [12, 12, 12, 12, 12, 12] + heating_type: [ELECTRICITY, ELECTRICITY, ELECTRICITY, ELECTRICITY, NATURAL_GAS, FUEL_OIL] + # About $125 a month for electric heat, $80 a month for gas heat and 600 + # gallons of heating oil a year. + pre_subsidy_electricity_expense: [1_500, 1_500, 1_500, 1_500, 0, 0] + gas_expense: [0, 0, 0, 0, 960, 0] + fuel_oil_expense: [0, 0, 0, 0, 0, 2_400] + # PY2027 size 12 rows (page 75): "$0 $29,082", "$29,083 $58,163", + # "$58,164 $87,244", "$87,245 $116,325": the first dollar of each row, then + # the last row for gas and oil. + or_liheap_countable_income: [0, 29_083, 58_164, 87_245, 87_245, 87_245] + output: + # PY2027 size 12 limit $116,325 (page 71). + or_liheap_income_limit: [116_325, 116_325, 116_325, 116_325, 116_325, 116_325] + or_liheap_income_band: [1, 2, 3, 4, 4, 4] + # Region 2, size 12 (page 75): Electricity $750, $615, $515, $370; Natural + # Gas $380 and Heating Oil $535 in the last row. Sizes 7 to 12 print the + # size 6 amounts. + or_liheap: [750, 615, 515, 370, 380, 535] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit.yaml index b9a19ddc96c..073f9801cb1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit.yaml @@ -88,6 +88,9 @@ tax_unit_size: 1 real_estate_taxes: 2_000 rent: 0 - greater_age_head_spouse: 70 + # The claimant's own age now sets eligibility (R.I. Gen. Laws 44-33-3), + # so the income cutoff alone makes this filer ineligible. + age: 70 output: + ri_property_tax_credit_eligible: false ri_property_tax_credit: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.yaml index 4e50ae67af2..d2ea7dffe07 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.yaml @@ -1,59 +1,379 @@ - name: Head above minimum age, and disabled period: 2022 input: - state_code: RI - greater_age_head_spouse: 65 - head_is_disabled: true - spouse_is_disabled: false + people: + head: + age: 65 + is_disabled: true + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: RI output: ri_property_tax_credit_eligible: true -- name: Head above minimum age, not diabled +- name: Head above minimum age, not disabled period: 2022 input: - state_code: RI - greater_age_head_spouse: 65 - head_is_disabled: false - spouse_is_disabled: false + people: + head: + age: 65 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: RI output: ri_property_tax_credit_eligible: true - name: Head disabled, not above minimum age period: 2022 input: - state_code: RI - greater_age_head_spouse: 64 - head_is_disabled: true - spouse_is_disabled: false + people: + head: + age: 64 + is_disabled: true + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: RI output: ri_property_tax_credit_eligible: true - + - name: Spouse above minimum age, not disabled period: 2021 input: - state_code: RI - greater_age_head_spouse: 65 - head_is_disabled: false - spouse_is_disabled: false + people: + head: + age: 64 + spouse: + age: 65 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: RI output: ri_property_tax_credit_eligible: true - name: Not above minimum age, not disabled period: 2021 input: - state_code: RI - greater_age_head_spouse: 64 - head_is_disabled: false - spouse_is_disabled: false + people: + head: + age: 64 + spouse: + age: 64 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: RI output: ri_property_tax_credit_eligible: false - name: Spouse disabled period: 2021 input: - state_code: RI - greater_age_head_spouse: 64 - head_is_disabled: false - spouse_is_disabled: true + people: + head: + age: 64 + spouse: + age: 64 + is_disabled: true + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: RI + output: + ri_property_tax_credit_eligible: true + +- name: "2025 single filer who can be claimed as a dependent: property tax relief needs a claimant aged 65 or older or disabled who is not claimed as a dependent" + period: 2025 + input: + people: + filer: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: RI + output: + # R.I. Gen. Laws 44-33-3: the claimant does not include any person + # claimed as a dependent by any taxpayer. + ri_property_tax_credit_eligible: false + ri_property_tax_credit: 0 + +- name: "2025 joint return with one claimable spouse: property tax relief needs a claimant aged 65 or older or disabled who is not claimed as a dependent (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + real_estate_taxes: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: RI + output: + # The unclaimed spouse, aged 65, may be the claimant (household + # members choose who claims). Household income $0, so the + # threshold is $0; property tax $1,000 is capped at the 2025 + # maximum of $700. + ri_property_tax_credit_eligible: true + ri_property_tax_credit: 700 + +- name: "2025 joint return with one claimable spouse: property tax relief needs a claimant aged 65 or older or disabled who is not claimed as a dependent (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 65 + real_estate_taxes: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: RI + output: + ri_property_tax_credit_eligible: true + ri_property_tax_credit: 700 + +- name: "2025 joint return with both spouses claimable: property tax relief needs a claimant aged 65 or older or disabled who is not claimed as a dependent" + period: 2025 + input: + people: + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: RI + output: + ri_property_tax_credit_eligible: false + ri_property_tax_credit: 0 + +- name: "2025 joint return with neither spouse claimable: property tax relief needs a claimant aged 65 or older or disabled who is not claimed as a dependent" + period: 2025 + input: + people: + person_a: + age: 65 + real_estate_taxes: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + real_estate_taxes: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: RI + output: + # Same as above: min($1,000 - 0 x 3%, $700) = $700. + ri_property_tax_credit_eligible: true + ri_property_tax_credit: 700 + +- name: "2025 joint return where only the claimable spouse is 65 or older: property tax relief has no eligible claimant (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 40 + real_estate_taxes: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: RI + output: + # The age test and the dependency test apply to the same claimant, + # so the claimed spouse's age does not qualify the other spouse. + ri_property_tax_credit_eligible: false + ri_property_tax_credit: 0 + +- name: "2025 joint return where only the claimable spouse is 65 or older: property tax relief has no eligible claimant (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 40 + real_estate_taxes: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: RI + output: + ri_property_tax_credit_eligible: false + ri_property_tax_credit: 0 + +- name: "2025 joint return where the unclaimed spouse is disabled: property tax relief stays available (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 40 + is_disabled: true + real_estate_taxes: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: RI + output: + # The disabled, unclaimed spouse is the claimant. + ri_property_tax_credit_eligible: true + ri_property_tax_credit: 700 + +- name: "2025 joint return where the unclaimed spouse is disabled: property tax relief stays available (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 40 + is_disabled: true + real_estate_taxes: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 65 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: RI + output: + ri_property_tax_credit_eligible: true + ri_property_tax_credit: 700 + +- name: 2025 single filer aged 65 who can be claimed but is not claimed is a Rhode Island property tax relief claimant + period: 2025 + input: + people: + filer: + age: 65 + real_estate_taxes: 1000 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: RI output: + # 44-33-3 excludes a person "claimed as a dependent by any taxpayer": + # an actual claim. min($1,000 - $0, $700) = $700. ri_property_tax_credit_eligible: true + ri_property_tax_credit: 700 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.yaml b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.yaml index a69c04874f5..c59f55ea156 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.yaml @@ -200,3 +200,141 @@ ri_refundable_credits: 250 # 1,000 before refundable credits - 250 refundable credits = 750. ri_income_tax: 750 + +- name: "2021 single filer who can be claimed as a dependent: the 2021 child tax rebate needs a child validly claimed as a dependent" + period: 2021 + input: + people: + filer: + age: 20 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: RI + output: + # R.I. Gen. Laws 44-30-103: $250 for each child the taxpayer validly + # claimed as a dependent; under IRC 152(b)(1) a filer who can be + # claimed has no dependents. + ri_child_tax_rebate: 0 + +- name: "2021 joint return with one claimable spouse: the 2021 child tax rebate needs a child validly claimed as a dependent (the claimable spouse is the head)" + period: 2021 + input: + people: + person_a: + age: 20 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 20 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: RI + output: + # On a joint return either spouse being claimable leaves no + # dependents (Publication 501, Dependent Taxpayer Test). + ri_child_tax_rebate: 0 + +- name: "2021 joint return with one claimable spouse: the 2021 child tax rebate needs a child validly claimed as a dependent (the claimable spouse is the spouse)" + period: 2021 + input: + people: + person_b: + age: 20 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 20 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: RI + output: + ri_child_tax_rebate: 0 + +- name: "2021 joint return with both spouses claimable: the 2021 child tax rebate needs a child validly claimed as a dependent" + period: 2021 + input: + people: + person_a: + age: 20 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 20 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: RI + output: + ri_child_tax_rebate: 0 + +- name: "2021 joint return with neither spouse claimable: the 2021 child tax rebate needs a child validly claimed as a dependent" + period: 2021 + input: + people: + person_a: + age: 20 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 20 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: RI + output: + # One child aged 4: min(1, 3) x $250. + ri_child_tax_rebate: 250 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/deductions/sc_young_child_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/deductions/sc_young_child_deduction.yaml index c7acead018f..09ba8177f9d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/deductions/sc_young_child_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/deductions/sc_young_child_deduction.yaml @@ -72,3 +72,138 @@ # Young child deduction requires child to be under age 6 (ineligible_age = 6) # Child age 6 does NOT qualify - must be younger than 6 sc_young_child_deduction: 0 + +- name: "2025 single filer who can be claimed as a dependent: no South Carolina young child deduction" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: SC + output: + # S.C. Code 12-6-1160 requires a dependent eligible for the South + # Carolina dependent exemption, which a claimable filer has none of. + sc_young_child_deduction: 0 + +- name: "2025 joint return with one claimable spouse: no South Carolina young child deduction (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: SC + output: + sc_young_child_deduction: 0 + +- name: "2025 joint return with one claimable spouse: no South Carolina young child deduction (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: SC + output: + sc_young_child_deduction: 0 + +- name: "2025 joint return with both spouses claimable: no South Carolina young child deduction" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: SC + output: + sc_young_child_deduction: 0 + +- name: "2025 joint return with neither spouse claimable (control): South Carolina young child deduction" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: SC + output: + # One dependent under 6 x $4,930 (2025). + sc_young_child_deduction: 4930 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.yaml index 23771760581..5c8be843c2e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.yaml @@ -63,3 +63,166 @@ output: # 2025 dependent exemption amount is $4,930; 3 * $4,930 = $14,790. sc_dependent_exemption: 14_790 + +- name: "2025 single filer who can be claimed as a dependent: no South Carolina dependent exemption" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: SC + output: + # S.C. Code 12-6-1140(13) allows the exemption for dependents claimed + # under IRC 151 and 152; IRC 152(b)(1) leaves a claimable filer none. + sc_dependent_exemption: 0 + +- name: "2025 joint return with one claimable spouse: no South Carolina dependent exemption (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: SC + output: + # Either spouse being claimable leaves the joint return no + # dependents (Publication 501, Dependent Taxpayer Test). + sc_dependent_exemption: 0 + +- name: "2025 joint return with one claimable spouse: no South Carolina dependent exemption (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: SC + output: + sc_dependent_exemption: 0 + +- name: "2025 joint return with both spouses claimable: no South Carolina dependent exemption" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: SC + output: + sc_dependent_exemption: 0 + +- name: "2025 joint return with neither spouse claimable (control): South Carolina dependent exemption" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: SC + output: + # One dependent x $4,930 (2025). + sc_dependent_exemption: 4930 + +- name: 2025 single filer who can be claimed but whose would-be claimant meets the filing exception keeps the South Carolina dependent exemption + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + dependent_claimant_filing_exception: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: SC + output: + # Publication 501, Dependent Taxpayer Test: the bar does not apply when + # the would-be claimant is not required to file and files only for a + # refund. One child aged 4: $4,930 each. + sc_dependent_exemption: 4930 + sc_young_child_deduction: 4930 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.yaml new file mode 100644 index 00000000000..8b4660f1127 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.yaml @@ -0,0 +1,136 @@ +- name: "2025 single filer who can be claimed as a dependent: no Utah at-home parent credit" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 2000 + claimed_as_dependent_on_another_return: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [filer, newborn] + households: + household: + members: [filer, newborn] + state_code: UT + output: + # Utah Code 59-10-1005: the parent must claim the qualifying child as a + # dependent; under IRC 152(b)(1) a claimable filer has no dependents. + ut_at_home_parent_credit_potential: 0 + +- name: "2025 joint return with one claimable spouse: no Utah at-home parent credit (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 2000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 24 + employment_income: 4000 + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_a, person_b, newborn] + filing_status: JOINT + households: + household: + members: [person_a, person_b, newborn] + state_code: UT + output: + # Either spouse being claimable leaves the joint return no dependents. + ut_at_home_parent_credit_potential: 0 + +- name: "2025 joint return with one claimable spouse: no Utah at-home parent credit (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 24 + employment_income: 4000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 2000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_b, person_a, newborn] + filing_status: JOINT + households: + household: + members: [person_b, person_a, newborn] + state_code: UT + output: + ut_at_home_parent_credit_potential: 0 + +- name: "2025 joint return with both spouses claimable: no Utah at-home parent credit" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 2000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 24 + employment_income: 4000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_a, person_b, newborn] + filing_status: JOINT + households: + household: + members: [person_a, person_b, newborn] + state_code: UT + output: + ut_at_home_parent_credit_potential: 0 + +- name: "2025 joint return with neither spouse claimable (control): Utah at-home parent credit" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 2000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 24 + employment_income: 4000 + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_a, person_b, newborn] + filing_status: JOINT + households: + household: + members: [person_a, person_b, newborn] + state_code: UT + output: + # One child under 1 x $100 x one parent earning $3,000 or less + # (the other parent earns $4,000); AGI $6,000 is under $50,000. + ut_at_home_parent_credit_potential: 100 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_personal_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_personal_exemption.yaml index 3e278e3e5a2..c9f16e5b851 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_personal_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_personal_exemption.yaml @@ -33,3 +33,143 @@ state_code: UT output: ut_personal_exemption: 5_406 # 1802 * 3 + +- name: "2025 single filer who can be claimed as a dependent: no Utah additional dependent for a child born this year" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [filer, newborn] + households: + household: + members: [filer, newborn] + state_code: UT + output: + # The birth-year dependent (59-10-1018(1)(g)) must also be a dependent + # of the return; IRC 152(b)(1) leaves a claimable filer none. + ut_personal_exemption_additional_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with one claimable spouse: no Utah additional dependent for a child born this year (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_a, person_b, newborn] + filing_status: JOINT + households: + household: + members: [person_a, person_b, newborn] + state_code: UT + output: + ut_personal_exemption_additional_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with one claimable spouse: no Utah additional dependent for a child born this year (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_b, person_a, newborn] + filing_status: JOINT + households: + household: + members: [person_b, person_a, newborn] + state_code: UT + output: + ut_personal_exemption_additional_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with both spouses claimable: no Utah additional dependent for a child born this year" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_a, person_b, newborn] + filing_status: JOINT + households: + household: + members: [person_a, person_b, newborn] + state_code: UT + output: + ut_personal_exemption_additional_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with neither spouse claimable (control): Utah additional dependent for a child born this year" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + newborn: + age: 0 + tax_units: + tax_unit: + members: [person_a, person_b, newborn] + filing_status: JOINT + households: + household: + members: [person_a, person_b, newborn] + state_code: UT + output: + # (1 dependent + 1 additional dependent) x $2,111 = $4,222. + ut_personal_exemption_additional_dependents: 1 + ut_personal_exemption: 4222 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_total_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_total_dependents.yaml new file mode 100644 index 00000000000..c4366419ade --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_total_dependents.yaml @@ -0,0 +1,166 @@ +- name: "2025 single filer who can be claimed as a dependent: no Utah dependents for the taxpayer credit" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + child: + age: 2 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: UT + output: + # Utah Code 59-10-1018 counts dependents for whom the claimant is + # entitled to an IRC 24 credit; under IRC 152(b)(1) a claimable filer + # has no dependents. + ut_total_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with one claimable spouse: no Utah dependents for the taxpayer credit (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: UT + output: + # Either spouse being claimable leaves the joint return no dependents. + ut_total_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with one claimable spouse: no Utah dependents for the taxpayer credit (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: UT + output: + ut_total_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with both spouses claimable: no Utah dependents for the taxpayer credit" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: UT + output: + ut_total_dependents: 0 + ut_personal_exemption: 0 + +- name: "2025 joint return with neither spouse claimable (control): Utah dependents for the taxpayer credit" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: UT + output: + # One dependent x $2,111 (2025). + ut_total_dependents: 1 + ut_personal_exemption: 2111 + +- name: 2025 single filer whose would-be claimant meets the filing exception keeps Utah dependents + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + dependent_claimant_filing_exception: true + child: + age: 2 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: UT + output: + # The federal credit for the child is allowed (Dependent Taxpayer Test + # exception), so the child is a qualifying dependent: 1 x $2,111. + ut_total_dependents: 1 + ut_personal_exemption: 2111 diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml index bb540781e29..4c1e16314ad 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml @@ -145,3 +145,170 @@ state_code: VT output: vt_renter_credit: 1_692 + +- name: "2025 single filer who can be claimed as a dependent: Vermont renter credit needs a claimant who is not claimed as a dependent and sizes rent by personal exemptions" + period: 2025 + input: + people: + filer: + age: 30 + employment_income: 1000 + rent: 12000 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + # 32 V.S.A. 6066(c)(2): the claimant may not be a person claimed as a + # dependent by any taxpayer. + vt_renter_credit: 0 + +- name: "2025 joint return with one claimable spouse: Vermont renter credit needs a claimant who is not claimed as a dependent and sizes rent by personal exemptions (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 500 + rent: 6000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 500 + rent: 6000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + # The unclaimed spouse is the claimant. 6066(b)(2) sizes fair market + # rent by the number of personal exemptions: one. Income $1,000 is + # below the full-credit limit: round($1,476 x 12 x 10%) = $1,771. + vt_renter_credit: 1771 + +- name: "2025 joint return with one claimable spouse: Vermont renter credit needs a claimant who is not claimed as a dependent and sizes rent by personal exemptions (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 30 + employment_income: 500 + rent: 6000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 30 + employment_income: 500 + rent: 6000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + vt_renter_credit: 1771 + +- name: "2025 joint return with both spouses claimable: Vermont renter credit needs a claimant who is not claimed as a dependent and sizes rent by personal exemptions" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 500 + rent: 6000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 500 + rent: 6000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + vt_renter_credit: 0 + +- name: "2025 joint return with neither spouse claimable: Vermont renter credit needs a claimant who is not claimed as a dependent and sizes rent by personal exemptions" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 500 + rent: 6000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 500 + rent: 6000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + # Two exemptions: round($1,936 x 12 x 10%) = $2,323. + vt_renter_credit: 2323 + +- name: 2025 single renter who can be claimed but is not claimed keeps the Vermont renter credit + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 1000 + rent: 12000 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + # 6066(c)(2) bars a claimant "claimed as a dependent", an actual claim. + # A filer who can be claimed has no personal exemption; the fair market + # rent table starts at one exemption, so the model uses it: + # round($1,476 x 12 x 10%) = $1,771. + vt_renter_credit: 1771 diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/vt_ctc.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/vt_ctc.yaml index 757e825b1e3..d741ea0fb98 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/vt_ctc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/vt_ctc.yaml @@ -30,27 +30,57 @@ - name: Fully phases out at $175,000. 1000-20*(175000-125000)/1000 = 0 period: 2023 input: - state_code: VT - age: 5 - adjusted_gross_income: 175_000 + people: + parent: + age: 35 + child1: + age: 5 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 175_000 + households: + household: + members: [parent, child1] + state_code: VT output: vt_ctc: 0 - name: Check we're not creating a negative value. period: 2023 input: - state_code: VT - age: 5 - adjusted_gross_income: 200_000 + people: + parent: + age: 35 + child1: + age: 5 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 200_000 + households: + household: + members: [parent, child1] + state_code: VT output: vt_ctc: 0 - name: Halfway phased out at $150,000. 1000-20*(150000-125000)/1000 = 500 period: 2023 input: - state_code: VT - age: 5 - adjusted_gross_income: 150_000 + people: + parent: + age: 35 + child1: + age: 5 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 150_000 + households: + household: + members: [parent, child1] + state_code: VT output: vt_ctc: 500 @@ -58,35 +88,234 @@ - name: S.51 - CTC includes 6-year-olds starting 2025 period: 2025 input: - state_code: VT - age: 6 - adjusted_gross_income: 50_000 + people: + parent: + age: 35 + child1: + age: 6 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 50_000 + households: + household: + members: [parent, child1] + state_code: VT output: vt_ctc: 1_000 - name: S.51 - CTC still applies to 5-year-olds in 2025 period: 2025 input: - state_code: VT - age: 5 - adjusted_gross_income: 50_000 + people: + parent: + age: 35 + child1: + age: 5 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 50_000 + households: + household: + members: [parent, child1] + state_code: VT output: vt_ctc: 1_000 - name: S.51 - CTC does not apply to 7-year-olds in 2025 period: 2025 input: - state_code: VT - age: 7 - adjusted_gross_income: 50_000 + people: + parent: + age: 35 + child1: + age: 7 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 50_000 + households: + household: + members: [parent, child1] + state_code: VT output: vt_ctc: 0 - name: S.51 - CTC did not include 6-year-olds before 2025 period: 2024 input: - state_code: VT - age: 6 - adjusted_gross_income: 50_000 + people: + parent: + age: 35 + child1: + age: 6 + tax_units: + tax_unit: + members: [parent, child1] + adjusted_gross_income: 50_000 + households: + household: + members: [parent, child1] + state_code: VT + output: + vt_ctc: 0 + +- name: "2025 single filer who can be claimed as a dependent: no Vermont child tax credit" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 2000 + claimed_as_dependent_on_another_return: true + child: + age: 2 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: VT + output: + # 32 V.S.A. 5830f requires entitlement to the federal child tax credit + # (waiving only the identification-number rules); IRC 152(b)(1) leaves + # a claimable filer no dependents and so no federal credit. + vt_ctc: 0 + +- name: "2025 joint return with one claimable spouse: no Vermont child tax credit (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 1000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 1000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: VT + output: + # Either spouse being claimable leaves the joint return no dependents. + vt_ctc: 0 + +- name: "2025 joint return with one claimable spouse: no Vermont child tax credit (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 1000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 1000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: VT + output: + vt_ctc: 0 + +- name: "2025 joint return with both spouses claimable: no Vermont child tax credit" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 1000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 1000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: VT + output: + vt_ctc: 0 + +- name: "2025 joint return with neither spouse claimable (control): Vermont child tax credit" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 1000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 1000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 2 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: VT + output: + # One qualifying child aged 6 or under x $1,000; AGI $2,000 is below + # the $125,000 phase-out start. + vt_ctc: 1000 + +- name: 2025 a young household member who is not a dependent does not count for the Vermont child tax credit + period: 2025 + input: + people: + parent: + age: 30 + employment_income: 2000 + sibling: + age: 5 + is_tax_unit_dependent: false + tax_units: + tax_unit: + members: [parent, sibling] + households: + household: + members: [parent, sibling] + state_code: VT output: + # 5830f counts qualifying children under IRC 152(c), who are dependents. vt_ctc: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.yaml index 584f51e0e02..da3557099bb 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.yaml @@ -4,6 +4,7 @@ filing_status: JOINT head_is_dependent_elsewhere: true spouse_is_dependent_elsewhere: true + head_or_spouse_is_dependent_elsewhere: true tax_unit_count_dependents: 0 state_code: VT output: @@ -15,10 +16,12 @@ filing_status: JOINT head_is_dependent_elsewhere: true spouse_is_dependent_elsewhere: true + head_or_spouse_is_dependent_elsewhere: true tax_unit_count_dependents: 2 state_code: VT output: - vt_personal_exemptions: 9_000 # 2*4500 + # IRC 152(b)(1): a return with a claimable filer has no dependents. + vt_personal_exemptions: 0 - name: "Joint household, one claimable elsewhere, with no dependents" period: 2022 @@ -26,6 +29,7 @@ filing_status: JOINT head_is_dependent_elsewhere: true spouse_is_dependent_elsewhere: false + head_or_spouse_is_dependent_elsewhere: true tax_unit_count_dependents: 0 state_code: VT output: @@ -37,10 +41,12 @@ filing_status: JOINT head_is_dependent_elsewhere: true spouse_is_dependent_elsewhere: false + head_or_spouse_is_dependent_elsewhere: true tax_unit_count_dependents: 2 state_code: VT output: - vt_personal_exemptions: 13_500 # 3*4500 + # The unclaimed spouse only; IRC 152(b)(1) leaves the return no dependents. + vt_personal_exemptions: 4_500 # 1*4500 - name: "Joint household, neither claimable elsewhere, with no dependents" period: 2022 @@ -69,6 +75,7 @@ input: filing_status: SINGLE head_is_dependent_elsewhere: true + head_or_spouse_is_dependent_elsewhere: true tax_unit_count_dependents: 0 state_code: VT output: @@ -79,10 +86,12 @@ input: filing_status: SINGLE head_is_dependent_elsewhere: true + head_or_spouse_is_dependent_elsewhere: true tax_unit_count_dependents: 2 state_code: VT output: - vt_personal_exemptions: 9_000 # 2*4500 + # IRC 152(b)(1): a filer who can be claimed has no dependents. + vt_personal_exemptions: 0 - name: "Single household, not claimable elsewhere, with no dependents" period: 2022 @@ -143,3 +152,163 @@ state_code: VT output: vt_personal_exemptions: 13_050 # 3*4350 + +- name: "2025 single filer who can be claimed as a dependent: Vermont personal exemptions count only filers who cannot be claimed and, when none can be, the dependents" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: VT + output: + # 32 V.S.A. 5811(21)(C): no exemption for a dependent individual, and + # IRC 152(b)(1) leaves a claimable filer no dependents. + vt_personal_exemptions: 0 + +- name: "2025 joint return with one claimable spouse: Vermont personal exemptions count only filers who cannot be claimed and, when none can be, the dependents (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: VT + output: + # The unclaimed spouse's exemption only: 1 x $5,300. + vt_personal_exemptions: 5300 + +- name: "2025 joint return with one claimable spouse: Vermont personal exemptions count only filers who cannot be claimed and, when none can be, the dependents (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: VT + output: + vt_personal_exemptions: 5300 + +- name: "2025 joint return with both spouses claimable: Vermont personal exemptions count only filers who cannot be claimed and, when none can be, the dependents" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: VT + output: + vt_personal_exemptions: 0 + +- name: "2025 joint return with neither spouse claimable: Vermont personal exemptions count only filers who cannot be claimed and, when none can be, the dependents" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: VT + output: + # 3 exemptions x $5,300 (2025). + vt_personal_exemptions: 15900 + +- name: 2025 single filer who can be claimed but whose would-be claimant meets the filing exception gets Vermont dependent exemptions only + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 8000 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + dependent_claimant_filing_exception: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: VT + output: + # No own exemption (the filer can be claimed); the child counts because + # the Dependent Taxpayer Test does not apply: 1 x $5,300. + vt_personal_exemptions: 5300 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_additional_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_additional_exemption.yaml index c97d3f5d799..6677c4c2e6f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_additional_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_additional_exemption.yaml @@ -1,25 +1,291 @@ - name: Aged head period: 2022 input: - age_head: 65 - state_code: WI + people: + head: + age: 65 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: WI output: wi_additional_exemption: 250 - name: Aged head and aged spouse period: 2022 input: - age_head: 65 - age_spouse: 65 - state_code: WI + people: + head: + age: 65 + spouse: + age: 65 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI output: wi_additional_exemption: 500 - name: No aged head or aged spouse period: 2022 input: - age_head: 64 - age_spouse: 64 - state_code: WI + people: + head: + age: 64 + spouse: + age: 64 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI output: wi_additional_exemption: 0 + +- name: "2025 single filer who can be claimed as a dependent: Wisconsin $250 exemption for a filer aged 65 or older who cannot be claimed" + period: 2025 + input: + people: + filer: + age: 65 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + # Form 1 instructions, line 10b: no $250 exemption for a filer whom + # someone else can claim as a dependent. + wi_additional_exemption: 0 + +- name: "2025 joint return with one claimable spouse: Wisconsin $250 exemption for a filer aged 65 or older who cannot be claimed (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 65 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + # Each spouse is tested separately: only the unclaimed spouse counts. + wi_additional_exemption: 250 + +- name: "2025 joint return with one claimable spouse: Wisconsin $250 exemption for a filer aged 65 or older who cannot be claimed (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 65 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 65 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: WI + output: + wi_additional_exemption: 250 + +- name: "2025 joint return with both spouses claimable: Wisconsin $250 exemption for a filer aged 65 or older who cannot be claimed" + period: 2025 + input: + people: + person_a: + age: 65 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + wi_additional_exemption: 0 + +- name: "2025 joint return with neither spouse claimable: Wisconsin $250 exemption for a filer aged 65 or older who cannot be claimed" + period: 2025 + input: + people: + person_a: + age: 65 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + # Two filers aged 65 or older x $250. + wi_additional_exemption: 500 + +- name: "2025 joint return where only the claimable spouse is 65 or older: no Wisconsin $250 exemption (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 65 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 40 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + wi_additional_exemption: 0 + +- name: "2025 joint return where only the claimable spouse is 65 or older: no Wisconsin $250 exemption (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 40 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 65 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: WI + output: + wi_additional_exemption: 0 + +- name: 2025 single filer aged 65 who can be claimed but is not claimed gets no Wisconsin $250 exemption + period: 2025 + input: + people: + filer: + age: 65 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + # Line 10b requires the $700 exemption on line 10a, which a filer who + # can be claimed does not get whether or not anyone claims them. + wi_additional_exemption: 0 + +- name: 2025 joint return where one spouse aged 65 can be claimed but is not (the claimable spouse is the head) + period: 2025 + input: + people: + person_a: + age: 65 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 65 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + # Only the spouse who cannot be claimed gets $250. + wi_additional_exemption: 250 + +- name: 2025 joint return where one spouse aged 65 can be claimed but is not (the claimable spouse is the spouse) + period: 2025 + input: + people: + person_b: + age: 65 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 65 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: WI + output: + wi_additional_exemption: 250 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml index 11635a6391e..13e882d98cc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml @@ -27,3 +27,41 @@ state_code: WI output: wi_capital_gain_loss_subtraction: 0 + +- name: Directly reported capital gain distributions receive the long term subtraction + absolute_error_margin: 0.01 + period: 2025 + input: + non_sch_d_capital_gains: 1000 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 300 + +- name: Capital gain distribution exclusion is limited by net gain after short term losses + absolute_error_margin: 0.01 + period: 2026 + input: + non_sch_d_capital_gains: 1000 + short_term_capital_gains: -800 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 60 + +- name: Capital gain distributions do not create a subtraction for a net capital loss + absolute_error_margin: 0.01 + period: 2026 + input: + non_sch_d_capital_gains: 1000 + short_term_capital_gains: -1200 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 0 + +- name: Direct capital gain distribution treatment also applies before Act 15 + absolute_error_margin: 0.01 + period: 2024 + input: + non_sch_d_capital_gains: 1000 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 300 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_exemption.yaml index 96a1ec8eddb..2efa15ea6b6 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_exemption.yaml @@ -1,16 +1,32 @@ - name: WI exemption unit test 1 period: 2021 input: - exemptions_count: 5 - state_code: WI + people: + head: + tax_units: + tax_unit: + members: [head] + exemptions_count: 5 + households: + household: + members: [head] + state_code: WI output: wi_exemption: 5 * 700 - name: WI exemption unit test 2 period: 2022 input: - exemptions_count: 5 - age_head: 65 - state_code: WI + people: + head: + age: 65 + tax_units: + tax_unit: + members: [head] + exemptions_count: 5 + households: + household: + members: [head] + state_code: WI output: wi_exemption: 5 * 700 + 250 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_homestead_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_homestead_income.yaml index 2cea0b82040..29a4f76df90 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_homestead_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_homestead_income.yaml @@ -99,3 +99,154 @@ # loss_ald deducts the loss of total_self_employment_income (0 here), so # the add-back follows that total and household income is the same. wi_homestead_income: 5_000 + +- name: "2025 single filer who can be claimed as a dependent: no Wisconsin homestead $500 dependent deduction" + period: 2025 + input: + people: + filer: + age: 62 + employment_income: 10000 + real_estate_taxes: 1000 + claimed_as_dependent_on_another_return: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: WI + output: + # The $500 deduction is for a dependent under IRC 152, and a claimant + # who can be claimed has none (IRC 152(b)(1)). The claimant, 62 or + # older, stays eligible: ($1,000 - ($10,000 - $8,060) x 8.785%) x 80%. + wi_homestead_income: 10000 + wi_homestead_credit: 663.6568 + +- name: "2025 joint return with one claimable spouse: no Wisconsin homestead $500 dependent deduction (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: WI + output: + # Either spouse being claimable leaves the joint return no dependents. + wi_homestead_income: 10000 + wi_homestead_credit: 663.6568 + +- name: "2025 joint return with one claimable spouse: no Wisconsin homestead $500 dependent deduction (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: WI + output: + wi_homestead_income: 10000 + wi_homestead_credit: 663.6568 + +- name: "2025 joint return with both spouses claimable: no Wisconsin homestead $500 dependent deduction" + period: 2025 + input: + people: + person_a: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: WI + output: + wi_homestead_income: 10000 + wi_homestead_credit: 663.6568 + +- name: "2025 joint return with neither spouse claimable (control): Wisconsin homestead $500 dependent deduction" + period: 2025 + input: + people: + person_a: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 62 + employment_income: 5000 + real_estate_taxes: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: WI + output: + # $10,000 - $500 = $9,500; ($1,000 - $1,440 x 8.785%) x 80% = $698.7968. + wi_homestead_income: 9500 + wi_homestead_credit: 698.7968 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml index 1a64fe83ddc..5701c4fc15f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml @@ -26,3 +26,146 @@ # Both variables must agree (#7805). wi_income_tax: 6_636.85 state_income_tax: 6_636.85 + +# Household inputs match the PolicyBench scenario conversion, including +# takeup defaults. Filing status is inferred from the supplied person facts. +- name: PolicyBench scenario 042 elects the retirement exclusion + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 79 + employment_income: 0 + bank_account_assets: 152100 + farm_income: -1127.2940673828125 + farm_operations_income: -1127.2940673828125 + is_surviving_spouse: true + is_tax_unit_head: true + is_tax_unit_spouse: false + non_qualified_dividend_income: 5920 + non_sch_d_capital_gains: 3753 + other_medical_expenses: 250 + over_the_counter_health_expenses: 100 + qualified_bdc_income: 70.93914031982422 + qualified_dividend_income: 928 + real_estate_taxes: 5010.88232421875 + social_security_dependents: 29580 + stock_assets: 670000 + tax_exempt_ira_distributions: 4700 + taxable_interest_income: 175 + taxable_ira_distributions: 19200 + taxable_private_pension_income: 4886 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit: + members: [head] + tax_units: + tax_unit: + members: [head] + first_home_mortgage_balance: 1000 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head] + takes_up_snap_if_eligible: true + families: + family: + members: [head] + households: + household: + members: [head] + state_code: WI + household_vehicles_value: 8370 + output: + state_income_tax_before_refundable_credits: 0 + wi_capital_gain_loss_subtraction: 1125.90 + # The standard path isolates the capital gain correction: 708.70 - 300. + wi_income_tax_before_credits: 708.70 + wi_non_refundable_credits: 300 + state_refundable_credits: 0 + state_income_tax: 0 + +- name: PolicyBench scenario 091 subtracts directly reported capital gain distributions + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 22 + employment_income: 27000 + attends_eligible_educational_institution_for_american_opportunity_credit: true + bank_account_assets: 558700 + employer_sponsored_insurance_premiums: 6589.27587890625 + fsla_overtime_premium: 627.906982421875 + has_american_opportunity_credit_1098_t_or_exception: true + has_american_opportunity_credit_institution_ein: true + has_esi: true + health_insurance_premiums_without_medicare_part_b: 1800 + hourly_wage: 13 + hours_worked_last_week: 42 + weekly_hours_worked_before_lsr: 42 + is_enrolled_at_least_half_time_for_american_opportunity_credit: true + is_paid_hourly: true + is_pursuing_credential_for_american_opportunity_credit: true + is_tax_unit_head: true + is_tax_unit_spouse: false + non_qualified_dividend_income: 208 + non_sch_d_capital_gains: 1170 + other_health_insurance_premiums: 1800 + over_the_counter_health_expenses: 25 + qualified_dividend_income: 3968 + roth_401k_contributions_desired: 163.44000244140625 + roth_ira_contributions_desired: 67.12319946289062 + stock_assets: 718100 + taxable_interest_income: 4806 + traditional_401k_contributions_desired: 926.1599731445312 + traditional_ira_contributions_desired: 43.276798248291016 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit: + members: [head] + tax_units: + tax_unit: + members: [head] + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head] + takes_up_snap_if_eligible: true + families: + family: + members: [head] + households: + household: + members: [head] + state_code: WI + output: + state_income_tax_before_refundable_credits: 878.52 + wi_capital_gain_loss_subtraction: 351 + state_refundable_credits: 0 + state_income_tax: 878.52 + +- name: Minimal retirement election is reflected before refundable credits + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 24000 + state_income_tax_before_refundable_credits: 0 + state_refundable_credits: 0 + state_income_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml index 2de67b8e595..6d654f20358 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml @@ -19,29 +19,34 @@ wi_income_tax_before_refundable_credits: 0 - name: WI income tax before refundable credits with retirement exclusion - # This intermediate remains the standard pre-refundable-credit tax, - # even when the exclusion path wins in wi_income_tax. - # standard_before_refundable = max(0, 5000 - 500) = 4500 + # The elected path must be reported before refundable credits as well. absolute_error_margin: 0.01 period: 2025 input: + age: 68 + taxable_private_pension_income: 30_000 + is_tax_unit_head: true wi_income_tax_before_credits: 5_000 wi_non_refundable_credits: 500 wi_retirement_income_exclusion_tax: 3_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 state_code: WI output: - wi_income_tax_before_refundable_credits: 4_500 + wi_income_tax_before_refundable_credits: 3_000 + wi_refundable_credits: 0 - name: WI income tax before refundable credits - standard path lower # standard_before_refundable = max(0, 2000 - 500) = 1500 absolute_error_margin: 0.01 period: 2025 input: + age: 68 + taxable_private_pension_income: 30_000 + is_tax_unit_head: true wi_income_tax_before_credits: 2_000 wi_non_refundable_credits: 500 wi_retirement_income_exclusion_tax: 5_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 state_code: WI output: wi_income_tax_before_refundable_credits: 1_500 @@ -50,10 +55,13 @@ absolute_error_margin: 0.01 period: 2025 input: + age: 68 + taxable_private_pension_income: 30_000 + is_tax_unit_head: true wi_income_tax_before_credits: 5_000 wi_non_refundable_credits: 500 wi_retirement_income_exclusion_tax: 3_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 state_code: WI output: wi_income_tax: 3_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml index 02452cb7d60..a182704c0ac 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml @@ -14,7 +14,7 @@ wi_income_tax_before_credits: 350 wi_non_refundable_credits: 0 wi_retirement_income_exclusion_tax: 70 - wi_refundable_credits: 0 + wi_earned_income_credit: 0 wi_retirement_income_subtraction: 0 output: wi_income_tax: 70 @@ -35,7 +35,7 @@ wi_income_tax_before_credits: 350 wi_non_refundable_credits: 0 wi_retirement_income_exclusion_tax: 280 - wi_refundable_credits: 300 + wi_earned_income_credit: 300 wi_retirement_income_subtraction: 0 output: wi_income_tax: 50 @@ -52,7 +52,7 @@ wi_taxable_income: 10_000 wi_income_tax_before_credits: 350 wi_non_refundable_credits: 0 - wi_refundable_credits: 0 + wi_earned_income_credit: 0 wi_retirement_income_subtraction: 0 output: wi_income_tax: 350 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml new file mode 100644 index 00000000000..a846c22d7d2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml @@ -0,0 +1,249 @@ +- name: Retirement election retains the standard path on a tax tie + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + wi_income_tax_before_credits: 100 + wi_non_refundable_credits: 0 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 0 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 0 + state_income_tax: 100 + +- name: Refundable credits can make the standard retirement path preferable + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + wi_income_tax_before_credits: 100 + wi_non_refundable_credits: 0 + wi_retirement_income_exclusion_tax: 0 + wi_earned_income_credit: 200 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 200 + state_income_tax: -100 + +- name: Electing retirement exclusion forfeits otherwise available section 71.07 credits + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 200 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 300 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 0 + state_income_tax: 100 + +- name: Retirement election is unavailable before the 2025 effective date + absolute_error_margin: 0.01 + period: 2024 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 200 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 300 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + state_income_tax_before_refundable_credits: 800 + state_refundable_credits: 300 + state_income_tax: 500 + +- name: The 2025 retirement election forfeits homestead and earned income credits + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 100 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 25 + wi_homestead_credit: 200 + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 0 + state_income_tax: 100 + +- name: The 2026 retirement election retains homestead but forfeits earned income credit + absolute_error_margin: 0.01 + period: 2026 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 100 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 25 + wi_homestead_credit: 200 + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 200 + state_income_tax: -100 + +- name: Single retirement exclusion cannot subtract the same pension on lines 16 and 17 + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: Pooled joint exclusion preserves the maximum remaining line 17 subtraction + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 68 + taxable_private_pension_income: 45000 + farm_operations_income: -30000 + is_tax_unit_head: true + spouse: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI + output: + adjusted_gross_income: 23000 + wi_retirement_income_subtraction: 10000 + wi_retirement_income_exclusion_amount: 48000 + # $5,000 remains after line 16; all of it can be retained on line 17. + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: Unequal spouse pensions preserve both line 17 amounts under joint pooling + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 68 + taxable_private_pension_income: 53000 + farm_operations_income: -35000 + is_tax_unit_head: true + spouse: + age: 68 + taxable_private_pension_income: 5000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI + output: + adjusted_gross_income: 23000 + wi_retirement_income_subtraction: 10000 + wi_retirement_income_exclusion_amount: 48000 + # Allocate line 16 to the head's income above the two line 17 amounts. + wi_retirement_income_exclusion_line17_offset: 0 + +- name: Younger spouse income does not enter the retirement exclusion pool + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + spouse: + age: 66 + taxable_private_pension_income: 8000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI + output: + wi_retirement_income_subtraction: 10000 + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: IRA distributions cannot be subtracted on both retirement lines + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_ira_distributions: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: 401k distributions cannot be subtracted on both retirement lines + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_401k_distributions: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: Roth conversions cannot be subtracted on both retirement lines + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_roth_conversions: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_line17_offset: 5000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml index 25e2b9b9d1c..2ec7fa9cf4d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml @@ -57,12 +57,11 @@ wi_taxable_income: 10_000 wi_retirement_income_subtraction: 3_000 output: - # Line 17 ($3k) is already subtracted in wi_taxable_income. - # Per the Line 17 worksheet step 4, Line 17 self-adjusts when - # Line 16 is claimed. We simply subtract Line 16: - # exclusion_taxinc = max(0, 10000 - 8000) = 2000 - # exclusion_tax = 2000 * 0.035 = 70 - wi_retirement_income_exclusion_tax: 70 + # Line 17 ($3k) is already subtracted in wi_taxable_income, but all of + # this pension is subtracted on Line 16. Add back the overlapping $3k: + # exclusion_taxinc = max(0, 10000 - 8000 + 3000) = 5000. + wi_retirement_income_exclusion_line17_offset: 3_000 + wi_retirement_income_exclusion_tax: 175 - name: Joint filing - both spouses eligible absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_standard_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_standard_deduction.yaml index 24ec29f0189..4aca346ed9e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_standard_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_standard_deduction.yaml @@ -97,3 +97,204 @@ # same amount through the 22.515% and 12% segments that meet at $58,827, # within $0.05 of the schedule. wi_standard_deduction: 13_960 - 0.12 * (70_000 - 20_120) + +- name: "2025 single filer who can be claimed as a dependent: Wisconsin standard deduction limited for a filer who can be claimed" + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 1000 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + # Wis. Stat. 71.05(22)(f): the lesser of the ordinary deduction and + # max($1,350, earned income + $450) = max($1,350, $1,000 + $450). + wi_standard_deduction: 1450 + +- name: "2025 joint return with one claimable spouse: Wisconsin standard deduction limited for a filer who can be claimed (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + # Form 1 worksheet: applies if you or your spouse can be claimed; the + # couple's earned income is $1,000, so $1,450. + wi_standard_deduction: 1450 + +- name: "2025 joint return with one claimable spouse: Wisconsin standard deduction limited for a filer who can be claimed (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 22 + employment_income: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 22 + employment_income: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: WI + output: + wi_standard_deduction: 1450 + +- name: "2025 joint return with both spouses claimable: Wisconsin standard deduction limited for a filer who can be claimed" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 500 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + wi_standard_deduction: 1450 + +- name: "2025 joint return with neither spouse claimable: Wisconsin standard deduction limited for a filer who can be claimed" + period: 2025 + input: + people: + person_a: + age: 22 + employment_income: 500 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 22 + employment_income: 500 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WI + output: + # Joint maximum at WI income $1,000: $25,110 (2025). + wi_standard_deduction: 25110 + +- name: 2025 single filer who cannot be claimed gets the full Wisconsin standard deduction + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 1000 + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + wi_standard_deduction: 13560 + +- name: 2025 single filer who can be claimed and has no earned income gets the Wisconsin dependent floor + period: 2025 + input: + people: + filer: + age: 20 + taxable_interest_income: 2000 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + # max($1,350, $0 + $450) = $1,350. + wi_standard_deduction: 1350 + +- name: "2021 single filer who can be claimed: Wisconsin dependent standard deduction" + period: 2021 + input: + people: + filer: + age: 20 + employment_income: 600 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + # 2021 Form 1 worksheet: max($1,100, $600 + $350) = $1,100. + wi_standard_deduction: 1100 + +- name: "2023 single filer who can be claimed: Wisconsin dependent standard deduction" + period: 2023 + input: + people: + filer: + age: 20 + employment_income: 3000 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WI + output: + # 2023 Form 1 worksheet: max($1,250, $3,000 + $400) = $3,400. + wi_standard_deduction: 3400 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.yaml index 73a49c95403..f1e5e340755 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.yaml @@ -13,3 +13,143 @@ alternative_minimum_tax: 0 output: wv_low_income_family_tax_credit_eligible: true + +- name: "2025 single filer who can be claimed as a dependent: West Virginia low-income family tax credit uses the exemptions that may be claimed" + period: 2025 + input: + people: + filer: + age: 30 + employment_income: 16000 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WV + output: + # W. Va. Code 11-21-22A: family size is the number of exemptions that + # may be legally claimed; with zero exemptions there is no credit. + wv_low_income_family_tax_credit_eligible: false + wv_low_income_family_tax_credit_potential: 0 + +- name: "2025 joint return with one claimable spouse: West Virginia low-income family tax credit uses the exemptions that may be claimed (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WV + output: + # Family size 1: 2025 FPG $15,650. Taxable income $16,000 - $2,000 = + # $14,000; tax $10,000 x 2.22% + $4,000 x 2.96% = $340.40. Income + # exceeds the FPG by $350, so the credit is 80%: $272.32. + wv_low_income_family_tax_credit_eligible: true + wv_low_income_family_tax_credit_fpg: 15650 + wv_income_tax_before_non_refundable_credits: 340.4 + wv_low_income_family_tax_credit_potential: 272.32 + +- name: "2025 joint return with one claimable spouse: West Virginia low-income family tax credit uses the exemptions that may be claimed (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: WV + output: + wv_low_income_family_tax_credit_eligible: true + wv_low_income_family_tax_credit_fpg: 15650 + wv_income_tax_before_non_refundable_credits: 340.4 + wv_low_income_family_tax_credit_potential: 272.32 + +- name: "2025 joint return with both spouses claimable: West Virginia low-income family tax credit uses the exemptions that may be claimed" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WV + output: + wv_low_income_family_tax_credit_eligible: false + wv_low_income_family_tax_credit_potential: 0 + +- name: "2025 joint return with neither spouse claimable: West Virginia low-income family tax credit uses the exemptions that may be claimed" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WV + output: + # Family size 2: $15,650 + $5,500 = $21,150. Taxable income $12,000; + # tax $222 + $2,000 x 2.96% = $281.20; income below the FPG: 100%. + wv_low_income_family_tax_credit_eligible: true + wv_low_income_family_tax_credit_fpg: 21150 + wv_income_tax_before_non_refundable_credits: 281.2 + wv_low_income_family_tax_credit_potential: 281.2 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/exemptions/wv_personal_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/exemptions/wv_personal_exemption.yaml index a41435eb495..557b240cfa2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/exemptions/wv_personal_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/exemptions/wv_personal_exemption.yaml @@ -28,3 +28,238 @@ tax_unit_size: 2 output: wv_personal_exemption: 4_000 + +- name: "2025 single filer who can be claimed as a dependent: West Virginia personal exemption counts only exemptions that may be claimed" + period: 2025 + input: + people: + filer: + age: 30 + employment_income: 16000 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: WV + output: + # W. Va. Code 11-21-16 and IRC 151(d)(2): no exemption for a filer who + # can be claimed; a return with no exemptions takes $500 instead. + wv_personal_exemption: 500 + +- name: "2025 joint return with one claimable spouse: West Virginia personal exemption counts only exemptions that may be claimed (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WV + output: + # One exemption for the spouse who cannot be claimed: $2,000. + wv_personal_exemption: 2000 + +- name: "2025 joint return with one claimable spouse: West Virginia personal exemption counts only exemptions that may be claimed (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_b, person_a] + filing_status: JOINT + households: + household: + members: [person_b, person_a] + state_code: WV + output: + wv_personal_exemption: 2000 + +- name: "2025 joint return with both spouses claimable: West Virginia personal exemption counts only exemptions that may be claimed" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WV + output: + # No exemptions on the return: the $500 fallback, once per return. + wv_personal_exemption: 500 + +- name: "2025 joint return with neither spouse claimable: West Virginia personal exemption counts only exemptions that may be claimed" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person_a, person_b] + filing_status: JOINT + households: + household: + members: [person_a, person_b] + state_code: WV + output: + # Two exemptions x $2,000. + wv_personal_exemption: 4000 + +- name: "2025 joint return with one claimable spouse and a child: West Virginia personal exemption has no dependent exemption (the claimable spouse is the head)" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: WV + output: + # IRC 152(b)(1): either spouse being claimable leaves the return no + # dependents; one exemption x $2,000 (the control below gets $6,000). + wv_personal_exemption: 2000 + +- name: "2025 joint return with one claimable spouse and a child: West Virginia personal exemption has no dependent exemption (the claimable spouse is the spouse)" + period: 2025 + input: + people: + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_a: + age: 30 + employment_income: 8000 + claimed_as_dependent_on_another_return: true + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_b, person_a, child] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child] + state_code: WV + output: + wv_personal_exemption: 2000 + +- name: "2025 joint return with neither spouse claimable and a child: West Virginia personal exemption" + period: 2025 + input: + people: + person_a: + age: 30 + employment_income: 8000 + is_tax_unit_head: true + is_tax_unit_spouse: false + person_b: + age: 30 + employment_income: 8000 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 4 + tax_units: + tax_unit: + members: [person_a, person_b, child] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child] + state_code: WV + output: + wv_personal_exemption: 6000 + +- name: 2025 single filer who can be claimed but whose would-be claimant meets the filing exception gets a West Virginia dependent exemption + period: 2025 + input: + people: + filer: + age: 22 + employment_income: 16000 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + dependent_claimant_filing_exception: true + child: + age: 4 + tax_units: + tax_unit: + members: [filer, child] + households: + household: + members: [filer, child] + state_code: WV + output: + # No own exemption; one dependent exemption x $2,000, so no $500. + wv_personal_exemption: 2000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml index 8d900252b8a..071d8ec0c16 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml @@ -34,3 +34,213 @@ adjusted_gross_income_person: 20_000 output: wv_senior_citizen_disability_deduction_person: 0 + +# Schedule M line 47, box (c), excludes lines 35-46 before the $8,000 +# cap; box (d) then subtracts lines 29-34. Sources: 2025 IT-140 PDF +# pages 5-6 and 29-30; 2024 PDF pages 5-6 and 29; 2018 PDF page 5; +# 2014 PDF page 9. +- name: A senior with a taxable state refund subtracts no more than own income. + period: 2025 + input: + age: 66 + employment_income: 4_000 + salt_refund_income: 2_000 + state_code: WV + output: + adjusted_gross_income_person: 6_000 + # Box (c) = min(6,000 - 2,000, 8,000) = 4,000; box (d) = 0. + wv_senior_citizen_disability_deduction_person: 4_000 + wv_subtractions: 6_000 + wv_agi: 0 + +- name: The 2024 line 49 excludes taxable state refunds from box c. + period: 2024 + input: + age: 66 + adjusted_gross_income_person: 6_000 + salt_refund_income: 2_000 + state_code: WV + output: + # min(6,000 - 2,000, 8,000) - 0 = 4,000. + wv_senior_citizen_disability_deduction_person: 4_000 + +- name: The 2018 form already excludes refunds before capping remaining income. + period: 2018 + input: + wv_senior_citizen_disability_deduction_eligible_person: true + adjusted_gross_income_person: 6_000 + salt_refund_income: 2_000 + wv_senior_citizen_disability_deduction_total_modifications: 0 + output: + # min(6,000 - 2,000, 8,000) - 0 = 4,000. + wv_senior_citizen_disability_deduction_person: 4_000 + +- name: The 2014 worksheet excludes refunds from available income. + period: 2014 + input: + wv_senior_citizen_disability_deduction_eligible_person: true + adjusted_gross_income_person: 6_000 + salt_refund_income: 2_000 + wv_senior_citizen_disability_deduction_total_modifications: 1_000 + output: + # Available income = 6,000 - 2,000 - 1,000 = 3,000. + # Remaining allowance = 8,000 - 1,000 = 7,000; min = 3,000. + wv_senior_citizen_disability_deduction_person: 3_000 + +- name: Only the refund recipient's box c is reduced on a joint return. + period: 2025 + input: + people: + head: + age: 66 + employment_income: 4_000 + salt_refund_income: 2_000 + spouse: + age: 68 + employment_income: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: WV + output: + # Head: min(6,000 - 2,000, 8,000); spouse: min(6,000, 8,000). + wv_senior_citizen_disability_deduction_person: [4_000, 6_000] + wv_senior_citizen_disability_deduction: 10_000 + wv_subtractions: 12_000 + wv_agi: 0 + +- name: Only the military pay recipient's box c is reduced on a joint return. + period: 2025 + input: + people: + head: + age: 66 + adjusted_gross_income_person: 6_000 + spouse: + age: 68 + adjusted_gross_income_person: 6_000 + military_service_income: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: WV + output: + # Head: min(6,000, 8,000); spouse: min(6,000 - 2,000, 8,000). + wv_senior_citizen_disability_deduction_person: [6_000, 4_000] + wv_senior_citizen_disability_deduction: 10_000 + wv_subtractions: 12_000 + +- name: A line 39 contribution reduces only the contributor's remaining income. + period: 2025 + input: + people: + head: + age: 66 + adjusted_gross_income_person: 6_000 + investment_in_529_plan_indv: 2_000 + spouse: + age: 68 + adjusted_gross_income_person: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: WV + output: + # The contribution deducts 2,000 on a line within the excluded range. + # Head: min(6,000 - 2,000, 8,000); spouse: min(6,000, 8,000). + wv_529_plan_deduction: 2_000 + wv_senior_citizen_disability_deduction_person: [4_000, 6_000] + wv_subtractions: 12_000 + +- name: Income still above the cap after exclusions leaves the modification unchanged. + period: 2025 + input: + age: 66 + adjusted_gross_income_person: 20_000 + military_service_income: 2_000 + salt_refund_income: 1_000 + investment_in_529_plan_indv: 1_000 + wv_senior_citizen_disability_deduction_total_modifications: 3_000 + state_code: WV + output: + # min(20,000 - 4,000, 8,000) - 3,000 = 5,000, as before. + wv_senior_citizen_disability_deduction_person: 5_000 + +- name: Box d is subtracted after excluding refunds and applying the cap. + period: 2025 + input: + age: 66 + adjusted_gross_income_person: 10_000 + salt_refund_income: 3_000 + us_govt_interest_person: 2_000 + state_code: WV + output: + wv_senior_citizen_disability_deduction_total_modifications: 2_000 + # min(10,000 - 3,000, 8,000) - 2,000 = 5,000. + wv_senior_citizen_disability_deduction_person: 5_000 + wv_subtractions: 10_000 + +- name: Remaining income exactly at the cap is reduced by box d. + period: 2025 + input: + age: 66 + adjusted_gross_income_person: 10_000 + salt_refund_income: 2_000 + us_govt_interest_person: 1_000 + state_code: WV + output: + # min(10,000 - 2,000, 8,000) - 1,000 = 7,000. + wv_senior_citizen_disability_deduction_person: 7_000 + +- name: Exclusions exceeding AGI leave no senior modification. + period: 2025 + input: + age: 66 + adjusted_gross_income_person: 1_000 + salt_refund_income: 2_000 + state_code: WV + output: + # max(0, min(1,000 - 2,000, 8,000)) = 0. + wv_senior_citizen_disability_deduction_person: 0 + +- name: Negative AGI leaves no senior modification. + period: 2025 + input: + age: 66 + adjusted_gross_income_person: -1_000 + salt_refund_income: 2_000 + state_code: WV + output: + wv_senior_citizen_disability_deduction_person: 0 + +- name: A qualifying disabled person also excludes a line 39 contribution. + period: 2025 + input: + age: 50 + is_permanently_and_totally_disabled: true + adjusted_gross_income_person: 6_000 + investment_in_529_plan_indv: 1_000 + state_code: WV + output: + # min(6,000 - 1,000, 8,000) - 0 = 5,000. + wv_senior_citizen_disability_deduction_person: 5_000 + wv_529_plan_deduction: 1_000 + wv_subtractions: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/territories/pr/tax/income/taxable_income/deductions/retirement/pr_retirement_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/territories/pr/tax/income/taxable_income/deductions/retirement/pr_retirement_deduction.yaml index 214810e1891..a3318097b84 100644 --- a/policyengine_us/tests/policy/baseline/gov/territories/pr/tax/income/taxable_income/deductions/retirement/pr_retirement_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/territories/pr/tax/income/taxable_income/deductions/retirement/pr_retirement_deduction.yaml @@ -1,7 +1,10 @@ +# IRA contributions cannot exceed compensation (26 U.S.C. 219(b)(1)(B)), so each +# case gives the filer wages equal to their Puerto Rico AGI. - name: Contributions are higher than maximum deduction period: 2024 input: pr_agi_person: 10_000 + employment_income: 10_000 traditional_ira_contributions_desired: 3_001 roth_ira_contributions_desired: 2_000 state_code: PR @@ -12,6 +15,7 @@ period: 2024 input: pr_agi_person: 1_300 + employment_income: 1_300 traditional_ira_contributions_desired: 500 roth_ira_contributions_desired: 300 state_code: PR @@ -22,6 +26,7 @@ period: 2024 input: pr_agi_person: 4_000 + employment_income: 4_000 traditional_ira_contributions_desired: 500 roth_ira_contributions_desired: 301 state_code: PR @@ -32,6 +37,7 @@ period: 2024 input: pr_agi_person: 4_000 + employment_income: 4_000 traditional_ira_contributions_desired: 5000 roth_ira_contributions_desired: 3000 state_code: PR diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_compensation.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_compensation.yaml new file mode 100644 index 00000000000..6886c1f5115 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_compensation.yaml @@ -0,0 +1,171 @@ +- name: IRA contributions require compensation. + period: 2026 + input: + age: 40 + traditional_ira_contributions_desired: 1_000 + output: + ira_contribution_limit: 0 + traditional_ira_contributions: 0 + +- name: Compensation limits combined traditional and Roth contributions proportionally. + period: 2026 + input: + age: 40 + employment_income: 2_000 + traditional_ira_contributions_desired: 4_000 + roth_ira_contributions_desired: 1_000 + output: + ira_compensation: 2_000 + ira_contribution_limit: 2_000 + ira_contribution_scale: 0.4 + traditional_ira_contributions: 1_600 + roth_ira_contributions: 400 + +- name: Compensation uses wages after payroll deductions and disregards net self-employment losses. + period: 2026 + input: + employment_income: 3_000 + pre_tax_contributions: 1_000 + self_employment_income: -1_000 + output: + ira_compensation: 2_000 + ira_contribution_limit: 2_000 + +- name: Compensation includes all modeled net self-employment sources and deducts retirement and SE tax adjustments. + period: 2026 + input: + self_employment_income: 5_000 + sstb_self_employment_income: 1_000 + farm_operations_income: 2_000 + partnership_self_employment_net_earnings: 3_000 + self_employment_tax_ald_person: 1_000 + self_employed_pension_contribution_ald_person: 2_000 + output: + ira_compensation: 8_000 + +- name: Taxable alimony under an earlier divorce instrument is compensation. + period: 2026 + input: + alimony_income: 2_500 + divorce_year: 2018 + traditional_ira_contributions_desired: 4_000 + output: + ira_compensation: 2_500 + ira_contribution_limit: 2_500 + traditional_ira_contributions: 2_500 + +- name: Nontaxable alimony under a later divorce instrument is excluded from compensation. + period: 2026 + input: + alimony_income: 2_500 + divorce_year: 2020 + traditional_ira_contributions_desired: 4_000 + output: + ira_compensation: 0 + ira_contribution_limit: 0 + traditional_ira_contributions: 0 + +- name: Pension, dividend and rental income are excluded from IRA compensation. + period: 2026 + input: + pension_income: 50_000 + dividend_income: 10_000 + rental_income: 10_000 + traditional_ira_contributions_desired: 4_000 + output: + ira_compensation: 0 + ira_contribution_limit: 0 + traditional_ira_contributions: 0 + +- name: Positive desired IRA contributions below one dollar retain the correct scale. + period: 2026 + absolute_error_margin: 0.000001 + input: + employment_income: 0.5 + traditional_ira_contributions_desired: 0.25 + roth_ira_contributions_desired: 0.25 + output: + ira_compensation: 0.5 + ira_contribution_limit: 0.5 + ira_contribution_scale: 1 + traditional_ira_contributions: 0.25 + roth_ira_contributions: 0.25 + +- name: Zero desired IRA contributions use a finite scale. + period: 2026 + input: + employment_income: 5_000 + traditional_ira_contributions_desired: 0 + roth_ira_contributions_desired: 0 + output: + ira_contribution_limit: 5_000 + ira_contribution_scale: 1 + traditional_ira_contributions: 0 + roth_ira_contributions: 0 + +- name: The lower-compensation joint spouse uses compensation remaining after the other spouse's combined IRA contributions. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 10_000 + traditional_ira_contributions_desired: 6_000 + roth_ira_contributions_desired: 1_000 + spouse: + age: 40 + employment_income: 1_000 + traditional_ira_contributions_desired: 7_500 + tax_units: + tax_unit: + members: [head, spouse] + tax_unit_is_joint: true + output: + ira_contribution_limit: [7_500, 4_000] + traditional_ira_contributions: [6_000, 4_000] + roth_ira_contributions: [1_000, 0] + +- name: Married spouses filing separately cannot share IRA compensation. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 10_000 + traditional_ira_contributions_desired: 6_000 + roth_ira_contributions_desired: 1_000 + spouse: + age: 40 + employment_income: 1_000 + traditional_ira_contributions_desired: 7_500 + tax_units: + tax_unit: + members: [head, spouse] + tax_unit_is_joint: false + output: + ira_contribution_limit: [7_500, 1_000] + traditional_ira_contributions: [6_000, 1_000] + roth_ira_contributions: [1_000, 0] + +- name: Dependent compensation is excluded from spousal IRA compensation sharing. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 6_000 + traditional_ira_contributions_desired: 5_000 + spouse: + age: 40 + traditional_ira_contributions_desired: 7_500 + child: + age: 17 + employment_income: 20_000 + traditional_ira_contributions_desired: 1_000 + tax_units: + tax_unit: + members: [head, spouse, child] + tax_unit_is_joint: true + output: + ira_contribution_limit: [6_000, 1_000, 7_500] + traditional_ira_contributions: [5_000, 1_000, 1_000] diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_contribution_limit.yaml index 6586faed86b..9d698e8cac8 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/ira_contribution_limit.yaml @@ -4,6 +4,7 @@ people: person1: age: 40 + employment_income: 100_000 traditional_ira_contributions_desired: 4_000 roth_ira_contributions_desired: 2_000 output: @@ -19,6 +20,7 @@ people: person1: age: 40 + employment_income: 100_000 traditional_ira_contributions_desired: 9_000 roth_ira_contributions_desired: 3_000 output: @@ -34,6 +36,7 @@ people: person1: age: 50 + employment_income: 100_000 traditional_ira_contributions_desired: 9_000 roth_ira_contributions_desired: 3_000 output: @@ -48,6 +51,7 @@ people: person1: age: 45 + employment_income: 100_000 traditional_ira_contributions_desired: 2_000 roth_ira_contributions_desired: 8_000 output: @@ -76,6 +80,7 @@ people: person1: age: 40 + employment_income: 100_000 traditional_ira_contributions_desired: 9_000 roth_ira_contributions_desired: 3_000 output: @@ -87,6 +92,7 @@ people: person1: age: 40 + employment_income: 100_000 pr_agi_person: 10_000 traditional_ira_contributions_desired: 9_000 roth_ira_contributions_desired: 3_000 @@ -96,3 +102,33 @@ state_code: PR output: pr_retirement_deduction: 5_000 + +- name: A supplied Roth contribution uses up the spouse's shared compensation + # 26 U.S.C. 219(c)(1)(B): the nonworking spouse's limit is the other + # spouse's $10,000 compensation less their $7,000 Roth contribution. + period: 2026 + input: + people: + head: + age: 40 + employment_income: 10_000 + roth_ira_contributions: 7_000 + is_tax_unit_head: true + spouse: + age: 40 + roth_ira_contributions: 0 + traditional_ira_contributions_desired: 7_500 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + ira_contribution_limit: [7_500, 3_000] + traditional_ira_contributions: [0, 3_000] diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index 411f6194675..885e4783141 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -48,8 +48,8 @@ traditional_ira_contributions_desired: 9_000 self_employed_pension_contributions_desired: 72_000 output: - ira_contribution_limit: 8_000 - traditional_ira_contributions: 8_000 + ira_contribution_limit: 8_600 + traditional_ira_contributions: 8_600 self_employed_pension_contribution_limit: 72_000 self_employed_pension_contributions: 72_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_state_income_tax.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_state_income_tax.yaml index 0d896a75b82..8cd4833223b 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_state_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_state_income_tax.yaml @@ -10,16 +10,41 @@ output: household_state_income_tax: 3_900 -- name: Wisconsin household state income tax follows the lower exclusion path +- name: Wisconsin household state income tax follows the lower elected exclusion path period: 2025 + # 2025 Schedule SB instructions, p. 7, line 16: the exclusion forfeits + # Form 1 credits on lines 13-20 and 30-35, including the earned income credit. + # https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7 + # Form 1, p. 2, lines 12, 21-22, and 30: standard net tax is + # 5_000 - 500 - 200 = 4_300; elected exclusion tax is 3_000 with no credits. + # https://www.revenue.wi.gov/TaxForms2025/2025-Form1.pdf#page=2 input: state_code: WI wi_income_tax_before_credits: 5_000 wi_non_refundable_credits: 500 + wi_retirement_income_exclusion_amount: 24_000 wi_retirement_income_exclusion_tax: 3_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 + wi_homestead_credit: 0 + # Isolate Wisconsin tax in household net income, preserving its aggregation. + household_market_income: 10_000 + household_benefits: 0 + household_health_costs: 0 + income_tax_before_refundable_credits: 0 + income_tax_refundable_credits: 0 + employee_payroll_tax: 0 + self_employment_tax: 0 + flat_tax: 0 + local_income_tax_before_refundable_credits: 0 + local_occupational_tax: 0 + state_use_tax: 0 output: + wi_retirement_income_exclusion_elected: true + wi_income_tax_before_refundable_credits: 3_000 + wi_refundable_credits: 0 + wi_income_tax: 3_000 household_state_income_tax: 3_000 + household_net_income: 7_000 # 10_000 - 3_000, with no refundable credits. - name: Case 3, New Jersey household state income tax uses filing-threshold zero-out. period: 2025 diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index 9049852d5a0..e50fffb6cf2 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -138,6 +138,19 @@ def test_cms_moop_per_capita_extends_to_2100(): assert parameter("2100-01-01") > parameter("2036-01-01") +def test_cms_esi_premium_per_enrollee_holds_last_projected_growth_rate(): + """Employer plan premium inputs keep growing after CMS's 2034 projection, + at the 2033-2034 growth rate.""" + parameter = PARAMETERS.calibration.gov.hhs.cms.esi_premium_per_enrollee + last_growth = parameter("2034-01-01") / parameter("2033-01-01") + + assert parameter("2034-01-01") == 13_124 + for year in [2035, 2036, 2050, 2100]: + growth = parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") + assert growth == pytest.approx(last_growth, rel=1e-12) + assert parameter("2150-01-01") == parameter("2100-01-01") + + def test_soi_income_upraters_extend_without_trustees_reform(): """SOI income upraters should inherit baseline long-run CBO extensions.""" soi = PARAMETERS.calibration.gov.irs.soi diff --git a/policyengine_us/tests/policy/baseline/partners/AGENTS.md b/policyengine_us/tests/policy/baseline/partners/AGENTS.md index 59ed7ae0277..04c2a171bdf 100644 --- a/policyengine_us/tests/policy/baseline/partners/AGENTS.md +++ b/policyengine_us/tests/policy/baseline/partners/AGENTS.md @@ -1,20 +1,21 @@ -# Partner API Contract Tests +# Partner API contract tests This directory contains API partner contract tests. Do not rewrite expected outputs here merely to match changed model behavior or make CI pass. A failure in this directory is evidence of a possible partner-facing API change. -Subagents must not edit partner test files. If a subagent finds that an edit is needed, it must stop and report back; the top-level agent runs the three-question gate with the user before any edit is made. +Ziming Hua (@hua7450) approves every edit to these files (Max Ghenis, 2026-10-09). Subagents and agent teammates must not edit partner test files. If a subagent or agent teammate finds that an edit is needed, it must stop and report back; the top-level agent takes the three-question gate to Ziming before any edit is made. Before changing these tests: - Flag the partner-facing risk to the user. -- Use the `AskUserQuestion` tool to ask these three questions in a single call (per CLAUDE.md): +- Put these three questions to Ziming on the PR that causes the change (per CLAUDE.md): request his review with `gh pr edit --add-reviewer hua7450`, and post a comment that names each changed case, its old and new expected values, and the model change and law behind it. If Ziming is the person you are working with, show him the changed cases and ask him directly with the `AskUserQuestion` tool instead; record his answer in a PR comment, which counts as his approval of those edits. 1. Are you sure you want to edit this test file? 2. Have you notified a team member about this change? 3. Have you notified the API partner about this change? +- Do not edit partner test files until Ziming has answered yes to all three questions, and do not merge a PR that changes them until he has approved a head that contains the edits. - Identify the model change that caused the output change. - Preserve the failing behavior as evidence unless the change is intentional. -- Explain the partner-facing impact to the user. +- Explain the partner-facing impact to Ziming and the user. -Changing these tests without explicit user confirmation is unsafe, even if CI passes afterward. +Changing these tests without Ziming's explicit approval is unsafe, even if CI passes afterward. diff --git a/policyengine_us/tests/policy/contrib/states/mn/walz/mn_walz_hf1938.yaml b/policyengine_us/tests/policy/contrib/states/mn/walz/mn_walz_hf1938.yaml index ba403f7eda6..aacb657d111 100644 --- a/policyengine_us/tests/policy/contrib/states/mn/walz/mn_walz_hf1938.yaml +++ b/policyengine_us/tests/policy/contrib/states/mn/walz/mn_walz_hf1938.yaml @@ -109,3 +109,22 @@ state_code: MN output: mn_itemized_deductions: 10_000 + +- name: Itemized deductions with reform take the Minnesota casualty loss and exempt it from the offset + absolute_error_margin: 0.5 + period: 2023 + reforms: policyengine_us.reforms.states.mn.walz.mn_walz_hf1938.mn_walz_hf1938 + input: + gov.contrib.states.mn.walz.hf1938.repeal: true + filing_status: SINGLE + adjusted_gross_income: 2_000_000 + interest_deduction: 10_000 + casualty_loss: 250_000 + state_code: MN + output: + # Schedule M1CAT: (250,000 - 100) - 10% x 2,000,000 = 49,900 + mn_casualty_loss_deduction: 49_900 + # Offset: the lesser of 80% of the deductions other than the casualty loss + # (0.8 x 10,000 = 8,000) and 3% of AGI over $220,650 (53,380.50). + # 10,000 + 49,900 - 8,000 + mn_itemized_deductions: 51_900 diff --git a/policyengine_us/tests/policy/contrib/states/ut/dependent_exemption/ut_dependent_exemption.yaml b/policyengine_us/tests/policy/contrib/states/ut/dependent_exemption/ut_dependent_exemption.yaml index 49278857c1e..9e66390db99 100644 --- a/policyengine_us/tests/policy/contrib/states/ut/dependent_exemption/ut_dependent_exemption.yaml +++ b/policyengine_us/tests/policy/contrib/states/ut/dependent_exemption/ut_dependent_exemption.yaml @@ -148,3 +148,112 @@ # in_effect is false, so the $0 amount does not apply: two dependents # at the uprated baseline amount. ut_personal_exemption: 4_318 + +- name: Reform in effect counts no dependents on a claimable filer's return (single filer) + period: 2026 + reforms: policyengine_us.reforms.states.ut.dependent_exemption.ut_dependent_exemption_reform + input: + gov.contrib.states.ut.dependent_exemption.in_effect: true + gov.contrib.states.ut.dependent_exemption.amount: 3_000 + people: + filer: + age: 20 + claimed_as_dependent_on_another_return: true + child1: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [filer, child1] + households: + household: + members: [filer, child1] + state_code: UT + output: + # IRC 152(b)(1): a filer who can be claimed has no dependents, as in the + # baseline ut_total_dependents. + ut_personal_exemption: 0 + +- name: Reform in effect counts no dependents when one joint spouse can be claimed (the claimable spouse is the spouse) + period: 2026 + reforms: policyengine_us.reforms.states.ut.dependent_exemption.ut_dependent_exemption_reform + input: + gov.contrib.states.ut.dependent_exemption.in_effect: true + gov.contrib.states.ut.dependent_exemption.amount: 3_000 + people: + person_b: + age: 22 + is_tax_unit_head: true + person_a: + age: 20 + is_tax_unit_spouse: true + claimed_as_dependent_on_another_return: true + child1: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person_b, person_a, child1] + filing_status: JOINT + households: + household: + members: [person_b, person_a, child1] + state_code: UT + output: + ut_personal_exemption: 0 + +- name: Reform in effect counts no dependents when one joint spouse can be claimed (the claimable spouse is the head) + period: 2026 + reforms: policyengine_us.reforms.states.ut.dependent_exemption.ut_dependent_exemption_reform + input: + gov.contrib.states.ut.dependent_exemption.in_effect: true + gov.contrib.states.ut.dependent_exemption.amount: 3_000 + people: + person_a: + age: 20 + is_tax_unit_head: true + claimed_as_dependent_on_another_return: true + person_b: + age: 22 + is_tax_unit_spouse: true + child1: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person_a, person_b, child1] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child1] + state_code: UT + output: + ut_personal_exemption: 0 + +- name: Reform in effect, neither spouse claimable (control) + period: 2026 + reforms: policyengine_us.reforms.states.ut.dependent_exemption.ut_dependent_exemption_reform + input: + gov.contrib.states.ut.dependent_exemption.in_effect: true + gov.contrib.states.ut.dependent_exemption.amount: 3_000 + people: + person_a: + age: 20 + is_tax_unit_head: true + person_b: + age: 22 + is_tax_unit_spouse: true + child1: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person_a, person_b, child1] + filing_status: JOINT + households: + household: + members: [person_a, person_b, child1] + state_code: UT + output: + # One dependent at the reform amount (no age limit): $3,000. + ut_personal_exemption: 3_000 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/tests/test_ira_formula_differential.py b/policyengine_us/tests/test_ira_formula_differential.py new file mode 100644 index 00000000000..e09d888e4bd --- /dev/null +++ b/policyengine_us/tests/test_ira_formula_differential.py @@ -0,0 +1,519 @@ +"""Fast IRA formula checks that do not import the complete US model. + +Execute the actual formula AST with NumPy arithmetic and small entity proxies. +The Decimal oracle uses Publication 590-A's remaining-allowance worksheet and +the separate deductible/designated-contribution categories in IRC 219(c) and +408(o), rather than reproducing the model's reduction or aggregate formulas. + +Authorities: https://www.law.cornell.edu/uscode/text/26/219, +https://www.law.cornell.edu/uscode/text/26/408#o_2, +https://www.irs.gov/publications/p590a, +https://www.irs.gov/pub/irs-drop/n-25-67.pdf. + +These tests supplement Simulation tests: they do not validate input conversion, +entity construction, parameter loading, dependency cycles, MAGI, or aggregation. +""" + +import ast +from decimal import Decimal, ROUND_CEILING +from itertools import product +from pathlib import Path +from types import SimpleNamespace + +import numpy as np + + +VARIABLES = Path(__file__).resolve().parents[1] / "variables" +RETIREMENT = VARIABLES / "household/expense/retirement" +IRA = ( + VARIABLES + / "gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira" +) +STATUSES = SimpleNamespace( + SINGLE="SINGLE", + JOINT="JOINT", + SEPARATE="SEPARATE", + HEAD_OF_HOUSEHOLD="HEAD_OF_HOUSEHOLD", + SURVIVING_SPOUSE="SURVIVING_SPOUSE", +) + + +class StatusArray(np.ndarray): + possible_values = STATUSES + + +class StatusParameter(SimpleNamespace): + def __getitem__(self, statuses): + return np.asarray([getattr(self, str(status)) for status in statuses]) + + +class Group: + def __init__(self, groups, values=None): + self.groups = np.asarray(groups, dtype=int) + self.values = values or {} + + def __call__(self, name, period): + value = self.values[name] + if name == "filing_status": + return np.asarray(value).view(StatusArray) + return np.asarray(value) + + def sum(self, values): + return np.bincount(self.groups, weights=values)[self.groups] + + +class People: + def __init__(self, values, tax_groups=None, tax_values=None, marital_groups=None): + self.values = {key: np.asarray(value) for key, value in values.items()} + self.count = len(next(iter(self.values.values()))) + groups = np.arange(self.count) if tax_groups is None else tax_groups + self.tax_unit = Group(groups, tax_values) + self.marital_unit = Group( + np.arange(self.count) if marital_groups is None else marital_groups + ) + + def __call__(self, name, period): + return self.values.get(name, np.zeros(self.count)) + + +def formula(path, variable): + """Compile only the repository's formula, excluding model imports/metadata.""" + tree = ast.parse(path.read_text(), filename=str(path)) + variable_class = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == variable + ) + function = next( + node + for node in variable_class.body + if isinstance(node, ast.FunctionDef) and node.name == "formula" + ) + namespace = { + "np": np, + "max_": np.maximum, + "min_": np.minimum, + "where": np.where, + "clip": np.clip, + "add": lambda person, period, names: sum( + person(name, period) for name in names + ), + # These stubs carry desired contributions only, so no actual + # contribution is supplied as an input. + "supplied_ira_contributions": lambda person, period: { + "traditional_ira_contributions": None, + "roth_ira_contributions": None, + }, + } + exec( + compile(ast.Module(body=[function], type_ignores=[]), str(path), "exec"), + namespace, + ) + return namespace["formula"] + + +def parameters(): + starts = StatusParameter( + SINGLE=81_000, + JOINT=129_000, + SEPARATE=0, + HEAD_OF_HOUSEHOLD=81_000, + SURVIVING_SPOUSE=129_000, + ) + widths = StatusParameter( + SINGLE=10_000, + JOINT=20_000, + SEPARATE=10_000, + HEAD_OF_HOUSEHOLD=10_000, + SURVIVING_SPOUSE=20_000, + ) + retirement = SimpleNamespace( + limit=SimpleNamespace(ira=7_500), + catch_up=SimpleNamespace(age_threshold=50, limit=SimpleNamespace(ira=1_100)), + ) + phase_out = SimpleNamespace( + start=starts, + width=widths, + spouse_start=242_000, + spouse_width=10_000, + minimum=200, + rounding_interval=10, + ) + root = SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace( + gross_income=SimpleNamespace(retirement_contributions=retirement), + ald=SimpleNamespace(ira=SimpleNamespace(phase_out=phase_out)), + ) + ) + ) + return lambda period: root + + +def decimal(value): + return Decimal(str(value)) + + +def worksheet_allowance(age, status, active, spouse_active, lived_together, income): + """Pub. 590-A Worksheet 1-2: round the remaining allowance upwards.""" + annual_allowance = Decimal(8_600 if age >= 50 else 7_500) + if status == "SEPARATE" and lived_together: + covered = active or spouse_active + lower, upper = 0, 10_000 + elif active: + covered = True + lower, upper = ( + (129_000, 149_000) + if status in ("JOINT", "SURVIVING_SPOUSE") + else (81_000, 91_000) + ) + elif status == "JOINT" and spouse_active: + covered = True + lower, upper = 242_000, 252_000 + else: + covered = False + lower, upper = 0, 1 + if not covered or decimal(income) <= lower: + return annual_allowance + if decimal(income) >= upper: + return Decimal(0) + remaining_fraction = (Decimal(upper) - decimal(income)) / Decimal(upper - lower) + remaining = annual_allowance * remaining_fraction + rounded = (remaining / Decimal(10)).to_integral_value(rounding=ROUND_CEILING) * 10 + return max(Decimal(200), rounded) + + +def test_phaseout_matches_decimal_irs_worksheet_across_coverage_and_boundaries(): + rows = [] + expected = [] + tax_groups = [] + incomes = [ + 0, + 0.01, + 9_999.99, + 10_000, + 80_999.99, + 81_000, + 81_013.33, + 81_013.34, + 90_800, + 90_999.99, + 91_000, + 128_999.99, + 129_000, + 129_026.66, + 129_026.67, + 139_000, + 148_999.99, + 149_000, + 241_999.99, + 242_000, + 242_013.33, + 242_013.34, + 247_000, + 251_999.99, + 252_000, + 300_000, + ] + for age, status, active, spouse_active, together in product( + [40, 50], vars(STATUSES), [False, True], [False, True], [False, True] + ): + for income in incomes: + index = len(rows) + rows.extend( + [ + (age, status, active, together, income), + (age, status, spouse_active, together, income), + ] + ) + tax_groups.extend([index, index if status == "JOINT" else index + 1]) + expected.append( + float( + worksheet_allowance( + age, status, active, spouse_active, together, income + ) + ) + ) + people = People( + { + "age": [row[0] for row in rows], + "ira_active_participant": [row[2] for row in rows], + "is_tax_unit_head_or_spouse": np.ones(len(rows), dtype=bool), + }, + tax_groups, + { + "filing_status": [row[1] for row in rows], + "cohabitating_spouses": [row[3] for row in rows], + "ira_219g_magi": [row[4] for row in rows], + }, + np.arange(len(rows)) // 2, + ) + actual = formula(IRA / "ira_219g_deductible_limit.py", "ira_219g_deductible_limit")( + people, None, parameters() + ) + np.testing.assert_array_equal(actual[::2], expected) + assert np.all(np.isfinite(actual)) + assert np.all((actual == 0) | (actual >= 200)) + assert np.all(actual % 10 == 0) + assert np.all(np.diff(actual[::2].reshape(-1, len(incomes)), axis=1) <= 0) + + +def legal_spouse_consumption(age, compensation, traditional, roth, phaseout): + """Account separately for deductible, designated, and Roth contributions.""" + dollar = Decimal(8_600 if age >= 50 else 7_500) + compensation, traditional, roth, phaseout = map( + decimal, [compensation, traditional, roth, phaseout] + ) + deductible = max(Decimal(0), min(traditional, compensation, dollar, phaseout)) + designated = max( + Decimal(0), + min(traditional - deductible, compensation - deductible, dollar - deductible), + ) + return deductible + designated + max(Decimal(0), roth) + + +def reference_joint_deductions(ages, compensation, traditional, roth, phaseout): + result = [] + for index in range(2): + other = 1 - index + available = decimal(compensation[index]) + if compensation[index] < compensation[other]: + other_consumed = legal_spouse_consumption( + ages[other], + compensation[other], + traditional[other], + roth[other], + phaseout[other], + ) + available += max(Decimal(0), decimal(compensation[other]) - other_consumed) + result.append( + float( + max( + Decimal(0), + min( + decimal(traditional[index]), available, decimal(phaseout[index]) + ), + ) + ) + ) + return result + + +def test_actual_excess_contributions_match_statutory_spousal_categories(): + rows = [] + expected = [] + for ( + higher, + lower, + traditional, + roth, + lower_request, + higher_phaseout, + age, + ) in product( + [6_000, 10_000, 15_000], + [0, 500, 6_000], + [0, 6_000, 12_000], + [0, 5_000, 12_000], + [0, 7_500, 12_000], + [0, 200, 7_500], + [40, 50], + ): + # Include role swaps: the taxpayer with more compensation need not be head. + for reverse in [False, True]: + ages = [age, 40] + compensation = [higher, lower] + traditionals = [traditional, lower_request] + roths = [roth, 0] + limits = [higher_phaseout, 7_500] + if reverse: + ages, compensation, traditionals, roths, limits = ( + values[::-1] + for values in [ages, compensation, traditionals, roths, limits] + ) + rows.extend(zip(ages, compensation, traditionals, roths, limits)) + expected.extend( + reference_joint_deductions( + ages, compensation, traditionals, roths, limits + ) + ) + count = len(rows) + people = People( + { + "age": [row[0] for row in rows], + "ira_compensation": [row[1] for row in rows], + "traditional_ira_contributions": [row[2] for row in rows], + "roth_ira_contributions": [row[3] for row in rows], + "ira_219g_deductible_limit": [row[4] for row in rows], + "is_tax_unit_head_or_spouse": np.ones(count, dtype=bool), + "is_tax_unit_head": np.arange(count) % 2 == 0, + "is_tax_unit_spouse": np.arange(count) % 2 == 1, + }, + np.arange(count) // 2, + {"tax_unit_is_joint": np.ones(count, dtype=bool)}, + ) + actual = formula(IRA / "traditional_ira_deduction.py", "traditional_ira_deduction")( + people, None, parameters() + ) + np.testing.assert_array_equal(actual, expected) + assert np.all(actual >= 0) + assert np.all(actual <= people("traditional_ira_contributions", None)) + assert np.all(actual <= people("ira_219g_deductible_limit", None)) + assert np.all( + actual.reshape(-1, 2).sum(axis=1) + <= people("ira_compensation", None).reshape(-1, 2).sum(axis=1) + ) + + +def test_compensation_excludes_losses_and_passive_income(): + examples = [ + ({"irs_employment_income": 1_000, "self_employment_income": -500}, 1_000), + ({"taxable_alimony_income": 2_500}, 2_500), + ({"self_employment_income": 300, "self_employment_tax_ald_person": 500}, 0), + ( + { + "farm_operations_income": 7_500, + "self_employment_tax_ald_person": 530, + "self_employed_pension_contribution_ald_person": 1_000, + }, + 5_970, + ), + ( + { + "partnership_self_employment_net_earnings": 8_500, + "self_employment_tax_ald_person": 600, + }, + 7_900, + ), + ( + { + "sstb_self_employment_income": 9_000, + "self_employment_tax_ald_person": 636, + "self_employed_pension_contribution_ald_person": 1_000, + }, + 7_364, + ), + ( + { + "irs_employment_income": 1_000, + "taxable_alimony_income": 500, + "self_employment_income": 3_000, + "sstb_self_employment_income": -1_000, + "farm_operations_income": 2_000, + "partnership_self_employment_net_earnings": 5_000, + "self_employment_tax_ald_person": 300, + "self_employed_pension_contribution_ald_person": 1_000, + }, + 9_200, + ), + ( + { + "pension_income": 50_000, + "rental_income": 10_000, + "dividend_income": 10_000, + }, + 0, + ), + ] + names = set().union(*(values for values, _ in examples)) + people = People( + {name: [values.get(name, 0) for values, _ in examples] for name in names} + ) + actual = formula(RETIREMENT / "ira_compensation.py", "ira_compensation")( + people, None, parameters() + ) + np.testing.assert_array_equal(actual, [expected for _, expected in examples]) + + +def test_dependents_neither_claim_deduction_nor_supply_spousal_compensation(): + calculate = formula( + IRA / "traditional_ira_deduction.py", "traditional_ira_deduction" + ) + for joint, expected in [(True, [5_000, 1_000, 0]), (False, [5_000, 0, 0])]: + people = People( + { + "age": [40, 40, 17], + "ira_compensation": [6_000, 0, 20_000], + "traditional_ira_contributions": [5_000, 7_500, 1_000], + "roth_ira_contributions": [0, 0, 0], + "ira_219g_deductible_limit": [7_500, 7_500, 7_500], + "is_tax_unit_head_or_spouse": [True, True, False], + "is_tax_unit_head": [True, False, False], + "is_tax_unit_spouse": [False, True, False], + }, + [0, 0, 0], + {"tax_unit_is_joint": [joint, joint, joint]}, + ) + np.testing.assert_array_equal(calculate(people, None, parameters()), expected) + + +def test_compensation_has_unit_wage_effect_and_passive_income_irrelevance(): + calculate = formula(RETIREMENT / "ira_compensation.py", "ira_compensation") + losses = np.arange(-10_000, 1, 100) + baseline = People( + { + "irs_employment_income": np.full(len(losses), 3_000), + "self_employment_income": losses, + } + ) + modified = People( + { + **baseline.values, + "irs_employment_income": np.full(len(losses), 3_123.45), + "pension_income": np.full(len(losses), 100_000), + } + ) + before = calculate(baseline, None, parameters()) + after = calculate(modified, None, parameters()) + np.testing.assert_array_equal(before, np.full(len(losses), 3_000)) + np.testing.assert_allclose(after - before, 123.45, rtol=0, atol=1e-10) + + +def test_generated_single_contributions_preserve_shares_within_compensation(): + rows = list( + product( + [0, 0.25, 0.5, 2_000, 10_000], + [0, 0.25, 1_000, 8_000], + [0, 0.25, 1_000, 8_000], + [40, 50], + ) + ) + people = People( + { + "ira_compensation": [row[0] for row in rows], + "traditional_ira_contributions_desired": [row[1] for row in rows], + "roth_ira_contributions_desired": [row[2] for row in rows], + "age": [row[3] for row in rows], + "is_tax_unit_head_or_spouse": np.ones(len(rows), dtype=bool), + }, + tax_values={"tax_unit_is_joint": np.zeros(len(rows), dtype=bool)}, + ) + limit = formula(RETIREMENT / "ira_contribution_limit.py", "ira_contribution_limit")( + people, None, parameters() + ) + people.values["ira_contribution_limit"] = limit + scale = formula(RETIREMENT / "ira_contribution_scale.py", "ira_contribution_scale")( + people, None, parameters() + ) + traditional = people("traditional_ira_contributions_desired", None) * scale + roth = people("roth_ira_contributions_desired", None) * scale + assert np.all(np.isfinite(scale)) + assert np.all((scale >= 0) & (scale <= 1)) + assert np.all(traditional + roth <= people("ira_compensation", None) + 1e-8) + assert np.all( + traditional + roth <= np.where(people("age", None) >= 50, 8_600, 7_500) + 1e-8 + ) + np.testing.assert_allclose( + traditional * people("roth_ira_contributions_desired", None), + roth * people("traditional_ira_contributions_desired", None), + rtol=1e-12, + atol=1e-8, + ) + assert np.all( + scale[ + (people("ira_compensation", None) == 0.5) + & (people("traditional_ira_contributions_desired", None) == 0.25) + & (people("roth_ira_contributions_desired", None) == 0.25) + ] + == 1 + ) diff --git a/policyengine_us/tests/test_mi_household_resources_adjustments.py b/policyengine_us/tests/test_mi_household_resources_adjustments.py index 979df9c4e7f..afb6712a20f 100644 --- a/policyengine_us/tests/test_mi_household_resources_adjustments.py +++ b/policyengine_us/tests/test_mi_household_resources_adjustments.py @@ -80,7 +80,7 @@ "self_employed_health_insurance_ald": "17", "early_withdrawal_penalty": "18", "alimony_expense_ald": "19a", - "traditional_ira_contributions": "20", + "traditional_ira_deduction": "20", "student_loan_interest_ald": "21", } # Federal above-the-line deductions that are not Schedule 1 Part II @@ -370,13 +370,13 @@ def test_line_30_follows_the_federal_list(): households = grid_households() removed = { "early_withdrawal_penalty", - "traditional_ira_contributions", + "traditional_ira_deduction", "us_bonds_for_higher_ed", "puerto_rico_income", } model = calculate(households, reform=without_federal_deductions(removed)) assert "early_withdrawal_penalty" not in model["adjustments"] - assert "traditional_ira_contributions" not in model["adjustments"] + assert "traditional_ira_deduction" not in model["adjustments"] for i, h in enumerate(households): line_30 = sum(float(values[i]) for values in model["adjustments"].values()) assert model["mi_household_resources"][i] == pytest.approx( diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py index 10e1d6d7594..b8e2af53f29 100644 --- a/policyengine_us/tests/test_mi_household_resources_losses.py +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -38,8 +38,8 @@ out on purpose: when line 16 is floored at zero, more Schedule C income still raises the deduction for self-employment tax on line 30, so household resources fall. The form gives that result. -6. A dependent's business and rental losses and S corporation, estate and - rental income leave household resources unchanged. +6. A dependent's business and rental income and losses, including Schedule C, + Schedule F, S corporation and estate income, leave household resources unchanged. Amounts are whole dollars of at most $500,000, and the self-employment tax deduction is read from the model. The model computes in single precision, so a @@ -312,10 +312,11 @@ def test_worked_example(): DEPENDENT_VALUES = { - # Losses only for Schedule C and F: a dependent's self-employment tax - # deduction is a separate question (it is not on this return either). - "self_employment_income": [-20_000, 0], - "farm_operations_income": [-10_000, 0], + # Schedule C and F income and losses: a dependent's self-employment tax + # deduction is on the dependent's own return, so line 30 leaves it out + # (#9801). + "self_employment_income": [-20_000, 0, 20_000], + "farm_operations_income": [-10_000, 0, 10_000], "s_corp_income": [-20_000, 0, 15_000], "estate_income": [-5_000, 0, 5_000], "rental_income": [-20_000, 0, 15_000], diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py new file mode 100644 index 00000000000..fd9f10f8a5b --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -0,0 +1,493 @@ +"""Property tests: whose amounts count in Michigan household resources. + +MCL 206.508(3): "'Household' means a claimant and spouse." MCL 206.508(4) +counts "all income received by all persons of a household". The MI-1040CR +asks for "all taxable and nontaxable income you and your spouse received" +(2025 MI-1040 book, page 31), so a dependent's own income and Schedule 1 +adjustments are on the dependent's return, not the claimant's. The form +counts amounts the claimant receives for others in the household on three +lines (page 32): + +- line 21: Social Security, SSI and railroad retirement benefits, including + "amounts received for minor children or other dependent adults who live + with you"; +- line 22: child support and foster parent payments; +- line 27: payments to the household by MDHHS and other public assistance. + +Line 31 is "insurance premiums you paid for yourself and your family"; a +premium on any member's record is read as one the claimant or spouse paid +for the family's coverage. + +So for every household: + +1. mi_household_resources equals a reference written from the form: the head + and spouse's lines 14 to 20 and 23 to 26 and their line 30 adjustments, + plus every member's lines 21, 22 and 27, less every member's premiums, + floored at zero. +2. A dependent's own income and adjustments do not matter: zeroing them + leaves household resources unchanged. +3. Amounts received for the household count wherever they are recorded: + moving a dependent's line 21, 22 or 27 amount, or premium, to the head + leaves household resources unchanged. +4. Raising a dependent's line 21, 22 or 27 amount by d raises household + resources by between 0 and d, and by exactly d when they were positive. +5. Each of the spouse's own sources adds what it adds on the head. +6. The lists are reformable: a source moved into + household_resources_all_members counts for every member, and an SPM unit + source in household_resources counts in full. + +Every generated household has a dependent, so properties 2 to 4 always bind, +and property 4 always includes a household with positive resources. + +Comparisons allow one cent or eight float32 spacings at the sum of the +household's absolute amounts, as in test_mi_household_resources_losses.py. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + +YEAR = 2025 +CAPITAL_LOSS_LIMIT = 3_000 +TOLERANCE = 0.01 + +# Inputs that are the claimant's or spouse's own, by MI-1040CR line. +CLAIMANT_LINES = { + "employment_income_before_lsr": 14, + "strike_benefits": 14, + "disability_benefits": 14, + "taxable_interest_income": 15, + "tax_exempt_interest_income": 15, + "qualified_dividend_income": 15, + "self_employment_income": 16, + "s_corp_income": 16, + "rental_income": 17, + "taxable_pension_income": 18, + "long_term_capital_gains": 19, + "alimony_income": 20, + "gambling_winnings": 20, + "unemployment_compensation": 23, + "miscellaneous_income": 24, + "veterans_benefits": 26, + "workers_compensation": 26, + # Guaranteed income pilot payments: the recipient's own (line 25). + "gi_cash_assistance": 25, + # Line 30: penalty on early withdrawal of savings (Schedule 1 line 18). + "early_withdrawal_penalty": 30, +} +# Inputs the claimant receives for the household, by MI-1040CR line. +HOUSEHOLD_LINES = { + "social_security_retirement": 21, + "social_security_survivors": 21, + "ssi": 21, + "railroad_benefits": 21, + "child_support_received": 22, + "general_assistance": 27, +} +PREMIUMS = "health_insurance_premiums" +# Sources that can be negative. +SIGNED = {"self_employment_income", "s_corp_income", "rental_income"} +SIGNED.add("long_term_capital_gains") +PERSON_INPUTS = [*CLAIMANT_LINES, *HOUSEHOLD_LINES, PREMIUMS] +# Person-level line 30 amounts read from the model. +PERSON_ADJUSTMENTS = [ + "self_employment_tax_ald_person", + "self_employed_health_insurance_ald_person", +] + + +def zero_person(): + return {name: 0 for name in PERSON_INPUTS} + + +def build_situation(households): + """One Michigan tax unit per household: a head aged 45, an optional + spouse aged 44, and dependents aged 10 or 16 (children) or 75 (a + dependent parent). The SPM unit is the tax unit, and its Family + Independence Program grant (tanf) is an input, so the model does not + compute one from the household's income.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + spm_units = {} + for i, h in enumerate(households): + members, couple = [], [] + roles = [("head", h["head"])] + if h.get("spouse") is not None: + roles.append(("spouse", h["spouse"])) + roles += [("dependent", d) for d in h.get("dependents", [])] + for j, (role, p) in enumerate(roles): + name = f"person_{i}_{j}" + age = {"head": 45, "spouse": 44}.get(role, p.get("age", 10)) + person = {"age": {YEAR: age}} + for variable in PERSON_INPUTS: + person[variable] = {YEAR: p.get(variable, 0)} + # Roles are set for everyone: an adult dependent would otherwise + # be the spouse, and a variable input for some people gives the + # others its default value, not its formula. + person["is_tax_unit_head"] = {YEAR: role == "head"} + person["is_tax_unit_spouse"] = {YEAR: role == "spouse"} + person["is_tax_unit_dependent"] = {YEAR: role == "dependent"} + people[name] = person + members.append(name) + if role == "dependent": + marital_units[f"marital_unit_{i}_{j}"] = {"members": [name]} + else: + couple.append(name) + tax_units[f"tax_unit_{i}"] = {"members": members} + spm_units[f"spm_unit_{i}"] = { + "members": members, + "tanf": {YEAR: h.get("tanf", 0)}, + } + marital_units[f"marital_unit_{i}"] = {"members": couple} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "MI"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "households": households_out, + } + + +def filers(h): + return [h["head"]] + ([h["spouse"]] if h.get("spouse") is not None else []) + + +def everyone(h): + return filers(h) + list(h.get("dependents", [])) + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + result = np.asarray(simulation.calculate("mi_household_resources", YEAR)) + # Each person's model amounts for the line 30 self-employment + # adjustments, in situation order. + adjustments = sum( + np.asarray(simulation.calculate(name, YEAR)) for name in PERSON_ADJUSTMENTS + ) + per_household, k = [], 0 + for h in households: + n = len(everyone(h)) + per_household.append(adjustments[k : k + n]) + k += n + return result, per_household + + +def amount(people, names): + return sum(p.get(name, 0) for p in people for name in names) + + +def by_line(lines, *wanted): + return [name for name, line in lines.items() if line in wanted] + + +def reference(h, person_adjustments): + """MI-1040CR line 33 from the form.""" + own = filers(h) + line_14 = amount(own, by_line(CLAIMANT_LINES, 14)) + line_15 = amount(own, by_line(CLAIMANT_LINES, 15)) + line_16 = max(0, amount(own, ["self_employment_income", "s_corp_income"])) + line_17 = max(0, amount(own, ["rental_income"])) + line_18 = amount(own, by_line(CLAIMANT_LINES, 18)) + line_19 = max(-CAPITAL_LOSS_LIMIT, amount(own, ["long_term_capital_gains"])) + lines_20_to_26 = amount(own, by_line(CLAIMANT_LINES, 20, 23, 24, 25, 26)) + received = amount(everyone(h), list(HOUSEHOLD_LINES)) + # Line 27: the household's Family Independence Program grant, in full. + received += h.get("tanf", 0) + # Line 30: the head and spouse come first in each household's slice. + line_30 = float(sum(person_adjustments[: len(own)])) + line_30 += amount(own, ["early_withdrawal_penalty"]) + line_31 = amount(everyone(h), [PREMIUMS]) + total = line_14 + line_15 + line_16 + line_17 + line_18 + line_19 + total += lines_20_to_26 + received + return max(0, total - line_30 - line_31) + + +def tolerance(h): + """One cent, or eight float32 spacings at the sum of the household's + absolute amounts.""" + scale = sum(abs(v) for p in everyone(h) for v in p.values()) + scale += abs(h.get("tanf", 0)) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def copy(h): + out = {"head": dict(h["head"]), "tanf": h.get("tanf", 0)} + if h.get("spouse") is not None: + out["spouse"] = dict(h["spouse"]) + out["dependents"] = [dict(d) for d in h.get("dependents", [])] + return out + + +def without_dependents_own_amounts(h): + out = copy(h) + for d in out["dependents"]: + for name in CLAIMANT_LINES: + d[name] = 0 + return out + + +def with_household_amounts_on_head(h): + out = copy(h) + for d in out["dependents"]: + for name in [*HOUSEHOLD_LINES, PREMIUMS]: + out["head"][name] = out["head"].get(name, 0) + d.get(name, 0) + d[name] = 0 + return out + + +def assert_properties(households): + """Properties 1, 2 and 3. The modified copies go in the same + simulation.""" + zeroed = [without_dependents_own_amounts(h) for h in households] + moved = [with_household_amounts_on_head(h) for h in households] + result, adjustments = calculate(households + zeroed + moved) + n = len(households) + for i, h in enumerate(households): + tol = tolerance(h) + # 1. The form. + assert result[i] == pytest.approx(reference(h, adjustments[i]), abs=tol), h + assert result[i] >= 0, h + # 2. A dependent's own income and adjustments do not count. + assert result[n + i] == pytest.approx(result[i], abs=tol), h + # 3. Amounts received for the household count wherever recorded. + assert result[2 * n + i] == pytest.approx(result[i], abs=tol), h + + +# --------------------------------------------------------------------------- +# Deterministic tests (no Hypothesis needed). +# --------------------------------------------------------------------------- + + +def base_household(dependent_age=10, spouse=False): + head = zero_person() + head["employment_income_before_lsr"] = 40_000 + return { + "head": head, + "spouse": zero_person() if spouse else None, + "dependents": [dict(zero_person(), age=dependent_age)], + } + + +def test_each_source_for_a_dependent(): + """For a child and for a dependent parent, every source on its own: a + dependent's own income and adjustments leave household resources at the + base, and an amount received for the household adds in full, as it + would for the head.""" + households, cases = [], [] + for age, spouse in itertools.product([10, 75], [False, True]): + base = base_household(age, spouse) + households.append(base) + cases.append((age, spouse, None, 0)) + for name in PERSON_INPUTS: + values = [7_000, -7_000] if name in SIGNED else [7_000] + for value in values: + h = copy(base) + h["dependents"][0][name] = value + households.append(h) + cases.append((age, spouse, name, value)) + if name in HOUSEHOLD_LINES or name == PREMIUMS: + on_head = copy(base) + on_head["head"][name] = value + households.append(on_head) + cases.append((age, spouse, f"head:{name}", value)) + result, adjustments = calculate(households) + base_result = {} + for i, (age, spouse, name, value) in enumerate(cases): + if name is None: + base_result[(age, spouse)] = result[i] + head_result = { + (age, spouse, name[5:], value): result[i] + for i, (age, spouse, name, value) in enumerate(cases) + if name is not None and name.startswith("head:") + } + for i, (age, spouse, name, value) in enumerate(cases): + h = households[i] + assert result[i] == pytest.approx( + reference(h, adjustments[i]), abs=TOLERANCE + ), cases[i] + if name is None or name.startswith("head:"): + continue + base = base_result[(age, spouse)] + if name in CLAIMANT_LINES: + assert result[i] == pytest.approx(base, abs=TOLERANCE), cases[i] + elif name in HOUSEHOLD_LINES: + assert result[i] == pytest.approx(base + value, abs=TOLERANCE) + assert result[i] == pytest.approx( + head_result[(age, spouse, name, value)], abs=TOLERANCE + ), cases[i] + else: # Premiums paid for the family. + assert result[i] == pytest.approx(base - value, abs=TOLERANCE) + assert result[i] == pytest.approx( + head_result[(age, spouse, name, value)], abs=TOLERANCE + ), cases[i] + + +def test_spouse_counts_like_the_head(): + """Both spouses' own amounts count: each source on the spouse adds what + it adds on the head.""" + households = [] + for name in CLAIMANT_LINES: + if name == "early_withdrawal_penalty": + continue + on_head = base_household(spouse=True) + on_head["head"][name] += 7_000 + on_spouse = base_household(spouse=True) + on_spouse["spouse"][name] = 7_000 + households += [on_head, on_spouse] + result, adjustments = calculate(households) + for k in range(0, len(households), 2): + assert result[k] == pytest.approx(result[k + 1], abs=TOLERANCE) + assert result[k] == pytest.approx( + reference(households[k], adjustments[k]), abs=TOLERANCE + ) + + +def reform_lists(household_resources=None, all_members=None): + """A reform that replaces Michigan's source lists for the year.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + p = parameters.gov.states.mi.tax.income + for node, values in [ + (p.household_resources, household_resources), + (p.household_resources_all_members, all_members), + ]: + if values is not None: + node.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +def test_reformed_lists(): + """6. A source moved into household_resources_all_members counts for + every member, and an SPM unit source put in household_resources in place + of tanf counts in full.""" + h = base_household() + h["dependents"][0]["gi_cash_assistance"] = 7_000 + baseline = Simulation(situation=build_situation([h])) + p = baseline.tax_benefit_system.parameters.gov.states.mi.tax.income + sources = list(p.household_resources(f"{YEAR}-01-01")) + all_members = list(p.household_resources_all_members(f"{YEAR}-01-01")) + # The dependent's own pilot payment is not household resources... + assert baseline.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 40_000, abs=TOLERANCE + ) + # ...unless a reform counts it for every member. + moved = Simulation( + situation=build_situation([h]), + reform=reform_lists(all_members=all_members + ["gi_cash_assistance"]), + ) + assert moved.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 47_000, abs=TOLERANCE + ) + # A parent with wages of 10,000 and a child, and an SPM unit grant of + # 6,000 given as tanf_if_takes_up, which the reform lists in place of tanf. + family = base_household() + family["head"]["employment_income_before_lsr"] = 10_000 + situation = build_situation([family]) + situation["spm_units"]["spm_unit_0"]["tanf_if_takes_up"] = {YEAR: 6_000} + swapped = Simulation( + situation=situation, + reform=reform_lists( + household_resources=[ + "tanf_if_takes_up" if s == "tanf" else s for s in sources + ], + all_members=[s for s in all_members if s != "tanf"], + ), + ) + assert swapped.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 16_000, abs=TOLERANCE + ) + + +# --------------------------------------------------------------------------- +# Hypothesis properties. Hypothesis is a dev extra: without it these tests +# are not collected and the deterministic tests above still run. +# --------------------------------------------------------------------------- + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + SIGNED_AMOUNT = st.integers(min_value=-200_000, max_value=200_000) + AMOUNT = st.integers(min_value=100, max_value=100_000) + SLOW = [hypothesis.HealthCheck.too_slow] + + @st.composite + def person(draw): + p = zero_person() + # Each person draws a few sources, so households are sparse enough + # that the floor at zero does not hide everything. + names = draw(st.lists(st.sampled_from(PERSON_INPUTS), max_size=5, unique=True)) + for name in names: + p[name] = draw(SIGNED_AMOUNT if name in SIGNED else AMOUNT) + return p + + @st.composite + def household(draw): + dependents = [] + for _ in range(draw(st.integers(min_value=1, max_value=2))): + d = draw(person()) + d["age"] = draw(st.sampled_from([10, 16, 75])) + dependents.append(d) + return { + "head": draw(person()), + "spouse": draw(st.one_of(st.none(), person())), + "dependents": dependents, + "tanf": draw(st.one_of(st.just(0), AMOUNT)), + } + + @hypothesis.settings(max_examples=50, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(household(), min_size=1, max_size=15)) + def test_properties(households): + assert_properties(households) + + @hypothesis.settings(max_examples=30, deadline=None, suppress_health_check=SLOW) + @hypothesis.given( + st.lists(household(), min_size=1, max_size=10), + st.sampled_from(list(HOUSEHOLD_LINES)), + st.integers(min_value=1, max_value=50_000), + ) + def test_more_received_for_a_dependent(households, source, extra): + """4. More of a line 21, 22 or 27 amount for a dependent raises + household resources by between 0 and the increase, and by exactly + the increase when they were positive.""" + # A household with positive resources, so the exact-increase branch + # always runs. + households = households + [base_household()] + raised = [] + for h in households: + r = copy(h) + r["dependents"][0][source] = r["dependents"][0].get(source, 0) + extra + raised.append(r) + result, _ = calculate(households + raised) + n = len(households) + for i, h in enumerate(raised): + tol = tolerance(h) + change = float(result[n + i]) - float(result[i]) + assert -tol <= change <= extra + tol, (households[i], source) + if result[i] > tol: + assert change == pytest.approx(extra, abs=tol), ( + households[i], + source, + ) diff --git a/policyengine_us/tests/test_mt_elderly_credit_dependent_deductions.py b/policyengine_us/tests/test_mt_elderly_credit_dependent_deductions.py new file mode 100644 index 00000000000..90736451eca --- /dev/null +++ b/policyengine_us/tests/test_mt_elderly_credit_dependent_deductions.py @@ -0,0 +1,209 @@ +"""Own valid adjustments reduce Montana household income once, losses never do. + +2023 Form 2 instructions p.51 line 1 uses each household member's federal +AGI. HB191 section 1(9), PDF p.2, retains federal AGI without regard to loss. +These properties compare a dependent with a member on a separate return, +holding receipts fixed at zero to isolate their non-loss deductions. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings, strategies as st + +from policyengine_us import Simulation + + +def build_situation(cases, year): + people, tax_units, marital_units, spm_units, families, households = ( + {}, + {}, + {}, + {}, + {}, + {}, + ) + for index, case in enumerate(cases): + # A: dependent; B: separate filer; C: no losses; D: extra own penalty. + for arrangement in "ABCD": + key = f"{arrangement}{index}" + claimant, member = f"claimant_{key}", f"member_{key}" + people[claimant] = { + "age": {year: 70}, + "taxable_pension_income": {year: case["pension"]}, + "early_withdrawal_penalty": {year: case["parent_penalty"]}, + "is_tax_unit_dependent": {year: False}, + "mt_refundable_credits_before_renter_credit": {year: 0}, + "mt_elderly_homeowner_or_renter_credit_received": {year: 0}, + "mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received": { + year: 0 + }, + } + people[member] = { + "age": {year: 19}, + "is_tax_unit_dependent": {year: arrangement != "B"}, + "employment_income": {year: case["wages"]}, + "self_employment_income": {year: case["self_employment"]}, + "taxable_interest_income": {year: case["interest"]}, + "traditional_ira_contributions": {year: case["ira"]}, + "educator_expense": {year: case["educator"]}, + "early_withdrawal_penalty": { + year: case["penalty"] + + (case["extra_penalty"] if arrangement == "D" else 0) + }, + "self_employed_health_insurance_premiums": {year: case["health"]}, + "self_employed_pension_contributions": { + year: case["pension_contribution"] + }, + "long_term_capital_gains": { + year: -case["capital_loss"] if arrangement != "C" else 0 + }, + "rental_income": { + year: -case["rental_loss"] if arrangement != "C" else 0 + }, + "mt_refundable_credits_before_renter_credit": {year: 0}, + "mt_elderly_homeowner_or_renter_credit_received": {year: 0}, + "mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received": { + year: 0 + }, + } + tax_units[f"tax_unit_{key}"] = { + "members": [claimant] if arrangement == "B" else [claimant, member], + "health_savings_account_ald": {year: case["parent_hsa"]}, + "income_tax_refundable_credits": {year: 0}, + } + if arrangement == "B": + tax_units[f"member_tax_unit_{key}"] = { + "members": [member], + "health_savings_account_ald": {year: 0}, + "income_tax_refundable_credits": {year: 0}, + } + marital_units[f"claimant_marital_unit_{key}"] = {"members": [claimant]} + marital_units[f"member_marital_unit_{key}"] = {"members": [member]} + spm_units[f"spm_unit_{key}"] = {"members": [claimant, member]} + families[f"family_{key}"] = {"members": [claimant, member]} + households[f"household_{key}"] = { + "members": [claimant, member], + "state_code": {year: "MT"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "families": families, + "households": households, + } + + +def assert_properties(cases, year): + simulation = Simulation(situation=build_situation(cases, year)) + income = np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit_gross_household_income", year + ) + ).reshape(len(cases), 4, 2) + for index, case in enumerate(cases): + # Schedule SE p.1: net earnings x .9235 x (.124 + .029) / 2. + # Generated positive profits exceed the $400 net-earnings threshold + # and remain below the Social Security wage base. + self_employment_deduction = case["self_employment"] * 0.9235 * 0.153 / 2 + # Pub.590-A (2023) p.11: the deduction cannot exceed contributions, + # compensation, or the annual limit ($6,500 in 2023; $7,000 in + # 2024–25). These single members' MAGI is below the phase-out start. + # Compensation excludes the half-SE-tax and SE retirement deduction. + compensation = case["wages"] + max( + 0, + case["self_employment"] + - self_employment_deduction + - case["pension_contribution"], + ) + ira_deduction = min(case["ira"], compensation, 6_500 if year == 2023 else 7_000) + # 2023 Form1040 instructions p.89: the per-educator cap is $300; + # the cap remains $300 in the other generated years. + educator_deduction = min(case["educator"], 300) + own_deductions = ( + self_employment_deduction + + case["health"] + + case["pension_contribution"] + + ira_deduction + + educator_deduction + + case["penalty"] + ) + parent = case["pension"] - case["parent_hsa"] - case["parent_penalty"] + member = ( + case["wages"] + case["self_employment"] + case["interest"] - own_deductions + ) + tol = max(0.01, 8 * float(np.spacing(np.float32(abs(parent + member))))) + # Each person's non-loss deductions are used once, on that person. + assert income[index, :, 0] == pytest.approx([parent] * 4, abs=tol) + assert income[index, :3, 1] == pytest.approx([member] * 3, abs=tol) + # The filing arrangement leaves total household income unchanged. + assert income[index, 0].sum() == pytest.approx(income[index, 1].sum(), abs=tol) + # Removing capital/rental losses never changes this statutory total. + assert income[index, 0].sum() == pytest.approx(income[index, 2].sum(), abs=tol) + # Another dollar of the dependent's valid penalty is deducted once. + assert income[index, 3, 1] == pytest.approx( + member - case["extra_penalty"], abs=tol + ) + + +def test_worked_deductions_and_losses(): + case = { + "pension": 20_000, + "parent_hsa": 1_000, + "parent_penalty": 50, + "wages": 10_000, + "self_employment": 15_000, + "interest": 500, + "ira": 1_000, + "educator": 100, + "penalty": 200, + "health": 500, + "pension_contribution": 600, + "capital_loss": 3_000, + "rental_loss": 2_000, + "extra_penalty": 300, + } + # Parent = 20,000 - 1,000 - 50 = 18,950. Member = 25,500 - 1,059.71625 + # - 1,000 - 100 - 200 - 500 - 600 = 22,040.28375; extra penalty -> + # 21,740.28375. Neither of the two losses affects these amounts. + assert_properties([case], 2023) + + +@st.composite +def case_strategy(draw): + # Keep health/pension limits nonbinding, including after half-SE-tax: + # at the minimum $5,000 profit, half-SE-tax = $353.23875. With pension + # $500, Form7206 lines7-10,14 allow health up to $4,146.76125; the + # SEP 20% ceiling is ($5,000 - $353.23875) * .2 = $929.35225. + # https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1 + # https://www.irs.gov/pub/irs-prior/p560--2023.pdf#page=35 (PDFpp35-36) + return { + "pension": draw(st.integers(10_000, 35_000)), + "parent_hsa": draw(st.integers(0, 3_000)), + "parent_penalty": draw(st.integers(0, 500)), + "wages": draw(st.integers(10_000, 20_000)), + "self_employment": draw(st.integers(5_000, 15_000)), + "interest": draw(st.integers(0, 10_000)), + "ira": draw(st.integers(0, 10_000)), + "educator": draw(st.integers(0, 1_000)), + "penalty": draw(st.integers(0, 1_000)), + "health": draw(st.integers(0, 500)), + "pension_contribution": draw(st.integers(0, 500)), + "capital_loss": draw(st.integers(0, 20_000)), + "rental_loss": draw(st.integers(0, 20_000)), + "extra_penalty": draw(st.integers(1, 2_000)), + } + + +@settings( + max_examples=15, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + cases=st.lists(case_strategy(), min_size=1, max_size=4), + year=st.sampled_from([2023, 2024, 2025]), +) +def test_own_deductions_and_losses_are_independent_of_filing_arrangement(cases, year): + assert_properties(cases, year) diff --git a/policyengine_us/tests/test_mt_elderly_credit_household_members.py b/policyengine_us/tests/test_mt_elderly_credit_household_members.py new file mode 100644 index 00000000000..d1c1fff15ef --- /dev/null +++ b/policyengine_us/tests/test_mt_elderly_credit_household_members.py @@ -0,0 +1,241 @@ +"""Property tests: household members' income in the Montana elderly +homeowner/renter credit. + +MCA 15-30-2337(4) defines gross household income as "all income received by +all individuals of a household while they are members of the household", and +(9)(a) counts federal adjusted gross income "without regard to loss" plus +nontaxable income, including all Social Security. The 2024 Schedule 2EC lists +the claimant's income by category (line 7: "do not include any capital +losses"; line 6: "the total taxable and nontaxable Social Security benefits") +and puts the income of "other members of your household" on line 17. + +The model reaches the same quantity by two paths. A member who is the +claimant's tax unit dependent is counted through dependent_gross_income and +dependent_taxable_social_security; a member who files their own return is +counted through federal AGI, the loss add-back and taxable_social_security. +For a claimant aged 62 or older (taxable pension, Social Security, property +tax, rent) and one other adult in the household (taxable interest, a +long-term capital gain or loss, Social Security): + +1. The household's gross household income is the claimant's pension and + Social Security plus the other member's interest, positive capital gain + and Social Security, whichever path counts the other member. +2. So the claimant's credit is the same in both arrangements, and the other + member's own tax unit, with no one aged 62 or older, claims nothing. +3. The credit is between zero and the cap, and zero when gross household + income is $45,000 or more (line 29). +4. More interest for the other member never raises the credit: gross + household income rises, so net household income (line 22) cannot fall + and the credit multiplier (line 29) cannot rise. + +No one has earned income, so no refundable credit enters line 8. The model +computes in single precision, so comparisons allow one cent or eight float32 +spacings at the household's total income, whichever is larger. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2024 +CAP = 1_150 +INCOME_LIMIT = 45_000 +TOLERANCE = 0.01 + + +def build_situation(cases): + """Three Montana households per case: (A) the other member is the + claimant's dependent, (B) the other member files their own return and + (C) as A with the other member's extra interest added.""" + people, tax_units, marital_units, spm_units, families, households = ( + {}, + {}, + {}, + {}, + {}, + {}, + ) + for i, c in enumerate(cases): + for arrangement in "ABC": + key = f"{arrangement}{i}" + claimant, member = f"claimant_{key}", f"member_{key}" + interest = c["interest"] + ( + c["extra_interest"] if arrangement == "C" else 0 + ) + people[claimant] = { + "age": {YEAR: c["claimant_age"]}, + "taxable_pension_income": {YEAR: c["pension"]}, + "social_security_retirement": {YEAR: c["claimant_ss"]}, + "real_estate_taxes": {YEAR: c["property_tax"]}, + "rent": {YEAR: c["rent"]}, + } + people[member] = { + "age": {YEAR: c["member_age"]}, + "taxable_interest_income": {YEAR: interest}, + "long_term_capital_gains": {YEAR: c["capital_gain"]}, + "social_security_survivors": {YEAR: c["member_ss"]}, + } + if arrangement == "B": + tax_units[f"tax_unit_{key}"] = {"members": [claimant]} + tax_units[f"tax_unit_{key}_member"] = {"members": [member]} + else: + tax_units[f"tax_unit_{key}"] = {"members": [claimant, member]} + # Set for everyone: an input for some people gives the others + # its default (false), not its formula. Without it the model + # takes an adult in the claimant's tax unit to be the spouse. + people[claimant]["is_tax_unit_dependent"] = {YEAR: False} + people[member]["is_tax_unit_dependent"] = {YEAR: arrangement != "B"} + marital_units[f"marital_unit_{key}"] = {"members": [claimant]} + marital_units[f"marital_unit_{key}_member"] = {"members": [member]} + spm_units[f"spm_unit_{key}"] = {"members": [claimant, member]} + families[f"family_{key}"] = {"members": [claimant, member]} + households[f"household_{key}"] = { + "members": [claimant, member], + "state_code": {YEAR: "MT"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "families": families, + "households": households, + } + + +def calculate(cases): + """Household totals of gross household income and the credit, and the + other member's credit, as arrays indexed [arrangement, case].""" + simulation = Simulation(situation=build_situation(cases)) + gross_household_income = np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit_gross_household_income", + YEAR, + map_to="household", + ) + ) + credit = np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit", YEAR, map_to="household" + ) + ) + person_credit = np.asarray( + simulation.calculate("mt_elderly_homeowner_or_renter_credit", YEAR) + ) + dependent = np.asarray(simulation.calculate("is_tax_unit_dependent", YEAR)) + # Households and people are in situation order: A0, B0, C0, A1, ...; + # the member follows the claimant. + n = len(cases) + return { + "gross_household_income": gross_household_income.reshape(n, 3).T, + "credit": credit.reshape(n, 3).T, + "member_credit": person_credit[1::2].reshape(n, 3).T, + "member_is_dependent": dependent[1::2].reshape(n, 3).T, + } + + +def expected_gross_household_income(c, extra_interest=0): + """2024 Schedule 2EC line 18: lines 5 and 6 for the claimant, and line + 17 for the other member's interest, gains and Social Security.""" + claimant = c["pension"] + c["claimant_ss"] + member = c["interest"] + extra_interest + max(0, c["capital_gain"]) + return claimant + member + c["member_ss"] + + +def tolerance(amount): + return max(TOLERANCE, 8 * float(np.spacing(np.float32(abs(amount))))) + + +def assert_properties(cases): + result = calculate(cases) + a, b, c_ = 0, 1, 2 + # The other member is a dependent in A and C and their own filer in B. + assert result["member_is_dependent"].tolist() == [ + [True] * len(cases), + [False] * len(cases), + [True] * len(cases), + ] + for i, c in enumerate(cases): + expected = expected_gross_household_income(c) + tol = tolerance(expected + c["extra_interest"]) + ghi = result["gross_household_income"] + credit = result["credit"] + # 1. Both paths give the Schedule 2EC total. + assert ghi[a, i] == pytest.approx(expected, abs=tol), c + assert ghi[b, i] == pytest.approx(expected, abs=tol), c + assert ghi[c_, i] == pytest.approx(expected + c["extra_interest"], abs=tol), c + # 2. The claimant's credit does not depend on the arrangement, and + # the other member claims nothing. + assert credit[a, i] == pytest.approx(credit[b, i], abs=tol), c + assert np.all(result["member_credit"][:, i] == 0), c + for arrangement in (a, b, c_): + # 3. Bounds and the income limit. + assert -tol <= credit[arrangement, i] <= CAP + tol, c + if ghi[arrangement, i] >= INCOME_LIMIT + tol: + assert credit[arrangement, i] == 0, c + # 4. More income never raises the credit. + assert credit[c_, i] <= credit[a, i] + tol, c + + +def test_worked_example(): + """2024 Schedule 2EC: line 5 pension 20,000; line 17 the other member's + interest 10,000; line 18 = 30,000; line 22 = (30,000 - 12,600) x 0.05 = + 870; line 27 = 2,000 - 870 = 1,130; line 30 = 1,130 in both + arrangements. With 5,000 more interest, line 18 = 35,000; line 22 = + 1,120; line 28 = 880; line 29 = 40%; line 30 = 352.""" + case = { + "claimant_age": 70, + "pension": 20_000, + "claimant_ss": 0, + "property_tax": 2_000, + "rent": 0, + "member_age": 40, + "interest": 10_000, + "capital_gain": 0, + "member_ss": 0, + "extra_interest": 5_000, + } + result = calculate([case]) + assert result["gross_household_income"][:, 0] == pytest.approx( + [30_000, 30_000, 35_000], abs=TOLERANCE + ) + assert result["credit"][:, 0] == pytest.approx([1_130, 1_130, 352], abs=TOLERANCE) + assert_properties([case]) + + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + # Each example builds a Simulation; on a loaded runner input generation + # can trip the too_slow health check, which says nothing about the model. + SLOW = [hypothesis.HealthCheck.too_slow] + + def amount(high): + return st.one_of(st.just(0), st.integers(min_value=1, max_value=high)) + + @st.composite + def case(draw): + return { + "claimant_age": draw(st.integers(min_value=62, max_value=90)), + "pension": draw(amount(40_000)), + "claimant_ss": draw(amount(30_000)), + "property_tax": draw(st.integers(min_value=0, max_value=4_000)), + "rent": draw(amount(12_000)), + "member_age": draw(st.integers(min_value=15, max_value=61)), + "interest": draw(amount(40_000)), + "capital_gain": draw( + st.one_of(st.just(0), st.integers(min_value=-20_000, max_value=40_000)) + ), + "member_ss": draw(amount(20_000)), + "extra_interest": draw(st.integers(min_value=1, max_value=20_000)), + } + + @hypothesis.settings(max_examples=15, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(case(), min_size=1, max_size=8)) + def test_properties(cases): + assert_properties(cases) diff --git a/policyengine_us/tests/test_mt_elderly_credit_rebate_receipts.py b/policyengine_us/tests/test_mt_elderly_credit_rebate_receipts.py new file mode 100644 index 00000000000..9f44d62671b --- /dev/null +++ b/policyengine_us/tests/test_mt_elderly_credit_rebate_receipts.py @@ -0,0 +1,117 @@ +"""Actual cash receipts enter Montana household income once, without inference. + +SB542 sections 1(3),3(5) require qualifying occupancy and a claim, so a prior +property-tax bill alone cannot establish a receipt. Statutory gross household +income includes all nontaxable income (HB191 section1(9), PDFp2), including +rebate cash in2023 despite omission from that year's illustrative form list. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings, strategies as st + +from policyengine_us import Simulation + + +def build_situation(cases, year, with_receipts): + people, tax_units, households = {}, {}, {} + for i, c in enumerate(cases): + # No receipt input is supplied anywhere in A/B's simulation. Core + # defaults omitted person rows once any row supplies an input, so C + # must be in another simulation to exercise absence of that input. + for arrangement in "C" if with_receipts else "AB": + key = f"{arrangement}{i}" + members = [f"claimant{key}", f"member{key}"] + for j, name in enumerate(members): + people[name] = { + "age": {year: 70 if j == 0 else 40}, + "taxable_pension_income": {year: c["pension"] if j == 0 else 0}, + "is_tax_unit_dependent": {year: False}, + "real_estate_taxes": { + year: c["property_tax"] if j == 0 else 0, + year - 1: c["prior_tax"] + if arrangement == "A" and j == 0 + else 0, + }, + "mt_refundable_credits_before_renter_credit": {year: 0}, + "mt_elderly_homeowner_or_renter_credit_received": {year: 0}, + } + if arrangement == "C": + people[name][ + "mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received" + ] = {year: c["receipts"][j]} + tax_units[f"{name}_return"] = { + "members": [name], + "income_tax_refundable_credits": {year: 0}, + } + households[f"household{key}"] = { + "members": members, + "state_code": {year: "MT", year - 1: "MT"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def calculate(cases, year, with_receipts): + sim = Simulation(situation=build_situation(cases, year, with_receipts)) + shape = (len(cases), 1 if with_receipts else 2) + return { + name: np.asarray(sim.calculate(variable, year, map_to="household")).reshape( + shape + ) + for name, variable in { + "income": "mt_elderly_homeowner_or_renter_credit_gross_household_income", + "credit": "mt_elderly_homeowner_or_renter_credit", + "receipts": "mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received", + }.items() + } + + +def assert_receipts(cases, year): + absent = calculate(cases, year, with_receipts=False) + supplied = calculate(cases, year, with_receipts=True) + for i, c in enumerate(cases): + cash = sum(c["receipts"]) + assert absent["receipts"][i] == pytest.approx([0, 0], abs=0.01) + assert supplied["receipts"][i, 0] == pytest.approx(cash, abs=0.01) + assert absent["income"][i] == pytest.approx([c["pension"]] * 2, abs=0.01) + assert supplied["income"][i, 0] == pytest.approx(c["pension"] + cash, abs=0.01) + assert absent["credit"][i, 0] == pytest.approx(absent["credit"][i, 1], abs=0.01) + assert supplied["credit"][i, 0] <= absent["credit"][i, 0] + 0.01 + return absent["credit"], supplied["credit"] + + +def test_reviewer_unreceived_rebate(): + # No receipt: 1000-(20000-12600)*.035=741. The old assumed400 gave727. + absent, supplied = assert_receipts( + [ + { + "pension": 20000, + "property_tax": 1000, + "prior_tax": 1000, + "receipts": [400, 0], + } + ], + 2025, + ) + assert absent[0] == pytest.approx([741, 741], abs=0.01) + assert supplied[0, 0] == pytest.approx(727, abs=0.01) + + +@settings(max_examples=10, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + cases=st.lists( + st.fixed_dictionaries( + { + "pension": st.integers(0, 45000), + "property_tax": st.integers(0, 4000), + "prior_tax": st.integers(0, 4000), + "receipts": st.lists(st.integers(0, 675), min_size=2, max_size=2), + } + ), + min_size=1, + max_size=4, + ), + year=st.sampled_from([2023, 2024, 2025]), +) +def test_actual_receipts_count_once_and_prior_tax_does_not_imply_receipt(cases, year): + assert_receipts(cases, year) diff --git a/policyengine_us/tests/test_mt_elderly_credit_refundable_credits.py b/policyengine_us/tests/test_mt_elderly_credit_refundable_credits.py new file mode 100644 index 00000000000..a946da6f053 --- /dev/null +++ b/policyengine_us/tests/test_mt_elderly_credit_refundable_credits.py @@ -0,0 +1,201 @@ +"""Property tests: refundable credits in the Montana elderly homeowner/renter +credit's gross household income. + +ARM 42.4.301(2)(b)-(c) counts "federal refundable tax credits received" and +"any state refundable tax credits received" in gross household income, and +the 2024 Schedule 2EC line 8 combines "all the refundable credits received by +all household members". The model sums a person-level gross household income +over the household, so each tax unit's federal refundable credits are put on +its head once. These tests check that allocation for households of a claimant +aged 62 or older and a younger member who files their own return with up to +two children, so that the member can have a federal EITC and a refundable +child tax credit. + +YAML cases cannot compare two simulations or draw household structures, so +this file does both. Each example runs the households twice: as given (W) and +with the federal refundable credits in gross household income set to zero +(Z). Properties, for every household: + +1. Counted once: the household sum of the person-level federal credits + equals the sum of income_tax_refundable_credits over its tax units, read + from the tax-unit array. +2. Accounting: gross household income in W exceeds Z by exactly those + credits. +3. More income never raises the credit: the credit in W is at most Z's. +4. The credit is between zero and the cap, and zero when gross household + income is $45,000 or more. +5. Montana tax changes only through the credit: Montana refundable credits + other than this one are the same in W and Z, and the household's Montana + income tax rises by exactly the fall in its credit. + +The model computes in single precision, so comparisons allow one cent or +eight float32 spacings at the amount compared, whichever is larger. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2024 +CAP = 1_150 +INCOME_LIMIT = 45_000 +TOLERANCE = 0.01 +FEDERAL = "mt_elderly_homeowner_or_renter_credit_federal_refundable_credits" +GROSS = "mt_elderly_homeowner_or_renter_credit_gross_household_income" +CREDIT = "mt_elderly_homeowner_or_renter_credit" + + +def build_situation(cases, zero_federal_credits): + """One Montana household per case: the claimant files alone; the member + files with their children. Also returns each person's and tax unit's + case index.""" + people, tax_units, households, case_of = {}, {}, {}, {} + for i, c in enumerate(cases): + claimant, member = f"claimant_{i}", f"member_{i}" + children = [f"child_{i}_{j}" for j in range(len(c["child_ages"]))] + people[claimant] = { + "age": {YEAR: c["claimant_age"]}, + "taxable_pension_income": {YEAR: c["pension"]}, + "real_estate_taxes": {YEAR: c["property_tax"]}, + "rent": {YEAR: c["rent"]}, + } + people[member] = { + "age": {YEAR: c["member_age"]}, + "employment_income": {YEAR: c["wages"]}, + } + for child, age in zip(children, c["child_ages"]): + people[child] = {"age": {YEAR: age}} + tax_units[f"claimant_unit_{i}"] = {"members": [claimant]} + tax_units[f"member_unit_{i}"] = {"members": [member, *children]} + for name in [claimant, member, *children, *list(tax_units)[-2:]]: + case_of[name] = i + households[f"household_{i}"] = { + "members": [claimant, member, *children], + "state_code": {YEAR: "MT"}, + } + if zero_federal_credits: + # Set for everyone: an input for some people gives the others its + # default, not its formula. + for person in people.values(): + person[FEDERAL] = {YEAR: 0} + situation = {"people": people, "tax_units": tax_units, "households": households} + return situation, case_of + + +def calculate(cases, zero_federal_credits): + """Household totals of person- and tax-unit-level variables, summed by + entity id rather than array position.""" + situation, case_of = build_situation(cases, zero_federal_credits) + simulation = Simulation(situation=situation) + + def by_household(variable, entity): + values = np.asarray(simulation.calculate(variable, YEAR), dtype=float) + totals = np.zeros(len(cases)) + for name, value in zip(simulation.populations[entity].ids, values): + totals[case_of[name]] += value + return totals + + return { + "federal_person": by_household(FEDERAL, "person"), + "federal_tax_unit": by_household("income_tax_refundable_credits", "tax_unit"), + "gross": by_household(GROSS, "person"), + "credit": by_household(CREDIT, "person"), + "mt_other_credits": by_household( + "mt_refundable_credits_before_renter_credit", "person" + ), + "mt_income_tax": by_household("mt_income_tax", "tax_unit"), + } + + +def tolerance(amount): + return max(TOLERANCE, 8 * float(np.spacing(np.float32(abs(amount))))) + + +def assert_properties(cases): + w = calculate(cases, zero_federal_credits=False) + z = calculate(cases, zero_federal_credits=True) + for i, c in enumerate(cases): + tol = tolerance(w["gross"][i]) + federal = w["federal_tax_unit"][i] + # 1. Each tax unit's federal refundable credits count once. + assert w["federal_person"][i] == pytest.approx(federal, abs=tol), c + assert z["federal_person"][i] == 0, c + # 2. They are the whole difference in gross household income. + assert w["gross"][i] - z["gross"][i] == pytest.approx(federal, abs=tol), c + # 3. More income never raises the credit. + assert w["credit"][i] <= z["credit"][i] + tol, c + for result in (w, z): + # 4. Bounds and the income limit. + assert -tol <= result["credit"][i] <= CAP + tol, c + if result["gross"][i] >= INCOME_LIMIT + tol: + assert result["credit"][i] == 0, c + # 5. Montana tax moves only through this credit. + assert w["mt_other_credits"][i] == pytest.approx( + z["mt_other_credits"][i], abs=TOLERANCE + ), c + assert w["mt_income_tax"][i] - z["mt_income_tax"][i] == pytest.approx( + z["credit"][i] - w["credit"][i], abs=tolerance(z["credit"][i]) + ), c + + +def test_worked_example(): + """2024 Schedule 2EC: the claimant's line 5 pension 20,000; the + daughter's wages 10,000 (line 17) and her line 8 credits: federal EITC + 34% x 10,000 = 3,400, refundable child tax credit min(1,700, 15% x + 7,500) = 1,125 and Montana EITC 340. Line 18 = 34,865; line 22 = + (34,865 - 12,600) x 0.05 = 1,113.25; line 27 = 2,000 - 1,113.25 = + 886.75; line 30 = 886.75. Without the federal credits, line 18 = + 30,340, line 22 = 887 and line 30 = 1,113.""" + case = { + "claimant_age": 70, + "pension": 20_000, + "property_tax": 2_000, + "rent": 0, + "member_age": 30, + "wages": 10_000, + "child_ages": [5], + } + w = calculate([case], zero_federal_credits=False) + z = calculate([case], zero_federal_credits=True) + assert w["federal_tax_unit"][0] == pytest.approx(4_525, abs=TOLERANCE) + assert w["gross"][0] == pytest.approx(34_865, abs=TOLERANCE) + assert w["credit"][0] == pytest.approx(886.75, abs=TOLERANCE) + assert z["gross"][0] == pytest.approx(30_340, abs=TOLERANCE) + assert z["credit"][0] == pytest.approx(1_113, abs=TOLERANCE) + assert_properties([case]) + + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + # Each example builds two Simulations; on a loaded runner input + # generation can trip the too_slow health check, which says nothing about + # the model. + SLOW = [hypothesis.HealthCheck.too_slow] + + def amount(high): + return st.one_of(st.just(0), st.integers(min_value=1, max_value=high)) + + @st.composite + def case(draw): + return { + "claimant_age": draw(st.integers(min_value=62, max_value=90)), + "pension": draw(amount(40_000)), + "property_tax": draw(st.integers(min_value=0, max_value=4_000)), + "rent": draw(amount(12_000)), + "member_age": draw(st.integers(min_value=19, max_value=61)), + "wages": draw(amount(30_000)), + "child_ages": draw( + st.lists(st.integers(min_value=0, max_value=16), max_size=2) + ), + } + + @hypothesis.settings(max_examples=10, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(case(), min_size=1, max_size=6)) + def test_properties(cases): + assert_properties(cases) diff --git a/policyengine_us/tests/test_mt_federal_income_tax_deduction_allocation.py b/policyengine_us/tests/test_mt_federal_income_tax_deduction_allocation.py new file mode 100644 index 00000000000..6f26c9dfea1 --- /dev/null +++ b/policyengine_us/tests/test_mt_federal_income_tax_deduction_allocation.py @@ -0,0 +1,209 @@ +"""Invariants of Montana's separate-spouse federal tax allocation. + +The 2022 Form 2 instructions, Itemized Deductions Schedule line 4a (PDF +page 33), attribute federal withholding to the spouse earning the income; +line 4 limits each separately filing spouse's deduction to $5,000: +https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33 + +The model's positive federal-AGI shares approximate spouse-specific payments +because withholding inputs are unavailable. The form does not prescribe an +allocation when neither filer has positive income; the model retains main's +zero allocation in that case rather than inventing a head-only fallback. For +every eligible tax unit, the method must: + +1. With positive filer AGI, allocate all of the couple's federal tax across + head and spouse before each spouse's cap, without any dependent share. +2. Treat negative filer AGI identically to zero AGI and disregard changes + to dependent AGI. +3. Allocate no tax when neither filer has positive AGI, as main does when + its allocation denominator is nonpositive. +4. Keep deductions nonnegative and at most $5,000 per person; conserve + nonnegative federal tax when filer AGI is positive and each spouse's + uncapped deduction is at most $5,000. + +Each generated case is simulated alongside clipped-income, changed-dependent, +and zero-filer-income variants. Comparisons allow one cent for float32 tax +amounts and eight float32 spacings for dimensionless shares. +""" + +import hypothesis +import hypothesis.strategies as st +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2022 +CAP = 5_000 +SHARE_TOLERANCE = 8 * np.finfo(np.float32).eps +AMOUNT_TOLERANCE = 0.01 +ARRANGEMENTS = ("original", "clipped", "dependent", "zero") + + +def build_situation(cases): + people, tax_units, households = {}, {}, {} + for i, case in enumerate(cases): + for arrangement in ARRANGEMENTS: + key = f"{arrangement}_{i}" + head, spouse, dependent = ( + f"head_{key}", + f"spouse_{key}", + f"dependent_{key}", + ) + filer_incomes = [case["head_agi"], case["spouse_agi"]] + if arrangement == "clipped": + filer_incomes = [max(income, 0) for income in filer_incomes] + elif arrangement == "zero": + filer_incomes = [0, 0] + dependent_income = case["dependent_agi"] + if arrangement in ("dependent", "zero"): + dependent_income = abs(dependent_income) + 25_000 + for name, age, income, is_dependent in zip( + (head, spouse, dependent), + (50, 49, 16), + (*filer_incomes, dependent_income), + (False, False, True), + ): + people[name] = { + "age": {YEAR: age}, + "adjusted_gross_income_person": {YEAR: income}, + "is_tax_unit_dependent": {YEAR: is_dependent}, + } + tax_units[f"tax_unit_{key}"] = { + "members": [head, spouse, dependent], + "income_tax_before_refundable_credits": {YEAR: case["federal_tax"]}, + "state_filing_status_if_married_filing_separately_on_same_return": { + YEAR: "SEPARATE" + }, + } + households[f"household_{key}"] = { + "members": [head, spouse, dependent], + "state_code": {YEAR: "MT"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def calculate(cases): + simulation = Simulation(situation=build_situation(cases)) + shape = (len(cases), len(ARRANGEMENTS), 3) + shares = np.asarray( + simulation.calculate("mt_federal_income_tax_deduction_share", YEAR) + ).reshape(shape) + deductions = np.asarray( + simulation.calculate("mt_federal_income_tax_deduction_indiv", YEAR) + ).reshape(shape) + return shares, deductions + + +def assert_properties(cases): + shares, deductions = calculate(cases) + for i, case in enumerate(cases): + case_shares, case_deductions = shares[i], deductions[i] + # Each array is indexed [arrangement, head/spouse/dependent]. + has_positive_filer_income = case["head_agi"] > 0 or case["spouse_agi"] > 0 + assert np.all(case_shares >= 0), case + assert np.all(case_shares <= 1), case + assert case_shares.sum(axis=1) == pytest.approx( + [int(has_positive_filer_income)] * 3 + [0], abs=SHARE_TOLERANCE + ), case + assert np.all(case_shares[:, 2] == 0), case + # Negative income and dependent income cannot shift tax to or away + # from either filer. + for arrangement in (1, 2): + assert case_shares[arrangement] == pytest.approx( + case_shares[0], abs=SHARE_TOLERANCE + ), case + assert case_deductions[arrangement] == pytest.approx( + case_deductions[0], abs=AMOUNT_TOLERANCE + ), case + # The source is silent on zero filer income; preserve main's zero + # allocation, even with a positive-income dependent. + assert case_shares[3] == pytest.approx([0, 0, 0], abs=SHARE_TOLERANCE), case + federal_tax = max(case["federal_tax"], 0) + assert case_deductions[3] == pytest.approx([0, 0, 0], abs=AMOUNT_TOLERANCE), ( + case + ) + assert np.all(case_deductions >= 0), case + assert np.all(case_deductions <= CAP), case + assert np.all(case_deductions[:, 2] == 0), case + if not has_positive_filer_income: + assert np.all(case_deductions == 0), case + elif np.all(case_shares[:3] * federal_tax <= CAP): + assert case_deductions[:3].sum(axis=1) == pytest.approx( + [federal_tax] * 3, abs=AMOUNT_TOLERANCE + ), case + + +def test_worked_allocations(): + cases = [ + # 2_000 * 40_000 / (40_000 + 0) = 2_000; the dependent's + # 10_000 is excluded. An unmasked allocator gives 1_600 / 0 / 400. + { + "head_agi": 40_000, + "spouse_agi": 0, + "dependent_agi": 10_000, + "federal_tax": 2_000, + }, + # Negative head AGI contributes zero; all 2_000 goes to the spouse. + { + "head_agi": -5_000, + "spouse_agi": 10_000, + "dependent_agi": 25_000, + "federal_tax": 2_000, + }, + # Neither filer has positive AGI: main's zero allocation is retained. + { + "head_agi": 0, + "spouse_agi": 0, + "dependent_agi": 10_000, + "federal_tax": 2_000, + }, + { + "head_agi": -5_000, + "spouse_agi": -2_000, + "dependent_agi": 10_000, + "federal_tax": 2_000, + }, + # 9_000 * 40_000 / (40_000 + 40_000) = 4_500 per spouse. + # Neither individual cap binds, so the full 9_000 is conserved. + { + "head_agi": 40_000, + "spouse_agi": 40_000, + "dependent_agi": 10_000, + "federal_tax": 9_000, + }, + ] + shares, deductions = calculate(cases) + assert shares[:, 0] == pytest.approx( + np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 0], [0.5, 0.5, 0]]), + abs=SHARE_TOLERANCE, + ) + assert deductions[:, 0] == pytest.approx( + np.array( + [[2_000, 0, 0], [0, 2_000, 0], [0, 0, 0], [0, 0, 0], [4_500, 4_500, 0]] + ), + abs=AMOUNT_TOLERANCE, + ) + + +@hypothesis.settings( + max_examples=10, + deadline=None, + suppress_health_check=[hypothesis.HealthCheck.too_slow], +) +@hypothesis.given( + st.lists( + st.fixed_dictionaries( + { + "head_agi": st.integers(min_value=-100_000, max_value=100_000), + "spouse_agi": st.integers(min_value=-100_000, max_value=100_000), + "dependent_agi": st.integers(min_value=-100_000, max_value=100_000), + "federal_tax": st.integers(min_value=-5_000, max_value=30_000), + } + ), + min_size=1, + max_size=6, + ) +) +def test_allocation_properties(cases): + assert_properties(cases) diff --git a/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py new file mode 100644 index 00000000000..862276999d7 --- /dev/null +++ b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py @@ -0,0 +1,80 @@ +"""The NY Drive Clean rebate schedule changes for vehicles purchased after +June 30, 2021. + +Section 4 of the NYSERDA Drive Clean Implementation Manual (last updated June +30, 2021) has one table for vehicles purchased on or before June 30, 2021 and +another for vehicles purchased after it, with the MSRP cap for the flat $500 +rebate falling from $60,000 to $42,000. The new amounts therefore start on +July 1, 2021. Calendar-year simulations read parameters on January 1; +first-of-month rolling-year YAML tests exercise the formula in June and July. +Core 3.32.15 rejects caching mid-month annual periods, so these parameter tests +cover the exact June 30 boundary and randomized schedule invariants. +Source: https://www.nyserda.ny.gov/-/media/Project/Nyserda/Files/Programs/Drive-Clean-NY/implementation-manual.pdf#page=8 +""" + +from datetime import date + +import numpy as np +import pytest +from hypothesis import example, given +from hypothesis import strategies as st + +from policyengine_us.system import system + +DRIVE_CLEAN = system.parameters.gov.states.ny.nyserda.drive_clean +ALL_ELECTRIC_RANGES = np.array([10, 20, 39, 40, 100, 120, 199, 200]) +# The manual's section 4 tables, at ranges unaffected by the disclosed +# pre-existing 119-mile threshold discrepancy. +OLD_AMOUNTS = [500, 1_100, 1_100, 1_700, 1_700, 2_000, 2_000, 2_000] +NEW_AMOUNTS = [500, 500, 500, 1_000, 1_000, 1_000, 1_000, 2_000] + + +@pytest.mark.parametrize( + "instant, amounts, msrp_threshold", + [ + ( + "2021-06-30", + OLD_AMOUNTS, + 60_000, + ), + ( + "2021-07-01", + NEW_AMOUNTS, + 42_000, + ), + ], +) +def test_drive_clean_schedule_changes_after_june_30_2021( + instant, amounts, msrp_threshold +): + scale = DRIVE_CLEAN.amount(instant) + + assert scale.calc(ALL_ELECTRIC_RANGES).tolist() == amounts + assert DRIVE_CLEAN.flat_rebate.msrp_threshold(instant) == msrp_threshold + + +@given(st.dates(min_value=date(2017, 3, 21), max_value=date(2022, 12, 31))) +@example(date(2021, 6, 30)) +@example(date(2021, 7, 1)) +def test_drive_clean_schedule_is_stable_on_each_side_of_transition(calendar_date): + before_july = calendar_date < date(2021, 7, 1) + amounts = OLD_AMOUNTS if before_july else NEW_AMOUNTS + msrp_threshold = 60_000 if before_july else 42_000 + instant = calendar_date.isoformat() + + assert DRIVE_CLEAN.amount(instant).calc(ALL_ELECTRIC_RANGES).tolist() == amounts + assert DRIVE_CLEAN.flat_rebate.msrp_threshold(instant) == msrp_threshold + + +@given(st.integers(min_value=2018, max_value=2035)) +@example(2021) +@example(2022) +def test_drive_clean_january_first_lookups_retain_calendar_year_schedule(year): + # All corrected dates lie after January 1, 2021 and before January 1, 2022. + # Calendar-year lookups therefore retain the same old/new source values. + instant = f"{year}-01-01" + amounts = OLD_AMOUNTS if year <= 2021 else NEW_AMOUNTS + msrp_threshold = 60_000 if year <= 2021 else 42_000 + + assert DRIVE_CLEAN.amount(instant).calc(ALL_ELECTRIC_RANGES).tolist() == amounts + assert DRIVE_CLEAN.flat_rebate.msrp_threshold(instant) == msrp_threshold diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index 0899577cbed..5a4899c5de8 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -15,12 +15,16 @@ a reachable bottom row, so duplicates there are flagged. """ +import calendar import math +import re from collections import defaultdict from datetime import date import pytest -from policyengine_core.parameters import ParameterNode, ParameterScale +from hypothesis import example, given +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale from policyengine_core.parameters.operations.uprate_parameters import ( uprate_parameters, ) @@ -212,10 +216,18 @@ def test_duplicate_threshold_guard_reads_tied_dates_like_core(): assert _duplicate_threshold_errors(distinct) == [] +# Several parameter files key a value at 0000-01-01 to mean "since the +# beginning of time" (see tests/code_health/test_uprating_placement.py). Python +# dates have no year zero, so it is the one instant that is not a calendar date. +YEAR_ZERO_SENTINEL = "0000-01-01" + + def _is_calendar_date(instant_str): - # Core's public lookup parses the instant, which fails for padded - # 0000-01-01 entries and for 2021-06-31 in - # gov.states.ny.nyserda.drive_clean.amount. + # Core compares instants as strings and its INSTANT_PATTERN checks only the + # digit ranges, so an impossible day such as 2021-06-31 loads and sorts + # without error while naming no date. + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", instant_str) is None: + return False try: date.fromisoformat(instant_str) except ValueError: @@ -223,6 +235,110 @@ def _is_calendar_date(instant_str): return True +def _non_calendar_instants(parameter): + return [ + value_at_instant.instant_str + for value_at_instant in parameter.values_list + if value_at_instant.instant_str != YEAR_ZERO_SENTINEL + and not _is_calendar_date(value_at_instant.instant_str) + ] + + +def test_calendar_date_guard_flags_impossible_dates_only(): + parameter = Parameter( + "test_parameter", + { + "values": { + YEAR_ZERO_SENTINEL: 0, + "2021-06-30": 1, + "2021-06-31": 2, + "2024-02-29": 3, + "2025-02-29": 4, + "2025-04-31": 5, + } + }, + ) + + assert sorted(_non_calendar_instants(parameter)) == [ + "2021-06-31", + "2025-02-29", + "2025-04-31", + ] + # Forms core's INSTANT_PATTERN or date.fromisoformat accept that are not + # full YYYY-MM-DD days. + for instant_str in ["2021", "2021-06", "20210630", "2021-W26-3"]: + assert not _is_calendar_date(instant_str) + + +@given(st.dates()) +@example(date(2000, 2, 29)) +@example(date(2024, 2, 29)) +def test_calendar_date_guard_accepts_real_calendar_dates(calendar_date): + assert _is_calendar_date(calendar_date.isoformat()) + + +@given( + year=st.integers(min_value=1, max_value=9999), + month=st.integers(min_value=1, max_value=12), + day=st.integers(min_value=1, max_value=31), +) +@example(year=2021, month=6, day=31) +@example(year=1900, month=2, day=29) +@example(year=2000, month=2, day=29) +def test_calendar_date_guard_matches_gregorian_month_lengths(year, month, day): + instant_str = f"{year:04d}-{month:02d}-{day:02d}" + # The Gregorian month-length oracle independently checks impossible days, + # including century years that are leap years only when divisible by 400. + assert _is_calendar_date(instant_str) == ( + day <= calendar.monthrange(year, month)[1] + ) + + +@given(st.dates()) +@example(date(2021, 6, 30)) +def test_calendar_date_guard_requires_full_calendar_form(calendar_date): + canonical = calendar_date.isoformat() + iso_year, iso_week, iso_day = calendar_date.isocalendar() + alternatives = ( + canonical[:4], + canonical[:7], + canonical.replace("-", ""), + f"{iso_year:04d}-W{iso_week:02d}-{iso_day}", + ) + for instant_str in alternatives: + assert not _is_calendar_date(instant_str) + + +@given( + month=st.integers(min_value=1, max_value=12), + day=st.integers(min_value=1, max_value=31), +) +@example(month=1, day=1) +@example(month=1, day=2) +@example(month=2, day=29) +def test_calendar_date_guard_exempts_only_beginning_of_time(month, day): + instant_str = f"0000-{month:02d}-{day:02d}" + parameter = Parameter( + "test_parameter", + {"values": {YEAR_ZERO_SENTINEL: 0, instant_str: 1}}, + ) + # Year zero is not a calendar year; only the exact documented sentinel is + # exempt when validating the parameter's loaded instants. + expected = [] if instant_str == YEAR_ZERO_SENTINEL else [instant_str] + assert _non_calendar_instants(parameter) == expected + + +def test_parameter_instants_are_calendar_dates(): + errors = [ + f"{parameter.name}: {instant_str}" + for parameter in system.parameters.get_descendants() + if isinstance(parameter, Parameter) + for instant_str in _non_calendar_instants(parameter) + ] + + assert not errors, "\n".join(errors) + + def test_add_bracket_thresholds_match_core_for_every_scale_and_instant(monkeypatch): # Record every threshold core passes to add_bracket, repeats included, and # compare that multiset with the guard's; comparing the built scale's @@ -240,7 +356,8 @@ def recording_add_bracket(self, threshold, value, original=original): mismatches = [] for scale in _all_scales(): for instant_str in _change_instants(scale): - if not _is_calendar_date(instant_str): + if instant_str == YEAR_ZERO_SENTINEL: + # Core's public lookup cannot parse year zero. continue passed.clear() scale(instant_str) diff --git a/policyengine_us/tests/unit/test_ca_mn_casualty_loss_deduction_properties.py b/policyengine_us/tests/unit/test_ca_mn_casualty_loss_deduction_properties.py new file mode 100644 index 00000000000..96193c6eb9b --- /dev/null +++ b/policyengine_us/tests/unit/test_ca_mn_casualty_loss_deduction_properties.py @@ -0,0 +1,211 @@ +"""Properties of California's and Minnesota's casualty loss deductions. + +Both states allow a personal casualty or theft loss under 26 U.S.C. 165(h) +without the federal limit to declared disasters (Cal. Rev. & Tax. Code +17204(b), Schedule CA (540) line 15; Minn. Stat. 290.0122, subd. 8, Schedule +M1CAT). Each reduces the loss by $100 (Form 4684 line 11; M1CAT line 11) and +allows the rest above 10% of federal adjusted gross income (Form 4684 line 17 +and FTB Publication 1034; M1CAT lines 18-19). A joint return is one individual +for the $100 rule, and a dependent's loss belongs on the dependent's own +return. The model records one loss amount per person and no event count, so a +return's losses are treated as one casualty. + +`ca_casualty_loss_deduction` and `mn_casualty_loss_deduction` are two copies +of that computation for an ordinary loss. The model has no disaster input and +no Minnesota Schedule M1NC, so the properties below do not cover the 2020 +Schedule M1SA's net qualified disaster loss or the 2018 M1CAT's Schedule M1NC +line 38 base. Hypothesis draws batches of tax units (single or joint, +with up to two dependents who have their own losses), places each in +California and in Minnesota, and runs each batch as one vectorized baseline +simulation. For every tax unit and year: + +1. Each copy equals a closed-form reference on the head's and spouse's losses: + max(max(loss - 100, 0) - 10% x max(AGI, 0), 0). Dependents' losses never + enter it, and neither does California AGI. +2. Bounds: 0 <= deduction <= max(loss - 100, 0). +3. Each copy is zero outside its state. +4. Differential: the two copies agree on the same drawn units. +5. The federal `casualty_loss_deduction` is zero in these years, so neither + state follows the federal suspension. + +A deterministic grid adds the boundary inputs ($0, $99, $100, $101, the floor +plus $100) and checks monotonicity: each copy rises with the loss and falls +with AGI, and is zero for a loss of $100 or less. +""" + +import itertools + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +REDUCTION = 100 # Form 4684 line 11; Schedule M1CAT line 11 +FLOOR = 0.1 # Form 4684 line 17; Schedule M1CAT line 19 +TOLERANCE = 0.01 +CA_AGI_OFFSET = 7_000 # makes California AGI differ from federal AGI +# 2018 is the first year of the federal suspension; 2026 follows California's +# move to a January 1, 2025 conformity date. +YEARS = [2018, 2026] +STATE_OF = { + "ca_casualty_loss_deduction": "CA", + "mn_casualty_loss_deduction": "MN", +} +STATES = list(STATE_OF.values()) + + +def reference(loss, agi): + reduced = np.maximum(loss - REDUCTION, 0) + return np.maximum(reduced - FLOOR * np.maximum(agi, 0), 0) + + +def every_year(value): + return {year: value for year in YEARS} + + +def build_situation(units): + """Place each unit in each state, with the same federal AGI.""" + people, tax_units, households = {}, {}, {} + rows = [] # (filer loss, AGI, state) for each tax unit, in order + for state in STATES: + for i, unit in enumerate(units): + name = f"{state}{i}" + members = [f"{name}_head"] + people[members[0]] = { + "age": every_year(40), + "casualty_loss": every_year(unit["head_loss"]), + } + filer_loss = unit["head_loss"] + if unit["joint"]: + members.append(f"{name}_spouse") + people[members[-1]] = { + "age": every_year(40), + "casualty_loss": every_year(unit["spouse_loss"]), + } + filer_loss += unit["spouse_loss"] + for d, dependent_loss in enumerate(unit["dependent_losses"]): + members.append(f"{name}_dep{d}") + people[members[-1]] = { + "age": every_year(10), + "is_tax_unit_dependent": every_year(True), + "casualty_loss": every_year(dependent_loss), + } + agi = unit["agi"] + tax_units[name] = { + "members": members, + "adjusted_gross_income": every_year(agi), + # California's floor is on federal AGI (FTB Publication 1034), + # so a different California AGI must not move its copy. + "ca_agi": every_year(agi + CA_AGI_OFFSET), + } + households[name] = {"members": members, "state_code": every_year(state)} + rows.append((filer_loss, agi, state)) + situation = {"people": people, "tax_units": tax_units, "households": households} + loss, agi, state = (np.array(column) for column in zip(*rows)) + return situation, loss.astype(float), agi.astype(float), state + + +def check_reference_and_bounds(sim, loss, agi, state): + for variable, own_state in STATE_OF.items(): + for year in YEARS: + value = sim.calculate(variable, year) + expected = np.where(state == own_state, reference(loss, agi), 0) + np.testing.assert_allclose( + value, expected, atol=TOLERANCE, err_msg=f"{variable} {year}" + ) + assert np.all(value >= 0) + assert np.all(value <= np.maximum(loss - REDUCTION, 0) + TOLERANCE) + + +def check_copies_agree(sim, state): + for year in YEARS: + california = sim.calculate("ca_casualty_loss_deduction", year) + minnesota = sim.calculate("mn_casualty_loss_deduction", year) + # The same drawn units, in the same order, sit in each state. + np.testing.assert_allclose( + california[state == "CA"], minnesota[state == "MN"], atol=TOLERANCE + ) + # Neither state follows the federal suspension. + assert np.all(sim.calculate("casualty_loss_deduction", year) == 0) + + +money = st.one_of( + st.sampled_from([0, 50, 99, 100, 101, 150]), + st.integers(0, 60_000), +).map(float) +agis = st.one_of( + st.sampled_from([0, 1_000, 50_000]), + st.integers(-20_000, 300_000), +).map(float) +tax_unit_strategy = st.fixed_dictionaries( + { + "joint": st.booleans(), + "head_loss": money, + "spouse_loss": money, + "dependent_losses": st.lists(money, max_size=2), + "agi": agis, + } +) +SETTINGS = dict( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_unit_strategy, min_size=4, max_size=15)) +def test_copies_match_the_form_4684_reference(units): + situation, loss, agi, state = build_situation(units) + sim = Simulation(situation=situation) + check_reference_and_bounds(sim, loss, agi, state) + check_copies_agree(sim, state) + + +GRID_LOSSES = [0, 50, 99, 100, 101, 150, 5_000, 10_100, 10_101, 30_000] +GRID_AGIS = [-10_000, 0, 1_000, 50_000, 100_000, 300_000] +GRID_DEPENDENT_LOSSES = [0, 20_000] + + +def grid_situation(): + units = [ + { + "joint": False, + "head_loss": float(loss), + "spouse_loss": 0.0, + "dependent_losses": [float(dependent_loss)], + "agi": float(agi), + } + for loss, agi, dependent_loss in itertools.product( + GRID_LOSSES, GRID_AGIS, GRID_DEPENDENT_LOSSES + ) + ] + return build_situation(units) + + +@pytest.fixture(scope="module") +def grid(): + situation, loss, agi, state = grid_situation() + return Simulation(situation=situation), loss, agi, state + + +@pytest.mark.parametrize("variable, year", list(itertools.product(STATE_OF, YEARS))) +def test_grid_monotone_and_boundaries(grid, variable, year): + sim, loss, agi, state = grid + rows = state == STATE_OF[variable] + value = sim.calculate(variable, year)[rows] + np.testing.assert_allclose(value, reference(loss[rows], agi[rows]), atol=TOLERANCE) + shape = (len(GRID_LOSSES), len(GRID_AGIS), len(GRID_DEPENDENT_LOSSES)) + table = value.reshape(shape) + # A dependent's loss never changes the filer's deduction. + np.testing.assert_allclose(table[..., 0], table[..., 1], atol=TOLERANCE) + assert np.all(np.diff(table, axis=0) >= -TOLERANCE), "falls as the loss rises" + assert np.all(np.diff(table, axis=1) <= TOLERANCE), "rises as AGI rises" + # A loss of $100 or less is absorbed by the $100 reduction. + small = np.array(GRID_LOSSES) <= REDUCTION + assert np.all(table[small] == 0) + # Guard against a vacuous pass. + assert table.max() > 0 diff --git a/policyengine_us/tests/unit/test_ira_invariants.py b/policyengine_us/tests/unit/test_ira_invariants.py new file mode 100644 index 00000000000..d26e527c899 --- /dev/null +++ b/policyengine_us/tests/unit/test_ira_invariants.py @@ -0,0 +1,504 @@ +"""Differential tests for the IRA rules in 26 U.S.C. 219(b), (c), and (g). + +Each batch is an ordinary situation containing independent households. These +tests compare the vectorized model with a Decimal implementation of the law, +using the amounts published in IRS Notices 2014-70, 2015-75, 2016-62, 2017-64, +2018-83, 2019-59, 2020-79, 2021-61, 2022-55, 2023-75, 2024-80, and 2025-67. +No model parameters or formula helpers supply the expected results. + +The grids check that deductible dollar limits fall with MAGI, are rounded in +$10 increments, retain the $200 floor until complete phase-out, and distinguish +own participation from a spouse's participation. Contribution grids check +individual dollar limits, joint compensation conservation, role symmetry, +the strict lower-compensation rule, proportional allocation, and exclusion of +dependent contributions from the filers' deduction. +""" + +from decimal import Decimal, ROUND_FLOOR +from itertools import product + +import numpy as np +import pytest + +from policyengine_us import Simulation + + +# Year: (active single start, active joint start, nonactive spouse start, +# ordinary dollar limit, age-50 catch-up). +NOTICE_AMOUNTS = { + 2015: (61_000, 98_000, 183_000, 5_500, 1_000), + 2016: (61_000, 98_000, 184_000, 5_500, 1_000), + 2017: (62_000, 99_000, 186_000, 5_500, 1_000), + 2018: (63_000, 101_000, 189_000, 5_500, 1_000), + 2019: (64_000, 103_000, 193_000, 6_000, 1_000), + 2020: (65_000, 104_000, 196_000, 6_000, 1_000), + 2021: (66_000, 105_000, 198_000, 6_000, 1_000), + 2022: (68_000, 109_000, 204_000, 6_000, 1_000), + 2023: (73_000, 116_000, 218_000, 6_500, 1_000), + 2024: (77_000, 123_000, 230_000, 7_000, 1_000), + 2025: (79_000, 126_000, 236_000, 7_000, 1_000), + 2026: (81_000, 129_000, 242_000, 7_500, 1_100), +} + + +def blank_situation(): + return { + entity: {} + for entity in ( + "people", + "marital_units", + "tax_units", + "spm_units", + "families", + "households", + ) + } + + +def add_household( + situation, tag, year, person_inputs, tax_inputs, *, couple=False, separate=False +): + """Add a household, returning its persons' positions in calculated arrays.""" + positions = [] + names = [] + for index, inputs in enumerate(person_inputs): + name = f"{tag}_p{index}" + positions.append(len(situation["people"])) + names.append(name) + values = { + "age": 40, + "is_tax_unit_head": index == 0 or (separate and index == 1), + "is_tax_unit_spouse": couple and not separate and index == 1, + "is_tax_unit_dependent": index >= 2, + **inputs, + } + situation["people"][name] = { + variable: {year: value} for variable, value in values.items() + } + if separate: + for index, name in enumerate(names): + situation["tax_units"][f"{tag}_t{index}"] = { + "members": [name], + **{variable: {year: value} for variable, value in tax_inputs.items()}, + } + else: + situation["tax_units"][f"{tag}_t"] = { + "members": names, + **{variable: {year: value} for variable, value in tax_inputs.items()}, + } + groups = [names[:2]] if couple else [] + groups += [[name] for name in names[2 if couple else 0 :]] + for index, members in enumerate(groups): + situation["marital_units"][f"{tag}_m{index}"] = {"members": members} + for entity in ("spm_units", "families", "households"): + situation[entity][tag] = {"members": names} + situation["households"][tag]["state_code"] = {year: "TX"} + return positions + + +def statutory_phase_out(year, status, active, spouse_active, cohabitating): + """Return the applicable range, or None if section 219(g) does not apply.""" + single_start, joint_start, spouse_start, _, _ = NOTICE_AMOUNTS[year] + if status == "SEPARATE": + if cohabitating: + return (0, 10_000) if active or spouse_active else None + return (single_start, 10_000) if active else None + if active: + if status in ("JOINT", "SURVIVING_SPOUSE"): + return joint_start, 20_000 + return single_start, 10_000 + if status == "JOINT" and spouse_active: + return spouse_start, 10_000 + return None + + +def decimal_deductible_limit(limit, magi, phase_out): + """Apply 219(g)(2) directly, rounding the reduction downward to $10.""" + if phase_out is None: + return limit + start, width = map(Decimal, phase_out) + excess = max(Decimal(0), Decimal(str(magi)) - start) + if excess >= width: + return 0 + reduction = Decimal(limit) * excess / width + rounded_reduction = (reduction / 10).to_integral_value(rounding=ROUND_FLOOR) * 10 + return float(max(Decimal(200), Decimal(limit) - rounded_reduction)) + + +@pytest.mark.parametrize("year", NOTICE_AMOUNTS) +def test_phase_out_matches_statute_across_statuses_and_years(year): + routes = [ + ("SINGLE", True, False, False), + ("HEAD_OF_HOUSEHOLD", True, False, False), + ("SURVIVING_SPOUSE", True, False, False), + ("JOINT", True, False, False), + ("JOINT", True, True, False), + ("JOINT", False, True, False), + ("JOINT", False, False, False), + ("SEPARATE", True, False, True), + ("SEPARATE", False, True, True), + ("SEPARATE", False, False, True), + ("SEPARATE", True, True, False), + ("SEPARATE", False, True, False), + ] + situation = blank_situation() + expected, target_positions, dollar_limits, groups = [], [], [], [] + for route, age in product(routes, (49, 50)): + status, active, spouse_active, cohabitating = route + phase_out = statutory_phase_out(year, *route) + start, width = phase_out or (NOTICE_AMOUNTS[year][0], 10_000) + limit = NOTICE_AMOUNTS[year][3] + (NOTICE_AMOUNTS[year][4] if age >= 50 else 0) + offsets = [ + -1, + 0, + 1, + 25, + width // 3, + width // 2, + width - 301, + width - 251, + width - 1, + width, + width + 1, + ] + group = [] + for offset in offsets: + magi = start + offset + people = [{"age": age, "ira_active_participant": active}] + couple = status in ("JOINT", "SEPARATE") + if couple: + people.append({"age": age, "ira_active_participant": spouse_active}) + positions = add_household( + situation, + f"case{len(expected)}", + year, + people, + { + "filing_status": status, + "cohabitating_spouses": cohabitating, + "ira_219g_magi": magi, + }, + couple=couple, + separate=status == "SEPARATE", + ) + group.append(len(expected)) + target_positions.append(positions[0]) + dollar_limits.append(limit) + expected.append(decimal_deductible_limit(limit, magi, phase_out)) + groups.append(group) + simulation = Simulation(situation=situation) + actual = simulation.calculate("ira_219g_deductible_limit", year)[target_positions] + np.testing.assert_allclose(actual, expected, rtol=0, atol=0.01) + assert np.all(actual >= 0) + assert np.all(actual <= dollar_limits) + assert np.all((actual == 0) | (actual >= 200)) + np.testing.assert_allclose(actual % 10, 0, rtol=0, atol=0.01) + for group in groups: + assert np.all(np.diff(actual[group]) <= 0), "deductible limit rose with MAGI" + + +def reference_joint_contributions(compensation, desired, dollar_limits): + """219(c)(2) applies only to the spouse with strictly less compensation.""" + contributions = [ + min(compensation[index], desired[index], dollar_limits[index]) + for index in (0, 1) + ] + for index in (0, 1): + spouse = 1 - index + if compensation[index] < compensation[spouse]: + available = compensation[index] + max( + compensation[spouse] - contributions[spouse], 0 + ) + contributions[index] = min(available, desired[index], dollar_limits[index]) + return contributions + + +def test_joint_contributions_conserve_compensation_and_exclude_dependents(): + year = 2026 + compensation_grid = (0, 0.5, 1_000, 3_500, 7_500, 10_000) + allocations = ((0, 0), (0.25, 0.25), (1_000, 0), (0, 7_000), (20_000, 20_000)) + situation = blank_situation() + expected, desired_traditional, desired_total, compensation_total = [], [], [], [] + swapped_cases = {} + dollar_limits = (7_500, 7_500) + for case, (head_comp, spouse_comp, head_allocation, spouse_allocation) in enumerate( + product(compensation_grid, compensation_grid, allocations, allocations) + ): + compensation = (head_comp, spouse_comp) + total_desired = tuple( + sum(values) for values in (head_allocation, spouse_allocation) + ) + contribution = reference_joint_contributions( + compensation, total_desired, dollar_limits + ) + people = [] + for comp, allocation in zip(compensation, (head_allocation, spouse_allocation)): + people.append( + { + "ira_compensation": comp, + "traditional_ira_contributions_desired": allocation[0], + "roth_ira_contributions_desired": allocation[1], + } + ) + # A working dependent has lawful IRA contributions on their own return. + people.append( + { + "age": 17, + "ira_compensation": 2_000, + "traditional_ira_contributions_desired": 1_000, + "roth_ira_contributions_desired": 500, + } + ) + add_household( + situation, + f"case{case}", + year, + people, + {"filing_status": "JOINT", "ira_219g_magi": 0}, + couple=True, + ) + expected.extend([*contribution, 1_500]) + desired_traditional.extend([head_allocation[0], spouse_allocation[0], 1_000]) + desired_total.extend([*total_desired, 1_500]) + compensation_total.append(sum(compensation)) + swapped_cases[(head_comp, spouse_comp, head_allocation, spouse_allocation)] = ( + case + ) + simulation = Simulation(situation=situation) + traditional = simulation.calculate("traditional_ira_contributions", year) + roth = simulation.calculate("roth_ira_contributions", year) + total = traditional + roth + deduction = simulation.calculate("traditional_ira_deduction", year) + np.testing.assert_allclose(total, expected, rtol=1e-6, atol=0.001) + assert np.all(total >= 0) + assert np.all(total <= 7_500.01) + assert np.all(total <= np.array(desired_total) + 0.01) + contributions = total.reshape(-1, 3) + assert np.all( + contributions[:, :2].sum(axis=1) <= np.array(compensation_total) + 0.01 + ) + # The dependent's own-return deduction is recorded on the dependent: their + # $1,000 traditional contribution fits their $2,000 of compensation. The + # filers' above-the-line total sums the head and spouse only. + np.testing.assert_allclose(deduction.reshape(-1, 3)[:, 2], 1_000, atol=0.001) + filer_total = simulation.calculate("above_the_line_deductions", year) + np.testing.assert_allclose( + filer_total, deduction.reshape(-1, 3)[:, :2].sum(axis=1), atol=0.01 + ) + np.testing.assert_allclose( + deduction.reshape(-1, 3)[:, :2], traditional.reshape(-1, 3)[:, :2] + ) + # Preserve the existing allocation of contributions between IRA types. + expected_traditional = np.divide( + np.array(expected) * desired_traditional, + desired_total, + out=np.zeros(len(expected)), + where=np.array(desired_total) > 0, + ) + np.testing.assert_allclose(traditional, expected_traditional, rtol=1e-6, atol=0.001) + for ( + head_comp, + spouse_comp, + head_allocation, + spouse_allocation, + ), case in swapped_cases.items(): + swapped = swapped_cases[ + (spouse_comp, head_comp, spouse_allocation, head_allocation) + ] + np.testing.assert_allclose( + contributions[case, :2], contributions[swapped, 1::-1] + ) + + +def test_compensation_excludes_net_self_employment_losses(): + year = 2026 + situation = blank_situation() + expected = [] + for case, (wages, net_earnings, deductible_tax, retirement) in enumerate( + product((0, 0.5, 1_000, 20_000), (-5_000, 0, 10_000), (0, 500), (0, 2_000)) + ): + add_household( + situation, + f"case{case}", + year, + [ + { + "irs_employment_income": wages, + "self_employment_income": net_earnings / 4, + "sstb_self_employment_income": net_earnings / 4, + "farm_operations_income": net_earnings / 4, + "partnership_self_employment_net_earnings": net_earnings / 4, + "self_employment_tax_ald_person": deductible_tax, + "self_employed_pension_contribution_ald_person": retirement, + } + ], + {"filing_status": "SINGLE"}, + ) + expected.append(wages + max(net_earnings - deductible_tax - retirement, 0)) + simulation = Simulation(situation=situation) + np.testing.assert_allclose( + simulation.calculate("ira_compensation", year), expected, rtol=0, atol=0.001 + ) + + +def test_supplied_contributions_consume_the_limit_before_generated_ones(): + """Actual IRA contributions supplied as inputs use up compensation first. + + 26 U.S.C. 219(c)(1)(B) reduces the lower-compensation spouse's limit by the + other spouse's deductible and Roth contributions, and 408A(c)(2) shares one + dollar limit between a person's traditional and Roth contributions. So a + supplied Roth amount leaves only the remainder for generated contributions, + whether it is the spouse's or the person's own. + """ + year = 2026 + dollar_limit = 7_500 + situation = blank_situation() + expected_spouse, conserved = [], [] + head_compensation = (0, 5_000, 10_000, 20_000) + supplied_roth = (0, 2_000, 7_000, 9_000) + for case, (comp, roth) in enumerate(product(head_compensation, supplied_roth)): + add_household( + situation, + f"couple{case}", + year, + [ + {"ira_compensation": comp, "roth_ira_contributions": roth}, + { + "ira_compensation": 0, + "roth_ira_contributions": 0, + "traditional_ira_contributions_desired": 7_500, + }, + ], + {"filing_status": "JOINT", "ira_219g_magi": 0}, + couple=True, + ) + # The spouse has no compensation, so only the spousal rule can apply, + # and only when the other spouse has compensation to share. + expected_spouse.append(min(7_500, dollar_limit, max(0, comp - roth))) + conserved.append(roth <= comp) + singles = (0, 2_000, 7_500) + for case, roth in enumerate(singles): + add_household( + situation, + f"single{case}", + year, + [ + { + "ira_compensation": 20_000, + "roth_ira_contributions": roth, + "traditional_ira_contributions_desired": 7_500, + } + ], + {"filing_status": "SINGLE", "ira_219g_magi": 0}, + ) + simulation = Simulation(situation=situation) + traditional = simulation.calculate("traditional_ira_contributions", year) + roth = simulation.calculate("roth_ira_contributions", year) + couples = len(head_compensation) * len(supplied_roth) + couple_traditional = traditional[: 2 * couples].reshape(-1, 2) + couple_roth = roth[: 2 * couples].reshape(-1, 2) + np.testing.assert_allclose(couple_traditional[:, 1], expected_spouse, atol=0.001) + np.testing.assert_allclose(couple_traditional[:, 0], 0, atol=0) + # Joint contributions never exceed joint compensation when the supplied + # amount itself fits within it. + totals = couple_traditional.sum(axis=1) + couple_roth.sum(axis=1) + compensation = np.repeat(head_compensation, len(supplied_roth)) + assert np.all( + totals[np.array(conserved)] <= compensation[np.array(conserved)] + 0.01 + ) + single_traditional = traditional[2 * couples :] + np.testing.assert_allclose( + single_traditional, [dollar_limit - s for s in singles], atol=0.001 + ) + + +def test_contributions_set_after_construction_count_as_supplied(): + """A contribution given through set_input is supplied, not generated. + + YAML tests pass inputs at construction, so this needs the simulation API. + A single filer with $20,000 of compensation sets a $2,000 Roth + contribution after the simulation is built; the $7,500 limit then leaves + $5,500 for the desired traditional contribution (26 U.S.C. 408A(c)(2)). + """ + year = 2026 + situation = blank_situation() + add_household( + situation, + "single", + year, + [{"ira_compensation": 20_000, "traditional_ira_contributions_desired": 7_500}], + {"filing_status": "SINGLE", "ira_219g_magi": 0}, + ) + simulation = Simulation(situation=situation) + simulation.set_input("roth_ira_contributions", year, np.array([2_000.0])) + traditional = simulation.calculate("traditional_ira_contributions", year) + roth = simulation.calculate("roth_ira_contributions", year) + np.testing.assert_allclose(traditional, [5_500], atol=0.001) + np.testing.assert_allclose(roth, [2_000], atol=0) + + +def test_marginal_tax_rate_keeps_contributions_set_after_construction(): + """A counterfactual branch keeps an input set through set_input. + + The earnings perturbation must compare like with like: the marginal tax + rate is the same whether the Roth contribution is given when the + simulation is built or set afterwards. A branch that dropped the later + input would regenerate a different IRA allocation alongside the bump. + """ + year = 2026 + person = { + "ira_compensation": 60_000, + "traditional_ira_contributions_desired": 7_500, + } + + def simulation(roth_at_construction): + situation = blank_situation() + inputs = dict(person, employment_income=60_000) + if roth_at_construction: + inputs["roth_ira_contributions"] = 2_000 + add_household(situation, "single", year, [inputs], {}) + sim = Simulation(situation=situation) + if not roth_at_construction: + sim.set_input("roth_ira_contributions", year, np.array([2_000.0])) + return sim + + at_construction = simulation(True).calculate("marginal_tax_rate", year) + set_afterwards = simulation(False).calculate("marginal_tax_rate", year) + np.testing.assert_allclose(set_afterwards, at_construction, atol=1e-6) + + +def test_marginal_tax_rate_ignores_internal_set_input_calls(): + """A formula's own set_input call must not leak into an MTR branch. + + The SSA revenue formulas set tax_unit_taxable_social_security through + holder.set_input. Calculating one before the marginal tax rate must not + freeze taxable Social Security in the branch: for a single filer aged 67 + in Texas in 2026 with $30,000 of wages and $20,000 of benefits, a $1,000 + earnings increase raises taxable benefits, so the rate is the same as in + a fresh simulation. + """ + year = 2026 + + def simulation(): + situation = blank_situation() + add_household( + situation, + "retiree", + year, + [ + { + "age": 67, + "employment_income": 30_000, + "social_security_retirement": 20_000, + } + ], + {}, + ) + return Simulation(situation=situation) + + fresh = simulation().calculate("federal_marginal_tax_rate", year) + after_revenue = simulation() + after_revenue.calculate("tob_revenue_oasdi", year) + np.testing.assert_allclose( + after_revenue.calculate("federal_marginal_tax_rate", year), fresh, atol=1e-6 + ) diff --git a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py new file mode 100644 index 00000000000..aa925d3fb6d --- /dev/null +++ b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py @@ -0,0 +1,402 @@ +"""Properties of Montana's AGI-based deductions on a joint (single-column) return. + +From 2021 to 2023 the Form 2 Itemized Deductions Schedule (line 1b) and the +Standard Deduction Worksheet (line 1) both take Montana AGI from Form 2 page 1, +line 14. Single, head of household and joint returns report that line in one +column, so a joint return pools both spouses' income. The model retains its +existing convention of flooring that pooled Montana AGI at zero. From 2024 +Form 2 line 2 takes federal itemized deductions (Worksheet A), so the medical +floor applies to federal AGI. + +The deterministic tests evaluate every point of an input grid in one vectorized +simulation; Hypothesis adds generated batches that check the same invariants: + +- 2021-2023: the medical deduction is max(0, expenses - 7.5% of the return's + Montana AGI), and the standard deduction is 20% of that AGI held between the + filing status minimum and maximum; +- moving an income item between spouses preserves both deductions when the + return's applicable AGI and medical expenses are held fixed; +- each deduction is held once, by the head; +- the medical deduction lies between zero and the medical expenses; +- for a single filer the joint and separate variants agree; +- from 2024 the medical deduction is the federal medical expense deduction, + including where Montana AGI differs from federal AGI. + +Hypothesis also generates arbitrary couples over 2021-2026 to check these +invariants beyond the deterministic grid, with witnesses for losses, dependent +expenses, the standard-deduction interior, and senior Montana subtractions. +Sources: 2022 Form 2, PDF p. 7, and 2024 instructions, PDF p. 15: +https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 +https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15 +""" + +import itertools + +import numpy as np +import pytest +from hypothesis import given, settings, strategies as st + +from policyengine_us import Simulation + +MEDICAL_FLOOR = 0.075 # Itemized Deductions Schedule line 1c +STANDARD_RATE = 0.2 # Standard Deduction Worksheet line 2 + +HEAD_WAGES = [0, 30_000, 60_000, 100_000] +SPOUSE_SELF_EMPLOYMENT = [-50_000, -20_000, 0, 40_000] +MEDICAL = [0, 2_000, 10_000, 30_000] +# Where the spouse's self-employment income is recorded: on the spouse, or +# moved to the head. The return's income is the same either way. +SPLITS = ["spouse", "head"] +CHILD_MEDICAL = [None, 1_500] # no child, or a dependent child's expenses +COUPLE_GRID = list( + itertools.product( + HEAD_WAGES, SPOUSE_SELF_EMPLOYMENT, MEDICAL, SPLITS, CHILD_MEDICAL + ) +) +SINGLE_GRID = list( + itertools.product([0, 20_000, 100_000], [-10_000, 0], MEDICAL, [40, 70]) +) +STATE_YEARS = [2021, 2022, 2023] +FEDERAL_YEARS = [2024, 2025] +YEARS = STATE_YEARS + FEDERAL_YEARS + + +def every_year(value): + return {year: value for year in YEARS} + + +@pytest.fixture(scope="module") +def grid(): + """One simulation holding every couple and single-filer grid point.""" + people, tax_units, households = {}, {}, {} + unit_of_person, role = [], [] + for i, (wages, se, medical, split, child) in enumerate(COUPLE_GRID): + head, spouse = f"c{i}_head", f"c{i}_spouse" + people[head] = { + "age": every_year(40), + "employment_income": every_year(wages), + "self_employment_income": every_year(se if split == "head" else 0), + "other_medical_expenses": every_year(medical), + } + people[spouse] = { + "age": every_year(40), + "self_employment_income": every_year(se if split == "spouse" else 0), + } + members = [head, spouse] + if child is not None: + name = f"c{i}_child" + people[name] = { + "age": every_year(10), + "is_tax_unit_dependent": every_year(True), + "other_medical_expenses": every_year(child), + } + members.append(name) + unit_of_person += [i] * len(members) + role += ["head", "spouse", "dependent"][: len(members)] + tax_units[f"c{i}"] = {"members": members} + households[f"c{i}"] = {"members": members, "state_code": every_year("MT")} + for j, (wages, se, medical, age) in enumerate(SINGLE_GRID): + name = f"s{j}" + people[name] = { + "age": every_year(age), + "employment_income": every_year(wages), + "self_employment_income": every_year(se), + "other_medical_expenses": every_year(medical), + } + unit_of_person.append(len(COUPLE_GRID) + j) + role.append("head") + tax_units[name] = {"members": [name]} + households[name] = {"members": [name], "state_code": every_year("MT")} + sim = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + return sim, np.array(unit_of_person), np.array(role) + + +N_COUPLES = len(COUPLE_GRID) +COUPLES = slice(0, N_COUPLES) +SINGLES = slice(N_COUPLES, None) + + +def per_unit(sim, variable, year, unit): + """Sum a person-level variable to its tax unit, in grid order.""" + return np.bincount(unit, weights=sim.calculate(variable, year)) + + +def montana_agi_reference(sim, year): + # The grid has no Montana additions or subtractions before 2024, so page 1 + # line 14 is the return's federal AGI, floored at zero like PE's. + return np.maximum(sim.calculate("adjusted_gross_income", year), 0) + + +def twin_pairs(): + """Index pairs of couples that differ only in which spouse has the item.""" + index = {point: i for i, point in enumerate(COUPLE_GRID)} + return np.array( + [ + (index[(w, se, m, "spouse", c)], index[(w, se, m, "head", c)]) + for w, se, m, split, c in COUPLE_GRID + if split == "spouse" + ] + ).T + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_premises(grid, year): + sim, unit, _ = grid + filing_status = sim.calculate("filing_status", year).decode_to_str() + assert set(filing_status[COUPLES]) == {"JOINT"} + assert set(filing_status[SINGLES]) == {"SINGLE"} + np.testing.assert_allclose( + sim.calculate("mt_agi_joint", year), montana_agi_reference(sim, year) + ) + # Moving the item between spouses does not change the return's AGI... + spouse_side, head_side = twin_pairs() + agi = sim.calculate("adjusted_gross_income", year) + np.testing.assert_allclose(agi[spouse_side], agi[head_side]) + # ...but it does change the sum of each spouse's AGI floored at zero, so + # the grid reaches the cases where pooling matters. + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + assert np.any(summed_individual[COUPLES] > agi[COUPLES] + 1) + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_medical_floor_uses_the_return_montana_agi(grid, year): + sim, unit, role = grid + expenses = sim.calculate("itemized_medical_expenses", year) + expected = np.maximum( + expenses - MEDICAL_FLOOR * montana_agi_reference(sim, year), 0 + ) + deduction = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) + np.testing.assert_allclose(deduction, expected, atol=0.01) + held = sim.calculate("mt_medical_expense_deduction_joint", year) + assert np.all(held[role != "head"] == 0) + assert np.all(deduction >= 0) + assert np.all(deduction <= expenses + 0.01) + spouse_side, head_side = twin_pairs() + np.testing.assert_allclose(deduction[spouse_side], deduction[head_side]) + assert deduction.max() > 0 + # Adding each spouse's Montana AGI after flooring it at zero would change + # the deduction somewhere on the grid. + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + unpooled = np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + assert np.any(np.abs(unpooled - expected) > 1) + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_standard_deduction_uses_the_return_montana_agi(grid, year): + sim, unit, role = grid + p = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.states.mt.tax.income.deductions.standard + filing_status = sim.calculate("filing_status", year).decode_to_str() + minimum = p.floor[filing_status] + maximum = p.cap[filing_status] + expected = np.clip( + STANDARD_RATE * montana_agi_reference(sim, year), minimum, maximum + ) + deduction = per_unit(sim, "mt_standard_deduction_joint", year, unit) + np.testing.assert_allclose(deduction, expected, atol=0.01) + held = sim.calculate("mt_standard_deduction_joint", year) + assert np.all(held[role != "head"] == 0) + spouse_side, head_side = twin_pairs() + np.testing.assert_allclose(deduction[spouse_side], deduction[head_side]) + # Some returns fall between the minimum and maximum. + assert np.any((deduction > minimum + 1) & (deduction < maximum - 1)) + # Adding each spouse's Montana AGI after flooring it at zero would change + # the deduction somewhere on the grid. + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + unpooled = np.clip(STANDARD_RATE * summed_individual, minimum, maximum) + assert np.any(np.abs(unpooled - expected) > 1) + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_single_filer_joint_and_separate_variants_agree(grid, year): + sim, unit, _ = grid + for joint, separate in [ + ("mt_medical_expense_deduction_joint", "mt_medical_expense_deduction_indiv"), + ("mt_standard_deduction_joint", "mt_standard_deduction_indiv"), + ]: + np.testing.assert_allclose( + per_unit(sim, joint, year, unit)[SINGLES], + per_unit(sim, separate, year, unit)[SINGLES], + atol=0.01, + ) + + +@pytest.mark.parametrize("year", FEDERAL_YEARS) +def test_from_2024_the_federal_medical_deduction_carries_through(grid, year): + sim, unit, role = grid + federal = sim.calculate("medical_expense_deduction", year) + deduction = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) + np.testing.assert_allclose(deduction, federal, atol=0.01) + held = sim.calculate("mt_medical_expense_deduction_joint", year) + assert np.all(held[role != "head"] == 0) + # The grid includes returns whose Montana AGI differs from federal AGI + # (the subtraction for taxpayers 65 and older) and that deduct expenses. + montana_agi = sim.calculate("mt_agi_joint", year) + federal_agi = np.maximum(sim.calculate("adjusted_gross_income", year), 0) + assert np.any((montana_agi < federal_agi - 1) & (deduction > 0)) + + +GENERATED_YEARS = list(range(2021, 2027)) +# Keep wages plus positive self-employment below the 2021 Social Security +# wage base. Moving SE earnings to the wage earner would otherwise change +# the SE-tax deduction, changing federal AGI for a reason unrelated to MT. +GENERATED_COUPLES = st.lists( + st.tuples( + st.integers(min_value=0, max_value=100_000), + st.integers(min_value=-40_000, max_value=40_000), + st.integers(min_value=0, max_value=50_000), + st.one_of(st.none(), st.integers(min_value=0, max_value=10_000)), + st.sampled_from([40, 70]), + st.sampled_from([40, 70]), + ), + min_size=1, + max_size=6, +) + + +def generated_couple_simulation(points): + """Batch arbitrary situations with both placements of each income item.""" + people, tax_units, households = {}, {}, {} + unit_of_person = [] + + def all_years(value): + return {year: value for year in GENERATED_YEARS} + + for point_index, (wages, se, medical, child, head_age, spouse_age) in enumerate( + points + ): + for split_index, split in enumerate(["spouse", "head"]): + unit = 2 * point_index + split_index + head, spouse = f"h{unit}", f"s{unit}" + people[head] = { + # Keep medical expenses fixed when moving income between seniors; + # modeled Medicare premiums/MSP would otherwise change the premise. + "medicare_enrolled": all_years(False), + "age": all_years(head_age), + "employment_income": all_years(wages), + "self_employment_income": all_years(se if split == "head" else 0), + "other_medical_expenses": all_years(medical), + } + people[spouse] = { + "medicare_enrolled": all_years(False), + "age": all_years(spouse_age), + "self_employment_income": all_years(se if split == "spouse" else 0), + } + members = [head, spouse] + if child is not None: + name = f"c{unit}" + people[name] = { + "age": all_years(10), + "is_tax_unit_dependent": all_years(True), + "other_medical_expenses": all_years(child), + } + members.append(name) + unit_of_person.extend([unit] * len(members)) + tax_units[f"t{unit}"] = {"members": members} + households[f"hh{unit}"] = { + "members": members, + "state_code": all_years("MT"), + } + simulation = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + return simulation, np.array(unit_of_person) + + +@settings(max_examples=8, deadline=None) +@given(points=GENERATED_COUPLES) +def test_generated_joint_deduction_invariants(points): + # Fixed witnesses in every randomized batch prevent vacuous passes and + # retain the inherited zero-clipping convention. Their pre-2024 medical + # arithmetic is (10,000 + 1,500) - 7.5% * (100,000 - 20,000) = 5,500; + # 10,000 - 7.5% * max(-20,000, 0) = 10,000. The standard witness is + # 20% * (50,000 - 10,000) = 8,000, inside the 2022 joint bounds. + witnesses = [ + (100_000, -20_000, 10_000, 1_500, 70, 40), + (0, -20_000, 10_000, None, 40, 40), + (50_000, -10_000, 0, None, 40, 40), + # The older second adult is the model's head, despite input order. + (0, 0, 0, None, 40, 70), + ] + sim, unit = generated_couple_simulation(points + witnesses) + for year in GENERATED_YEARS: + federal_agi = sim.calculate("adjusted_gross_income", year) + np.testing.assert_allclose(federal_agi[::2], federal_agi[1::2], atol=0.02) + assert set(sim.calculate("filing_status", year).decode_to_str()) == {"JOINT"} + # Head selection follows the oldest eligible adult, not input order. + is_head = sim.calculate("is_tax_unit_head", year) + assert np.all(np.bincount(unit, weights=is_head) == 1) + # There is no Social Security income in these situations. The + # signed federal person amounts, state additions, and subtractions + # are therefore pooled before applying the existing zero floor. + pooled_agi = np.maximum( + per_unit(sim, "adjusted_gross_income_person", year, unit) + + per_unit(sim, "mt_additions", year, unit) + - per_unit(sim, "mt_subtractions", year, unit), + 0, + ) + montana_agi = sim.calculate("mt_agi_joint", year) + np.testing.assert_allclose(montana_agi, pooled_agi, atol=0.02) + np.testing.assert_allclose(montana_agi[::2], montana_agi[1::2], atol=0.02) + + expenses = sim.calculate("itemized_medical_expenses", year) + # Dependent medical expenses are part of the return's expenses. + # Medicare premiums are disabled to hold expenses fixed across twins. + np.testing.assert_allclose( + expenses, per_unit(sim, "other_medical_expenses", year, unit), atol=0.02 + ) + np.testing.assert_allclose(expenses[::2], expenses[1::2], atol=0.02) + medical = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) + standard = per_unit(sim, "mt_standard_deduction_joint", year, unit) + for variable, values in [ + ("mt_medical_expense_deduction_joint", medical), + ("mt_standard_deduction_joint", standard), + ]: + # Exactly the head carries the amount; no spouse or dependent + # can duplicate the return's deduction. + assert np.all(sim.calculate(variable, year)[~is_head] == 0) + np.testing.assert_allclose(values[::2], values[1::2], atol=0.02) + assert np.all(medical >= 0) + assert np.all(medical <= expenses + 0.02) + + if year < 2024: + np.testing.assert_allclose( + montana_agi, np.maximum(federal_agi, 0), atol=0.02 + ) + np.testing.assert_allclose( + medical, + np.maximum(expenses - MEDICAL_FLOOR * montana_agi, 0), + atol=0.02, + ) + p = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.states.mt.tax.income.deductions.standard + filing_status = sim.calculate("filing_status", year).decode_to_str() + minimum, maximum = p.floor[filing_status], p.cap[filing_status] + np.testing.assert_allclose( + standard, + np.clip(STANDARD_RATE * montana_agi, minimum, maximum), + atol=0.02, + ) + assert np.all(standard >= minimum - 0.02) + assert np.all(standard <= maximum + 0.02) + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + assert np.any(summed_individual > montana_agi + 1) + else: + np.testing.assert_allclose( + medical, sim.calculate("medical_expense_deduction", year), atol=0.02 + ) + np.testing.assert_allclose( + medical, + np.maximum(expenses - MEDICAL_FLOOR * np.maximum(federal_agi, 0), 0), + atol=0.02, + ) + np.testing.assert_allclose( + standard, sim.calculate("standard_deduction", year), atol=0.02 + ) + # Every batch includes a senior with positive expenses and + # enough income for the MT subtraction to change the AGI. + assert np.any((montana_agi < federal_agi - 1) & (medical > 0)) diff --git a/policyengine_us/tests/unit/test_wi_retirement_election.py b/policyengine_us/tests/unit/test_wi_retirement_election.py new file mode 100644 index 00000000000..1e7f6c40a0b --- /dev/null +++ b/policyengine_us/tests/unit/test_wi_retirement_election.py @@ -0,0 +1,255 @@ +"""Check accounting and reporting invariants beyond individual YAML amounts. + +One shared vectorized simulation and two scalar controls verify independence +between returns. YAML cases cannot compare vectorized and separate executions +or express equality between alternative reporting routes across input rows. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + + +def _situation(cases, year): + situation = { + entity: {} + for entity in ( + "people", + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ) + } + for index, case in enumerate(cases): + members = [] + for person_index, person in enumerate(case["people"]): + name = f"person_{index}_{person_index}" + members.append(name) + situation["people"][name] = { + variable: {str(year): value} + for variable, value in { + **person, + "is_tax_unit_head": person_index == 0, + "is_tax_unit_spouse": person_index == 1, + }.items() + } + unit_name = f"unit_{index}" + for entity in ("spm_units", "families", "marital_units"): + situation[entity][unit_name] = {"members": members} + situation["tax_units"][unit_name] = { + "members": members, + "filing_status": {str(year): "JOINT" if len(members) == 2 else "SINGLE"}, + } + situation["households"][unit_name] = { + "members": members, + "state_code": {str(year): "WI"}, + } + return situation + + +RETIREMENT_CASES = [ + {"people": [{"age": 68, "taxable_private_pension_income": 8_000}]}, + {"people": [{"age": 68, "taxable_private_pension_income": 30_000}]}, + {"people": [{"age": 66, "taxable_private_pension_income": 30_000}]}, + { + "people": [ + { + "age": 68, + "taxable_private_pension_income": 45_000, + "farm_operations_income": -30_000, + }, + {"age": 68, "taxable_private_pension_income": 8_000}, + ] + }, + { + "people": [ + { + "age": 68, + "taxable_private_pension_income": 53_000, + "farm_operations_income": -35_000, + }, + {"age": 68, "taxable_private_pension_income": 5_000}, + ] + }, + { + "people": [ + {"age": 68, "taxable_private_pension_income": 8_000}, + {"age": 66, "taxable_private_pension_income": 8_000}, + ] + }, + { + "people": [ + { + "age": 68, + "taxable_private_pension_income": 24_500, + "real_estate_taxes": 2_000, + } + ] + }, + {"people": [{"age": 68, "taxable_ira_distributions": 8_000}]}, + {"people": [{"age": 68, "taxable_401k_distributions": 8_000}]}, + {"people": [{"age": 68, "taxable_roth_conversions": 8_000}]}, +] + + +CAPITAL_AMOUNTS = [ + (0, 1_000, 0), + (0, 1_000, -800), + (0, 1_000, -1_200), + (2_000, 1_000, -800), + (-1_000, 1_500, -800), + (-2_000, 1_000, 3_000), +] + + +def _capital_gain_cases(): + direct_cases = [] + schedule_d_cases = [] + for long_term, distribution, short_term in CAPITAL_AMOUNTS: + common = { + "age": 40, + "employment_income": 40_000, + "short_term_capital_gains": short_term, + } + direct_cases.append( + { + "people": [ + { + **common, + "long_term_capital_gains": long_term, + "non_sch_d_capital_gains": distribution, + } + ] + } + ) + schedule_d_cases.append( + { + "people": [ + { + **common, + "long_term_capital_gains": long_term + distribution, + } + ] + } + ) + return direct_cases, schedule_d_cases + + +@pytest.fixture(scope="module") +def wi_simulation(): + direct_cases, schedule_d_cases = _capital_gain_cases() + return Simulation( + situation=_situation(RETIREMENT_CASES + direct_cases + schedule_d_cases, 2026) + ) + + +def test_retirement_election_vectorized_matches_separate_returns(wi_simulation): + simulation = wi_simulation + year = 2026 + retirement_slice = slice(0, len(RETIREMENT_CASES)) + variables = ( + "wi_retirement_income_exclusion_amount", + "wi_retirement_income_exclusion_line17_offset", + "wi_retirement_income_exclusion_tax", + "wi_retirement_income_exclusion_elected", + "state_income_tax_before_refundable_credits", + "state_refundable_credits", + "state_income_tax", + ) + vectorized = { + variable: simulation.calculate(variable, year)[retirement_slice] + for variable in variables + } + # Compare a taxable standard return and a return retaining homestead. + # The remaining rows exercise their vectorized accounting identities below. + for index in (1, 6): + case = RETIREMENT_CASES[index] + separate = Simulation(situation=_situation([case], year)) + for variable in variables: + assert vectorized[variable][index] == pytest.approx( + separate.calculate(variable, year)[0], abs=0.01 + ) + + before = vectorized["state_income_tax_before_refundable_credits"] + refundable = vectorized["state_refundable_credits"] + net = vectorized["state_income_tax"] + elected = vectorized["wi_retirement_income_exclusion_elected"] + standard_before = np.maximum( + 0, + simulation.calculate("wi_income_tax_before_credits", year) + - simulation.calculate("wi_non_refundable_credits", year), + )[retirement_slice] + earned_income_credit = simulation.calculate("wi_earned_income_credit", year)[ + retirement_slice + ] + homestead_credit = simulation.calculate("wi_homestead_credit", year)[ + retirement_slice + ] + standard_net = standard_before - earned_income_credit - homestead_credit + + assert standard_before[1] > 0 + assert standard_before[6] > 0 + assert homestead_credit[6] > 0 + assert elected[6] + np.testing.assert_allclose(net, before - refundable, atol=0.01) + assert np.all(net <= standard_net + 0.01) + assert np.any(elected) + assert np.any(~elected) + np.testing.assert_allclose( + refundable[elected], homestead_credit[elected], atol=0.01 + ) + np.testing.assert_allclose( + before[elected], + vectorized["wi_retirement_income_exclusion_tax"][elected], + atol=0.01, + ) + np.testing.assert_allclose(before[~elected], standard_before[~elected], atol=0.01) + + offset = vectorized["wi_retirement_income_exclusion_line17_offset"] + line17 = simulation.calculate("wi_retirement_income_subtraction", year)[ + retirement_slice + ] + assert np.all(offset >= 0) + assert np.all(offset <= line17) + assert np.all(offset <= vectorized["wi_retirement_income_exclusion_amount"]) + + +def test_capital_gain_distribution_reporting_route_and_loss_bounds(wi_simulation): + count = len(CAPITAL_AMOUNTS) + start = len(RETIREMENT_CASES) + direct_slice = slice(start, start + count) + schedule_d_slice = slice(start + count, start + 2 * count) + subtraction = wi_simulation.calculate("wi_capital_gain_loss_subtraction", 2026) + np.testing.assert_allclose( + subtraction[direct_slice], subtraction[schedule_d_slice], atol=0.01 + ) + + # Compare downstream tax when the original Schedule D has no net loss; + # federal and Wisconsin loss-addition conventions are outside this fix. + nonloss_rows = [0, 3, 5] + for variable in ( + "wi_agi", + "state_income_tax_before_refundable_credits", + "state_income_tax", + ): + values = wi_simulation.calculate(variable, 2026) + np.testing.assert_allclose( + values[direct_slice][nonloss_rows], + values[schedule_d_slice][nonloss_rows], + atol=0.01, + ) + + subtraction = subtraction[direct_slice] + long_term = np.array( + [gain + distribution for gain, distribution, _ in CAPITAL_AMOUNTS] + ) + net_gain = long_term + np.array( + [short_term for _, _, short_term in CAPITAL_AMOUNTS] + ) + assert np.all(subtraction >= 0) + assert np.all(subtraction <= 0.3 * np.maximum(long_term, 0) + 0.01) + assert np.all(subtraction <= 0.3 * np.maximum(net_gain, 0) + 0.01) + np.testing.assert_array_equal(subtraction[net_gain <= 0], 0) diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 3762e1dc1e2..5e9b7accd28 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -1,4 +1,5 @@ from policyengine_core.model_api import * +from policyengine_core.periods import period as to_period from policyengine_core.projectors import Projector from policyengine_us.entities import * from policyengine_us.tools.branched_simulation import BranchedSimulation @@ -13,6 +14,31 @@ USD = "currency-USD" +def has_input_for_period(simulation, variable, period): + """Whether the visible array for this exact period was set as an input. + + A nearer branch's stored value shadows its ancestors. Inputs from sibling + branches or other periods do not apply. Only finite definition periods + are supported. + + Core versions before 3.32.27 can retain an input key after deletion. If + that value has already been recalculated, this read-only check cannot + distinguish it from a supplied value. Core 3.32.27 fixes this in #561. + """ + period = to_period(period) + holder = simulation.get_holder(variable) + if holder.variable.definition_period == ETERNITY: + raise ValueError("has_input_for_period does not support eternal variables.") + if holder.variable.is_neutralized: + return False + stored_keys = set(holder.get_known_branch_periods()) + input_keys = getattr(simulation, "_user_input_keys", set()) + for branch_name in simulation._get_visible_branch_names(): + if (branch_name, period) in stored_keys: + return (variable, branch_name, period) in input_keys + return False + + def tax_unit_non_dep_sum(var, tax_unit, period): return tax_unit.sum( tax_unit.members(var, period) diff --git a/policyengine_us/tools/per_capita_uprating.py b/policyengine_us/tools/per_capita_uprating.py index d14029ef711..acf5f04a1e4 100644 --- a/policyengine_us/tools/per_capita_uprating.py +++ b/policyengine_us/tools/per_capita_uprating.py @@ -10,8 +10,9 @@ For every national total that a variable uprates by, this module adds a sibling parameter holding the total divided by population (``_per_capita``) and points the variable at it. Series that are -already per-person rates or price indices (CMS per-capita spending, CPI-U) -are left alone, as is the population series that uprates the weights. +already per-person rates or price indices (CMS per-capita spending, CMS +premiums per enrollee, CPI-U) are left alone, as is the population series +that uprates the weights. Parameters can follow a per-capita series too: a parameter whose ``uprating`` metadata names ``_per_capita`` gets that sibling built before @@ -32,16 +33,26 @@ "calibration.gov.irs.soi.", ) CALIBRATION_PREFIX = "calibration." -PER_CAPITA_MARKER = "per_capita" +# Fragments that mark a per-person rate when they appear in a path's last +# segment: spending per capita, or premiums per enrollee. Such a series already +# divides by the people it covers, so it is neither a national total nor +# divided again. A marker in an ancestor segment (a folder of estimates) does +# not make the series under it a rate. +PER_PERSON_MARKERS = ("per_capita", "per_enrollee") PER_CAPITA_SUFFIX = "_per_capita" DERIVED_FROM = "derived_from" +def _is_per_person_rate(path: str) -> bool: + name = path.rpartition(".")[2] + return any(marker in name for marker in PER_PERSON_MARKERS) + + def is_national_total_path(path: str | None) -> bool: """Whether an uprating parameter path names a national total.""" if not path: return False - return path.startswith(NATIONAL_TOTAL_PREFIXES) and PER_CAPITA_MARKER not in path + return path.startswith(NATIONAL_TOTAL_PREFIXES) and not _is_per_person_rate(path) def per_capita_path(path: str) -> str: @@ -50,12 +61,12 @@ def per_capita_path(path: str) -> str: def _is_unclassified_calibration_path(path: str | None) -> bool: """A ``calibration`` path this module cannot classify: not a national - total, not already per capita, not the population series.""" + total, not already a per-person rate, not the population series.""" if not path or not path.startswith(CALIBRATION_PREFIX): return False return not ( is_national_total_path(path) - or PER_CAPITA_MARKER in path + or _is_per_person_rate(path) or path == POPULATION_PATH ) diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_earned_income.py b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_earned_income.py index dcf955a98f0..78714572f34 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eitc_earned_income.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eitc_earned_income.py @@ -9,12 +9,21 @@ class eitc_earned_income(Variable): definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/32#c_2", + "https://www.law.cornell.edu/uscode/text/26/402#e_3", + "https://www.law.cornell.edu/uscode/text/26/125#a", + "https://www.law.cornell.edu/uscode/text/26/106#d", + "https://www.irs.gov/publications/p596", "https://www.irs.gov/instructions/i1040gi", ) def formula(tax_unit, period, parameters): + # IRC 32(c)(2)(A)(i) includes employee compensation only when + # includible in gross income. The EIC worksheet starts from Form + # 1040 line 1z (including W-2 box 1), already reduced by qualifying + # payroll deductions. Reuse federal taxable wages without another + # deduction, so supplied W-2 wages are not reduced twice. earned_income_sources = [ - "employment_income", + "irs_employment_income", "self_employment_income", "sstb_self_employment_income", "farm_operations_income", diff --git a/policyengine_us/variables/gov/irs/income/filer_adjusted_earnings.py b/policyengine_us/variables/gov/irs/income/filer_adjusted_earnings.py index 6830b102d53..a64258b8386 100644 --- a/policyengine_us/variables/gov/irs/income/filer_adjusted_earnings.py +++ b/policyengine_us/variables/gov/irs/income/filer_adjusted_earnings.py @@ -7,6 +7,29 @@ class filer_adjusted_earnings(Variable): definition_period = YEAR label = "Filer earned income adjusted for self-employment tax" unit = USD + reference = ( + "https://www.law.cornell.edu/uscode/text/26/32#c_2", + "https://www.law.cornell.edu/uscode/text/26/402#e_3", + "https://www.irs.gov/publications/p596", + "https://www.irs.gov/instructions/i1040gi", + ) def formula(tax_unit, period, parameters): - return tax_unit_non_dep_sum("adjusted_earnings", tax_unit, period) + person = tax_unit.members + p = parameters(period).gov.irs.ald.misc + adjustment = ( + (1 - p.self_emp_tax_adj) + * p.employer_share + * person("self_employment_tax", period) + ) + # Federal-style state credits exclude wages that are not includible + # in federal gross income. Preserve the existing personal SE-tax + # adjustment and zero floor, without changing gross earned_income. + excluded_wages = person("employment_income", period) - person( + "irs_employment_income", period + ) + adjusted = max_( + 0, person("earned_income", period) - excluded_wages - adjustment + ) + is_dependent = person("is_tax_unit_dependent", period) + return tax_unit.sum(adjusted * ~is_dependent) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/dependent_traditional_ira_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/dependent_traditional_ira_deduction.py new file mode 100644 index 00000000000..6a411b828bf --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/dependent_traditional_ira_deduction.py @@ -0,0 +1,87 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.irs_gross_income.social_security.dependent_taxable_ss_magi import ( + dependent_income_and_deductions, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.irs_gross_income.social_security.dependent_taxable_social_security import ( + dependent_taxable_social_security_amount, +) +from policyengine_us.variables.household.expense.retirement._ira_supplied_contributions import ( + traditional_ira_contributions_without_spousal_rule, +) + + +class dependent_traditional_ira_deduction(Variable): + value_type = float + entity = Person + label = "Dependent's traditional IRA deduction on their own return" + unit = USD + documentation = ( + "The IRC 219 deduction a tax unit dependent takes on their own return " + "for their own traditional IRA contributions. It is limited to the " + "dependent's compensation and the dollar limit. An active participant " + "in an employer plan is phased out on the single schedule (the " + "separate schedule if they live with their spouse), using their own " + "modified AGI, which includes the taxable Social Security figured " + "before this deduction. A dependent living with a covered spouse is " + "phased out on the separate schedule even if not covered. The filer's " + "traditional_ira_deduction excludes dependents, whose contributions " + "are not on the filer's return." + ) + definition_period = YEAR + defined_for = "is_tax_unit_dependent" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#b_1", + "https://www.law.cornell.edu/uscode/text/26/219#g", + "https://www.irs.gov/publications/p590a", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.irs.ald.ira.phase_out + retirement = parameters(period).gov.irs.gross_income.retirement_contributions + catch_up = person("age", period) >= retirement.catch_up.age_threshold + dollar_limit = retirement.limit.ira + catch_up * retirement.catch_up.limit.ira + compensation = person("ira_compensation", period) + # The spousal rule never reaches a dependent, so their contributions + # are figured on their own limit. This avoids the filer's filing + # status, which itself depends on who is a dependent. + contributions = traditional_ira_contributions_without_spousal_rule( + person, period, min_(dollar_limit, compensation) + ) + active = person("ira_active_participant", period) + lives_with_spouse = person("dependent_lives_with_spouse", period) + start = where(lives_with_spouse, p.start.SEPARATE, p.start.SINGLE) + width = where(lives_with_spouse, p.width.SEPARATE, p.width.SINGLE) + # IRC 219(g)(3)(A): modified AGI is figured without this deduction and + # after IRC 86, so it includes the taxable Social Security figured + # before the IRA deduction (Pub. 590-A, Appendix B, Worksheet 1). + income, deductions = dependent_income_and_deductions(person, period, parameters) + other_deductions = [ + d for d in deductions if d != "dependent_traditional_ira_deduction" + ] + agi_before_ira = income - add(person, period, other_deductions) + taxable_social_security = dependent_taxable_social_security_amount( + person, + period, + parameters, + agi_before_ira + person("tax_exempt_interest_income", period), + ) + magi = (agi_before_ira + taxable_social_security).astype(np.float64) + fraction = clip((magi - start) / width, 0, 1) + # Section 219(g)(2)(C) rounds the reduction down to a multiple of $10. + reduction = ( + np.floor(np.round(dollar_limit * fraction, 6) / p.rounding_interval) + * p.rounding_interval + ) + phased_limit = where( + magi >= start + width, + 0, + max_(p.minimum, dollar_limit - reduction), + ) + # IRC 219(g)(1) also reaches a spouse who is not covered. A dependent + # living with a covered spouse files separately, so the separate + # range applies; one living apart all year is treated as unmarried + # (219(g)(4)) and the spouse's coverage is disregarded. + spouse_active = person.marital_unit.sum(active) > active.astype(int) + phased = active | (lives_with_spouse & spouse_active) + limit = min_(compensation, where(phased, phased_limit, dollar_limit)) + return min_(contributions, limit) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_deductible_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_deductible_limit.py new file mode 100644 index 00000000000..d263733bba2 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_deductible_limit.py @@ -0,0 +1,61 @@ +from policyengine_us.model_api import * + + +class ira_219g_deductible_limit(Variable): + value_type = float + entity = Person + label = "IRA dollar deduction limit after the employer-plan phase-out" + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#g", + "https://www.irs.gov/publications/p590a", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.irs.ald.ira.phase_out + retirement = parameters(period).gov.irs.gross_income.retirement_contributions + catch_up = person("age", period) >= retirement.catch_up.age_threshold + dollar_limit = retirement.limit.ira + catch_up * retirement.catch_up.limit.ira + tax_unit = person.tax_unit + filing_status = tax_unit("filing_status", period) + status = filing_status.possible_values + joint = filing_status == status.JOINT + separate = filing_status == status.SEPARATE + lived_together = separate & tax_unit("cohabitating_spouses", period) + # Section 219(g)(4): separate filers living apart all year are unmarried + # for this phase-out, so the spouse's coverage is disregarded. + treated_as_single = separate & ~lived_together + active = person("ira_active_participant", period) + # A marital unit can span two separate tax units. + spouse_active = person.marital_unit.sum(active) > active.astype(int) + # Also support joint returns whose members omit explicit marital units. + filer = person("is_tax_unit_head_or_spouse", period) + other_joint_active = tax_unit.sum(active * filer) > (active & filer).astype(int) + spouse_route = ~active & ( + (joint & other_joint_active) | (lived_together & spouse_active) + ) + start = where( + treated_as_single, + p.start.SINGLE, + where(spouse_route & joint, p.spouse_start, p.start[filing_status]), + ) + width = where( + treated_as_single, + p.width.SINGLE, + where(spouse_route & joint, p.spouse_width, p.width[filing_status]), + ) + magi = tax_unit("ira_219g_magi", period).astype(np.float64) + fraction = clip((magi - start) / width, 0, 1) + # Section 219(g)(2)(C) rounds the reduction down to a multiple of $10. + # Float32 income inputs can land infinitesimally below an exact multiple. + reduction = ( + np.floor(np.round(dollar_limit * fraction, 6) / p.rounding_interval) + * p.rounding_interval + ) + phased_limit = where( + magi >= start + width, + 0, + max_(p.minimum, dollar_limit - reduction), + ) + return where(active | spouse_route, phased_limit, dollar_limit) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_magi.py new file mode 100644 index 00000000000..48cabbaa860 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_magi.py @@ -0,0 +1,49 @@ +from policyengine_us.model_api import * + + +class ira_219g_magi(Variable): + value_type = float + entity = TaxUnit + label = "Modified AGI for the IRC 219(g) IRA deduction phase-out" + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#g_3_A", + "https://www.irs.gov/publications/p590a", + ) + + def formula(tax_unit, period, parameters): + irs = parameters(period).gov.irs + sources = [ + s + for s in irs.gross_income.sources + if s not in ["taxable_social_security", "taxable_unemployment_compensation"] + ] + if "taxable_unemployment_compensation" in irs.gross_income.sources: + # 219(g)(3)(A)(ii): without regard to 85(c). + sources.append("unemployment_compensation") + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + gross = 0 + for source in sources: + gross += not_dependent * max_(0, add(person, period, [source])) + gross = tax_unit.sum(gross) + # 219(g)(3)(A)(i): after section 86 (benefits figured before the IRA + # deduction, Pub. 590-A App. B Worksheet 1 line 17). + gross += tax_unit("ira_219g_taxable_social_security", period) + # Section 911 exclusions are disregarded for IRA MAGI. + gross += tax_unit("section_911_excluded_income", period) + deductions = [ + d + for d in irs.ald.deductions + if d not in irs.ald.ira.magi_excluded_deductions + ] + magi = gross - tax_unit_non_dep_add( + tax_unit, + period, + deductions, + include_dependents=irs.ald.filer_amounts_recorded_on_dependents, + ) + if parameters(period).gov.contrib.ubi_center.basic_income.taxable: + magi += add(tax_unit, period, ["basic_income"]) + return magi diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_taxable_social_security.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_taxable_social_security.py new file mode 100644 index 00000000000..52ac396a4b6 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_219g_taxable_social_security.py @@ -0,0 +1,82 @@ +from policyengine_us.model_api import * + + +class ira_219g_taxable_social_security(Variable): + """Section 86 taxable benefits figured without the IRA deduction. + + Pub. 590-A Appendix B, Worksheet 1 lines 1-17. Same inputs and thresholds as + the engine's taxable_ss_magi / tax_unit_taxable_social_security, except that + the IRA deduction is not subtracted (it is what is being computed). + """ + + value_type = float + entity = TaxUnit + label = "Taxable Social Security for 219(g) MAGI (before the IRA deduction)" + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#g_3_A", + "https://www.irs.gov/publications/p590a", + ) + + def formula(tax_unit, period, parameters): + irs = parameters(period).gov.irs + p = irs.social_security.taxability + # taxable_ss_magi, with the IRA deduction removed from the ALDs. + sources = [ + s + for s in irs.gross_income.sources + if s not in ["taxable_social_security", "taxable_unemployment_compensation"] + ] + if "taxable_unemployment_compensation" in irs.gross_income.sources: + sources.append("unemployment_compensation") + sources.append("tax_exempt_interest_income") + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + gross = 0 + for source in sources: + gross += not_dependent * max_(0, add(person, period, [source])) + gross = tax_unit.sum(gross) + gross += tax_unit("section_911_excluded_income", period) + if parameters(period).gov.contrib.ubi_center.basic_income.taxable: + gross += add(tax_unit, period, ["basic_income"]) + revoked = p.income.revoked_deductions + deductions = [ + d + for d in irs.ald.deductions + if d not in revoked and d not in irs.ald.ira.magi_excluded_deductions + ] + total_deductions = tax_unit_non_dep_add( + tax_unit, + period, + deductions, + include_dependents=irs.ald.filer_amounts_recorded_on_dependents, + ) + ss_magi = max_(0, gross - total_deductions) + # tax_unit_taxable_social_security, with that MAGI. + gross_ss = tax_unit("tax_unit_social_security_for_taxability", period) + combined = ss_magi + p.combined_income_ss_fraction * gross_ss + filing_status = tax_unit("filing_status", period) + separate = filing_status == filing_status.possible_values.SEPARATE + cohabitating = tax_unit("cohabitating_spouses", period) + base = where( + separate & cohabitating, + p.threshold.base.separate_cohabitating, + p.threshold.base.main[filing_status], + ) + adjusted_base = where( + separate & cohabitating, + p.threshold.adjusted_base.separate_cohabitating, + p.threshold.adjusted_base.main[filing_status], + ) + excess = max_(0, combined - base) + over_adjusted = max_(0, combined - adjusted_base) + tier1 = min_(p.rate.base.benefit_cap * gross_ss, p.rate.base.excess * excess) + bracket = min_(tier1, p.rate.additional.bracket * (adjusted_base - base)) + tier2 = min_( + p.rate.additional.excess * over_adjusted + bracket, + p.rate.additional.benefit_cap * gross_ss, + ) + return select( + [combined < base, combined < adjusted_base], [0, tier1], default=tier2 + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_active_participant.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_active_participant.py new file mode 100644 index 00000000000..00d6b5dd09a --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/ira_active_participant.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class ira_active_participant(Variable): + value_type = bool + entity = Person + label = "Active participant in an employer retirement plan for IRA deductions" + definition_period = YEAR + default_value = False + documentation = ( + "Whether this person was an active participant for any part of a plan " + "year ending within the tax year in a plan covered by section 219(g)(5), " + "including defined benefit, 401(a), 403(a), 403(b), SEP and SIMPLE plans. " + "By default, positive traditional or Roth 401(k) or 403(b) contributions " + "or self-employed pension contributions establish this person's own " + "coverage. An explicitly supplied value always overrides this default, " + "including false. Supply defined-benefit coverage and coverage from " + "employer contributions not represented by these amounts explicitly, " + "generally using Form W-2 box 13. Do not include a spouse's coverage or " + "participation solely in a 457(b) plan. Apply the reserve and volunteer " + "firefighter exceptions in section 219(g)(6) through an explicit input. " + "When supplying coverage inputs, supply every relevant person's value: " + "core stores a population input array and defaults omitted rows to false." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#g_5", + "https://www.irs.gov/publications/p590a", + "https://www.irs.gov/instructions/iw2w3", + ) + + def formula(person, period, parameters): + # Section 219(g)(5) excludes participation solely in a 457(b) plan. + # Check each amount separately so a negative input cannot cancel + # positive contributions to another qualifying plan. + return ( + (person("traditional_401k_contributions", period) > 0) + | (person("roth_401k_contributions", period) > 0) + | (person("traditional_403b_contributions", period) > 0) + | (person("roth_403b_contributions", period) > 0) + | (person("self_employed_pension_contributions", period) > 0) + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/traditional_ira_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/traditional_ira_deduction.py new file mode 100644 index 00000000000..1175e16e46e --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/ira/traditional_ira_deduction.py @@ -0,0 +1,68 @@ +from policyengine_us.model_api import * + + +class traditional_ira_deduction(Variable): + value_type = float + entity = Person + label = "Traditional IRA deduction" + documentation = ( + "The IRC 219 deduction for traditional IRA contributions. For the head " + "and spouse it is the deduction on this tax return. For a tax unit " + "dependent it is the deduction on the dependent's own return, which " + "the filers' above-the-line total leaves out." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#a", + "https://www.law.cornell.edu/uscode/text/26/219#b_1", + "https://www.law.cornell.edu/uscode/text/26/219#c", + "https://www.law.cornell.edu/uscode/text/26/219#g", + "https://www.law.cornell.edu/uscode/text/26/408#o_2", + ) + + def formula(person, period, parameters): + compensation = person("ira_compensation", period) + filer = person("is_tax_unit_head_or_spouse", period) + joint = person.tax_unit("tax_unit_is_joint", period) + total_compensation = person.tax_unit.sum(compensation * filer) + spouse_compensation = max_(0, total_compensation - compensation) + contributions = person("traditional_ira_contributions", period) + roth_contributions = person("roth_ira_contributions", period) + retirement = parameters(period).gov.irs.gross_income.retirement_contributions + catch_up = person("age", period) >= retirement.catch_up.age_threshold + dollar_limit = retirement.limit.ira + catch_up * retirement.catch_up.limit.ira + # Section 219(c) subtracts deductible and designated nondeductible + # traditional contributions, plus any Roth contributions. Section + # 408(o)(2) caps designated traditional amounts; excess Roth amounts + # still consume the spouse's compensation under section 219(c). + # Only the higher earner's allowance is needed for the lower earner. + compensation_consumed = min_( + max_(0, contributions), min_(compensation, dollar_limit) + ) + max_(0, roth_contributions) + spouse_contributions = max_( + 0, + person.tax_unit.sum(compensation_consumed * filer) - compensation_consumed, + ) + lower_earning_spouse = joint & filer & (compensation < spouse_compensation) + available_compensation = where( + lower_earning_spouse, + compensation + max_(0, spouse_compensation - spouse_contributions), + compensation, + ) + # Applying the cap here also covers directly supplied actual contributions. + limit = min_( + available_compensation, person("ira_219g_deductible_limit", period) + ) + claimant = person("is_tax_unit_head", period) | ( + person("is_tax_unit_spouse", period) & joint + ) + filer_deduction = max_(0, min_(contributions, limit)) * claimant + # A tax unit dependent's deduction is on the dependent's own return. + # It is recorded on the dependent so that person-level consumers see + # it; the filers' above-the-line total sums the head and spouse only. + return where( + person("is_tax_unit_dependent", period), + person("dependent_traditional_ira_deduction", period), + filer_deduction, + ) 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/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index 120ca814d65..28cb37c3abf 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) class filer_loss_limited_net_capital_gains(Variable): @@ -13,7 +16,11 @@ class filer_loss_limited_net_capital_gains(Variable): "13), with a net loss limited under 26 USC 1211(b). This is Form 8960 " "line 5a without Schedule 1 line 4. A tax unit dependent's gains and " "losses are on the dependent's own return. Unlike " - "loss_limited_net_capital_gains, this leaves dependents out." + "loss_limited_net_capital_gains, this leaves dependents out. It " + "uses the same filer Schedule D lines as the tax worksheet. A tax " + "unit net_capital_gains amount supplied as an input " + "is kept; that amount is read as covering every member, and any " + "dependent's person-level gains and losses are then taken out." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/1211#b", @@ -25,15 +32,5 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] - net_capital_gains = tax_unit_non_dep_add( - tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] - ) - # Capital gain distributions go on Schedule D line 13 with the other - # gains and losses, so they net before the limit. Each filer's input - # is floored at zero, as in irs_gross_income. - person = tax_unit.members - not_dependent = ~person("is_tax_unit_dependent", period) - distributions = tax_unit.sum( - not_dependent * max_(0, person("non_sch_d_capital_gains", period)) - ) - return max_(-loss_limit, net_capital_gains + distributions) + lines = filer_schedule_d_lines(tax_unit, period) + return max_(-loss_limit, lines.line_16) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.py index c94dfb8efc8..0301ca340a3 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_social_security.py @@ -1,6 +1,54 @@ from policyengine_us.model_api import * +def dependent_taxable_social_security_amount(person, period, parameters, modified_agi): + """A dependent's taxable Social Security under IRC 86 for a given modified + AGI, on the dependent's own marital circumstances.""" + p = parameters(period).gov.irs.social_security.taxability + gross_ss = max_(0, person("social_security", period)) + # IRC 86(b)(1): modified AGI plus one-half of the benefits. + combined_income = modified_agi + p.combined_income_ss_fraction * gross_ss + lives_with_spouse = person("dependent_lives_with_spouse", period) + base_amount = where( + lives_with_spouse, + p.threshold.base.separate_cohabitating, + p.threshold.base.main["SINGLE"], + ) + adjusted_base_amount = where( + lives_with_spouse, + p.threshold.adjusted_base.separate_cohabitating, + p.threshold.adjusted_base.main["SINGLE"], + ) + # IRC 86(a)(1): the lesser of half the benefits or half the excess of + # combined income over the base amount. + amount_under_paragraph_1 = min_( + p.rate.base.benefit_cap * gross_ss, + p.rate.base.excess * max_(0, combined_income - base_amount), + ) + # IRC 86(a)(2)(A)(ii): the lesser of the paragraph (1) amount or half + # the difference between the adjusted base and base amounts. + bracket_amount = min_( + amount_under_paragraph_1, + p.rate.additional.bracket * (adjusted_base_amount - base_amount), + ) + # IRC 86(a)(2): the lesser of 85 percent of the excess over the + # adjusted base amount plus the bracket amount, or 85 percent of + # the benefits. + amount_over_adjusted_base = min_( + p.rate.additional.excess * max_(0, combined_income - adjusted_base_amount) + + bracket_amount, + p.rate.additional.benefit_cap * gross_ss, + ) + return select( + [ + combined_income < base_amount, + combined_income < adjusted_base_amount, + ], + [0, amount_under_paragraph_1], + default=amount_over_adjusted_base, + ) + + class dependent_taxable_social_security(Variable): value_type = float entity = Person @@ -28,49 +76,6 @@ class dependent_taxable_social_security(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.irs.social_security.taxability - gross_ss = max_(0, person("social_security", period)) - # IRC 86(b)(1): modified AGI plus one-half of the benefits. - combined_income = ( - person("dependent_taxable_ss_magi", period) - + p.combined_income_ss_fraction * gross_ss - ) - lives_with_spouse = person("dependent_lives_with_spouse", period) - base_amount = where( - lives_with_spouse, - p.threshold.base.separate_cohabitating, - p.threshold.base.main["SINGLE"], - ) - adjusted_base_amount = where( - lives_with_spouse, - p.threshold.adjusted_base.separate_cohabitating, - p.threshold.adjusted_base.main["SINGLE"], - ) - # IRC 86(a)(1): the lesser of half the benefits or half the excess of - # combined income over the base amount. - amount_under_paragraph_1 = min_( - p.rate.base.benefit_cap * gross_ss, - p.rate.base.excess * max_(0, combined_income - base_amount), - ) - # IRC 86(a)(2)(A)(ii): the lesser of the paragraph (1) amount or half - # the difference between the adjusted base and base amounts. - bracket_amount = min_( - amount_under_paragraph_1, - p.rate.additional.bracket * (adjusted_base_amount - base_amount), - ) - # IRC 86(a)(2): the lesser of 85 percent of the excess over the - # adjusted base amount plus the bracket amount, or 85 percent of - # the benefits. - amount_over_adjusted_base = min_( - p.rate.additional.excess * max_(0, combined_income - adjusted_base_amount) - + bracket_amount, - p.rate.additional.benefit_cap * gross_ss, - ) - return select( - [ - combined_income < base_amount, - combined_income < adjusted_base_amount, - ], - [0, amount_under_paragraph_1], - default=amount_over_adjusted_base, + return dependent_taxable_social_security_amount( + person, period, parameters, person("dependent_taxable_ss_magi", period) ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py index e3fd6292b91..1b712f483cb 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py @@ -21,11 +21,69 @@ "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", "educator_expense_ald": "educator_expense_ald_person", "early_withdrawal_penalty": "early_withdrawal_penalty", - "traditional_ira_contributions": "traditional_ira_contributions", + # The dependent's own IRC 219 deduction, figured on their own return; the + # filer's traditional_ira_deduction is limited to the head and spouse. + "traditional_ira_deduction": "dependent_traditional_ira_deduction", "alimony_expense_ald": "alimony_expense_ald_person", } +def dependent_income_and_deductions(person, period, parameters): + """A dependent's own-return income before above-the-line deductions, and + the person-level above-the-line deductions to subtract from it. + + Income nets business losses and capital losses (within the IRC 1211(b) + limit) and leaves out Social Security, as IRC 86(b)(2) requires. + """ + p = parameters(period).gov.irs + sources = p.gross_income.sources + revoked = p.social_security.taxability.income.revoked_deductions + ald = [deduction for deduction in p.ald.deductions if deduction not in revoked] + deduct_losses = "loss_ald" in ald + + # Capital gain distributions net with capital gains and losses on + # Schedule D (line 13), inside the IRC 1211(b) limit, below. + net_distributions = ( + "capital_gains" in sources and "non_sch_d_capital_gains" in sources + ) + + income = 0 + for source in sources: + components = DEPENDENT_GROSS_INCOME_SOURCE_OVERRIDES.get(source, [source]) + for component in components: + if net_distributions and component == "non_sch_d_capital_gains": + continue + amount = add(person, period, [component]) + if deduct_losses and component in BUSINESS_LOSS_SOURCES: + income += amount + else: + income += max_(0, amount) + + if "capital_gains" in sources: + # IRC 1211(b): net capital losses are deductible up to the limit, + # which is lower for a married individual filing separately. + lives_with_spouse = person("dependent_lives_with_spouse", period) + capital_loss_limit = p.ald.loss.capital.max + loss_limit = deduct_losses * where( + lives_with_spouse, + capital_loss_limit["SEPARATE"], + capital_loss_limit["SINGLE"], + ) + capital_gains = person("capital_gains", period) + if net_distributions: + capital_gains = capital_gains + max_( + 0, person("non_sch_d_capital_gains", period) + ) + income += max_(capital_gains, -loss_limit) + + deductions = [ + PERSON_ABOVE_THE_LINE_DEDUCTIONS[deduction] + for deduction in ald + if deduction in PERSON_ABOVE_THE_LINE_DEDUCTIONS + ] + return income, deductions + + class dependent_taxable_ss_magi(Variable): value_type = float entity = Person @@ -53,52 +111,7 @@ class dependent_taxable_ss_magi(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.irs - sources = p.gross_income.sources - revoked = p.social_security.taxability.income.revoked_deductions - ald = [deduction for deduction in p.ald.deductions if deduction not in revoked] - deduct_losses = "loss_ald" in ald - - # Capital gain distributions net with capital gains and losses on - # Schedule D (line 13), inside the IRC 1211(b) limit, below. - net_distributions = ( - "capital_gains" in sources and "non_sch_d_capital_gains" in sources - ) - - income = 0 - for source in sources: - components = DEPENDENT_GROSS_INCOME_SOURCE_OVERRIDES.get(source, [source]) - for component in components: - if net_distributions and component == "non_sch_d_capital_gains": - continue - amount = add(person, period, [component]) - if deduct_losses and component in BUSINESS_LOSS_SOURCES: - income += amount - else: - income += max_(0, amount) - - if "capital_gains" in sources: - # IRC 1211(b): net capital losses are deductible up to the limit, - # which is lower for a married individual filing separately. - lives_with_spouse = person("dependent_lives_with_spouse", period) - capital_loss_limit = p.ald.loss.capital.max - loss_limit = deduct_losses * where( - lives_with_spouse, - capital_loss_limit["SEPARATE"], - capital_loss_limit["SINGLE"], - ) - capital_gains = person("capital_gains", period) - if net_distributions: - capital_gains = capital_gains + max_( - 0, person("non_sch_d_capital_gains", period) - ) - income += max_(capital_gains, -loss_limit) - - deductions = [ - PERSON_ABOVE_THE_LINE_DEDUCTIONS[deduction] - for deduction in ald - if deduction in PERSON_ABOVE_THE_LINE_DEDUCTIONS - ] + income, deductions = dependent_income_and_deductions(person, period, parameters) adjusted_gross_income = income - add(person, period, deductions) # IRC 86(b)(2)(B): tax-exempt interest is added back. return adjusted_gross_income + person("tax_exempt_interest_income", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py index f0ca6fb51f5..8466dd97f87 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py @@ -39,6 +39,10 @@ class section_199a_net_capital_gain(Variable): title="2025 Instructions for Form 8995-A, line 34", href="https://www.irs.gov/pub/irs-prior/i8995a--2025.pdf#page=7", ), + dict( + title="TD 9847, Net Capital Gain, 84 FR 2954", + href="https://www.govinfo.gov/content/pkg/FR-2019-02-08/pdf/2019-01025.pdf#page=3", + ), ] def formula(tax_unit, period, parameters): @@ -47,6 +51,8 @@ def formula(tax_unit, period, parameters): # 1(h)(11)(B))". Neither part is reduced by a Form 4952 line 4g # election: the form uses Form 1040 line 3a, which the Form 4952 # instructions say not to reduce, and Schedule D lines 15 and 16. + # TD 9847's preamble explicitly confirms that elected gains and + # dividends remain net capital gain for the section 199A deduction. # Schedule D line 15: net long-term gain, including capital gain # distributions (long-term under 26 U.S.C. 852(b)(3)(B)). Without # Schedule D, net capital gain is Form 1040 line 7a. diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.py index edd31fe6157..842d794d44b 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/exemption/amt_exemption.py @@ -13,6 +13,8 @@ class amt_exemption(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/55#d", # 26 U.S.C. § 55(d) + "https://www.law.cornell.edu/uscode/text/26/59#j", + "https://www.law.cornell.edu/uscode/text/26/911#d_2", "https://www.irs.gov/instructions/i6251", ] @@ -35,7 +37,10 @@ def formula(tax_unit, period, parameters): # A reduced exemption amount is applied to kiddie tax filers kiddie_tax_applies = tax_unit("amt_kiddie_tax_applies", period) - adj_earnings = tax_unit("filer_adjusted_earnings", period) + # IRC 59(j) uses earned income under IRC 911(d)(2), rather than + # the EITC gross-income inclusion rule. Preserve the existing gross + # earnings cap independently of EITC payroll exclusions. + adj_earnings = tax_unit_non_dep_sum("adjusted_earnings", tax_unit, period) child_amount = p.exemption.child.amount exemption_cap = where( diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py index a0e3654534f..7f15bcc6666 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py @@ -9,8 +9,9 @@ def filer_schedule_d_lines(tax_unit, period): """Schedule D (Form 1040) lines 7, 15 and 16 of the head and spouse, shared - by the Form 1040 line 16 routing (has_qdiv_or_ltcg), net_capital_gain and - the Schedule D Tax Worksheet. + by Form 1040 line 7a (filer_loss_limited_net_capital_gains), the line 16 + routing (has_qdiv_or_ltcg), net_capital_gain and the Schedule D Tax + Worksheet. Line 7 is the net short-term capital gain or loss. Line 15 is the net long-term capital gain or loss, including capital gain distributions @@ -19,12 +20,16 @@ def filer_schedule_d_lines(tax_unit, period): A tax unit dependent's gains, losses and distributions belong on the dependent's own return, so they are left out, as irs_gross_income leaves - them out of adjusted gross income. Line 16 starts from net_capital_gains, - so a tax unit amount supplied there is kept; that amount is read as - covering every member, and any dependent's person-level gains and losses - are then taken out. An explicit aggregate override can make line 16 - differ from lines 7 and 15 combined, and the worksheet and statutory - net-capital-gain paths can then disagree. + them out of adjusted gross income. Line 16 combines the filers' lines 7 + and 15 directly, so a large dependent's gain cannot round away a filer's + smaller gain in the float32 tax unit aggregate. When net_capital_gains + differs from the computed all-member aggregate in its storage precision, + that supplied amount covers every member, so dependents' own gains and + losses are removed before the filers' distributions are added. An amount + equal to the computed aggregate uses the direct filer components. + An explicit aggregate override can make line 16 differ from lines 7 and + 15 combined, and the worksheet and statutory net-capital-gain paths can + then disagree. Capital gain distributions reported without Schedule D (non_sch_d_capital_gains) are long-term capital gains (26 U.S.C. @@ -43,14 +48,20 @@ def filer_schedule_d_lines(tax_unit, period): tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + distributions ) - # Line 16: lines 7 and 15 combined, figured from net_capital_gains so a - # supplied tax unit amount is kept. + # A component-derived aggregate may have rounded away a filer's dollar, + # so sum the filers directly when it matches its normal stored value. + # A differing supplied aggregate covers every member: subtract the + # dependents directly instead of reconciling it through a rounded total. + net_capital_gains = tax_unit("net_capital_gains", period) + computed_gains = add( + tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] + ).astype(net_capital_gains.dtype) dependent_gains = tax_unit.sum( - dependent - * ( - person("long_term_capital_gains", period) - + person("short_term_capital_gains", period) - ) + dependent * person("long_term_capital_gains", period) + ) + tax_unit.sum(dependent * person("short_term_capital_gains", period)) + line_16 = where( + net_capital_gains == computed_gains, + line_7 + line_15, + net_capital_gains.astype(float) - dependent_gains + distributions, ) - line_16 = tax_unit("net_capital_gains", period) - dependent_gains + distributions return FilerScheduleDLines(line_7=line_7, line_15=line_15, line_16=line_16) diff --git a/policyengine_us/variables/gov/simulation/capital_gains_responses.py b/policyengine_us/variables/gov/simulation/capital_gains_responses.py index cf19dfcb2e7..bc9bfc51d2c 100644 --- a/policyengine_us/variables/gov/simulation/capital_gains_responses.py +++ b/policyengine_us/variables/gov/simulation/capital_gains_responses.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + user_set_variables, +) from policyengine_us.variables.gov.simulation.behavioral_response_measurements import ( calculate_relative_capital_gains_mtr_change, get_behavioral_response_measurements, @@ -101,15 +104,13 @@ def formula(person, period, parameters): # pragma: no cover simulation = person.simulation DELTA = 1_000 adult_index_values = person("adult_index_cg", period) + inputs = user_set_variables(simulation) for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index for variable in simulation.tax_benefit_system.variables: variable_data = simulation.tax_benefit_system.variables[variable] - if ( - variable not in simulation.input_variables - and not variable_data.is_input_variable() - ): + if variable not in inputs and not variable_data.is_input_variable(): alt_simulation.delete_arrays(variable) alt_simulation.set_input( "capital_gains", diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py new file mode 100644 index 00000000000..fbd5f09ede5 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -0,0 +1,69 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, +) + + +def _ssi_established_spouse_indices(person, period, first, second): + """Rows of each person's co-resident spouse, or -1. + + SSI resource deeming uses this one test for a claimant's spouse + (416.1202(a)), a parent's spouse (416.1202(b)) and whether a child is + married (416.1856). A marital unit holds an unmarried person or a married, + cohabiting couple, so the members of a two-person marital unit who live in + the same household are spouses. So are the head and spouse of one tax + unit who share a marital unit, which covers situations that omit marital + units (those put everyone in one unit). Parent links that name one as the + other's parent always rule a pair out. When the simulation has a single + marital unit, which may be that default group, a pair is also ruled out + when exactly one of them is a dependent of the tax unit they share (no + one can claim a spouse), so a default unit never pairs a parent with the + child they claim. Explicit marital units otherwise stand, whatever tax + roles the model infers. Ambiguous candidates resolve to no spouse. + Known limitation: a simulation holding only a married couple in which + one spouse is the other's tax dependent (as the model infers for a + married minor when tax roles are omitted) cannot be told apart from a + default group, so that couple is not treated as married; supply tax roles + that do not make a spouse a dependent. + """ + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + dependent = person("is_tax_unit_dependent", period) + joint = head_or_spouse & person.tax_unit("tax_unit_married", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + couple = person.marital_unit.nb_persons() == 2 + marital_unit = person.marital_unit.reference_entity.members_entity_id + possible_default = person.marital_unit.count == 1 + own_index = np.arange(person.count) + spouse = np.full(person.count, -1, dtype=int) + candidates = np.zeros(person.count, dtype=int) + household = person.household + for position in range( + int(np.max(household.reference_entity.members_position, initial=-1)) + 1 + ): + member = household.value_nth_person(position, own_index, default=-1) + safe = np.maximum(member, 0) + names_applicant = (first[safe] == own_index) | (second[safe] == own_index) + named_by_applicant = (first == member) | (second == member) + same_tax_unit = tax_unit[safe] == tax_unit + # One claims the other when exactly one of them is a dependent of + # the tax unit they share; two dependents can be a married couple. + claims = possible_default & same_tax_unit & (dependent != dependent[safe]) + same_marital_unit = marital_unit[safe] == marital_unit + shown = ( + (member >= 0) + & (member != own_index) + & same_marital_unit + & (couple | (joint & head_or_spouse[safe] & same_tax_unit)) + & ~claims + & ~names_applicant + & ~named_by_applicant + ) + spouse = np.where(shown, member, spouse) + candidates += shown + return np.where(candidates == 1, spouse, -1) + + +def _ssi_spouse_index(person, period): + """The row of each person's co-resident spouse, or -1.""" + first, second = co_resident_parent_indices(person, period) + return _ssi_established_spouse_indices(person, period, first, second) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_resource_deeming_child.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_resource_deeming_child.py new file mode 100644 index 00000000000..42ac675676f --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_resource_deeming_child.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) + + +class is_ssi_resource_deeming_child(Variable): + value_type = bool + entity = Person + label = "Child subject to parental SSI resource deeming" + definition_period = MONTH + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202#p-416.1202(b)", + "https://www.ecfr.gov/current/title-20/section-416.1856", + "https://www.ecfr.gov/current/title-20/section-416.1167", + "https://secure.ssa.gov/poms.nsf/lnx/0501330200", + ) + + def formula(person, period, parameters): + # SI 01330.200 B divides only among SSI-eligible children. Match + # is_ssi_eligible's immigration gate without reading eligibility or + # the resource test, which depend on this allocation. + immigration_status = person("immigration_status", period) + is_citizen = immigration_status == immigration_status.possible_values.CITIZEN + meets_immigration_status = is_citizen | person( + "is_ssi_qualified_noncitizen", period + ) + # 416.1856: 'You are not married' and 'not the head of a household'. + # Marriage uses the same spouse test as resource deeming, so the + # default marital unit of a situation that omits marital units does + # not marry a child to the parent who claims them. + # age is annual in the model: supply the relevant attained age. + return ( + person("is_ssi_aged_blind_disabled", period) + & meets_immigration_status + & (person("age", period.this_year) < 18) + & (_ssi_spouse_index(person, period) < 0) + & ~person("is_household_head", period) + & ~person("ssi_lives_in_medical_treatment_facility", period) + & ~person("ssi_resource_deeming_waiver", period) + & ~person("ssi_resource_deeming_child_is_ineligible", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py new file mode 100644 index 00000000000..8a0c3c52e50 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) + + +class is_ssi_spousal_resource_deeming_applies(Variable): + value_type = bool + entity = Person + label = "Spouse's resources count toward the SSI resource test" + definition_period = MONTH + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202#p-416.1202(a)", + "https://www.ecfr.gov/current/title-20/section-416.1160", + "https://secure.ssa.gov/poms.nsf/lnx/0501330100", + "https://www.ecfr.gov/current/title-20/section-416.1167", + ) + + def formula(person, period, parameters): + # 416.1160: an ineligible spouse 'lives with you as your husband or + # wife and is not eligible for SSI benefits'; 416.1205(a) counts 'the + # resources of the spouse' against the couple limit. Eligibility is + # not known before the resource test, and 416.1205(b) tests an + # eligible couple's combined resources against the same limit, so a + # spouse's resources count either way when the claim is not already + # joint. The spouse is identified as a parent's spouse is in parental + # deeming. 416.1167 retains an absent person's household membership: + # enter a temporarily absent spouse in the household they remain in. + spouse = _ssi_spouse_index(person, period) + return ( + person("is_ssi_aged_blind_disabled", period) + & ~person("ssi_claim_is_joint", period) + & (spouse >= 0) + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_child_is_ineligible.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_child_is_ineligible.py new file mode 100644 index 00000000000..6a2e9e92d7f --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_child_is_ineligible.py @@ -0,0 +1,17 @@ +from policyengine_us.model_api import * + + +class ssi_resource_deeming_child_is_ineligible(Variable): + value_type = bool + entity = Person + label = "Child is otherwise ineligible for SSI resource deeming allocation" + definition_period = MONTH + reference = "https://secure.ssa.gov/poms.nsf/lnx/0501330200" + documentation = """ + Adjudicated SSI ineligibility for a reason other than the resource test, + used to remove a child from the equal parental resource allocation. + POMS SI 01330.200 B: 'If an eligible child is later determined ineligible + for any reason' redivide among the remaining eligible children effective + the first month of ineligibility. Resource ineligibility is calculated + within the resource deeming formula, avoiding an eligibility cycle. + """ diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_excluded_military_resources.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_excluded_military_resources.py new file mode 100644 index 00000000000..8aef144443e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_excluded_military_resources.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class ssi_resource_deeming_excluded_military_resources(Variable): + value_type = float + entity = Person + label = "Unspent retroactive military payments excluded from SSI deeming" + unit = USD + definition_period = MONTH + quantity_type = STOCK + reference = "https://www.ecfr.gov/current/title-20/section-416.1202" + documentation = """ + The part of otherwise countable resources consisting of qualifying + retroactive special pay under 37 USC 310 or combat-zone family separation + allowance under 37 USC 427. Sections 416.1202(a)(2)-(3) and (b)(1)(ii)-(iii) + exclude the unspent amount for nine months beginning with the month + following receipt. Supply only the amount within that exclusion window; + the model has no payment receipt history from which to derive it. + """ diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_waiver.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_waiver.py new file mode 100644 index 00000000000..332b5fb71a4 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resource_deeming_waiver.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class ssi_resource_deeming_waiver(Variable): + value_type = bool + entity = Person + label = "Child meets the SSI resource deeming home care exception" + definition_period = MONTH + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202#p-416.1202(b)(2)" + ) + documentation = """ + Adjudicated exception under 20 CFR 416.1202(b)(2). All three conditions + must hold: the disabled child previously received reduced SSI in a + medical treatment facility under 416.414; qualifies for Medicaid home + care under 1915(c) or 1902(e)(3); and would otherwise be ineligible due + to parental income or resource deeming. Medicaid enrollment alone does + not establish this exception. Supply the determination for each month. + """ diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py new file mode 100644 index 00000000000..2c0a1f658dd --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py @@ -0,0 +1,157 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_established_spouse_indices, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, + has_parent_ids, + unlinked_parent, +) + + +def _ssi_unlinked_parental_resource_pool(person, resources, parent, spouse): + """Return tax-unit/household proxy resources, count and exact pool keys. + + The deemors are the child's co-resident tax-unit members who are unlinked + parents or a parent's spouse, and the spouse of each of those, wherever + that spouse files (416.1202(b)(1): a parent and the parent's spouse). + With no parent ids, is_parent cannot distinguish a grandparent or an + unrelated parent from the child's parent: this is a data limitation. + Keys hold every deemor's row, sorted, so children share a pool exactly + when they share a deemor set. + """ + parent = parent | ((spouse >= 0) & parent[np.maximum(spouse, 0)]) + group = person.tax_unit + indices = np.arange(person.count) + households = person.household.reference_entity.members_entity_id + tax_units = group.reference_entity.members_entity_id + positions = range( + int(np.max(group.reference_entity.members_position, initial=-1)) + 1 + ) + + def deemors(): + for position in positions: + member = group.value_nth_person(position, indices, default=-1) + safe = np.maximum(member, 0) + selected = (member >= 0) & (households[safe] == households) & parent[safe] + yield member, selected + # Members of this tax unit are scanned above; add spouses who + # file elsewhere. Each person has at most one spouse. + partner = np.where(selected, spouse[safe], -1) + outside = (partner >= 0) & (tax_units[np.maximum(partner, 0)] != tax_units) + yield partner, outside + + count = np.zeros(person.count, dtype=int) + for _, selected in deemors(): + count += selected + keys = np.full((person.count, max(4, int(np.max(count, initial=0)))), -1) + total = np.zeros(person.count) + filled = np.zeros(person.count, dtype=int) + for rows, selected in deemors(): + keys[indices[selected], filled[selected]] = rows[selected] + total += np.where(selected, resources[np.maximum(rows, 0)], 0) + filled += selected + return total, count, keys + + +def _ssi_parental_resource_pool(person, period, resources): + """Resolve relationships first and identify children sharing deemors. + + 416.1202(b)(1) includes named co-resident natural, adoptive or step + parents, wherever their tax units, and an established co-resident spouse + of each parent. Duplicate named/inferred parents count once. Without + ids, use only unlinked_parent in the tax-unit proxy, as the parent-link + contract requires. SI 01330.200 B divides the excess among children of + the same parent(s), so allocation groups are exact deemor sets rather + than tax units, including when linked and unlinked children share a set. + """ + first, second = co_resident_parent_indices(person, period) + spouse = _ssi_established_spouse_indices(person, period, first, second) + total, count, keys = _ssi_unlinked_parental_resource_pool( + person, resources, unlinked_parent(person, period), spouse + ) + linked = has_parent_ids(person, period) + if np.any(linked): + first_spouse = np.where(first >= 0, spouse[first], -1) + second_spouse = np.where(second >= 0, spouse[second], -1) + named = np.column_stack((first, second, first_spouse, second_spouse)) + named.sort(axis=1) + # Deduplicate before sorting again to give every identical set the + # same key, independently of input parent slot or household order. + named[:, 1:] = np.where(named[:, 1:] == named[:, :-1], -1, named[:, 1:]) + named.sort(axis=1) + linked_total = np.sum(np.where(named >= 0, resources[named], 0), axis=1) + linked_count = np.sum(named >= 0, axis=1) + total = np.where(linked, linked_total, total) + count = np.where(linked, linked_count, count) + keys[linked] = -1 + keys[linked, :4] = named[linked] + # Sorted rows make equal deemor sets equal keys, linked or not. + keys.sort(axis=1) + _, pool = np.unique(keys, axis=0, return_inverse=True) + return total, count, pool + + +class ssi_resources_deemed_from_ineligible_parent(Variable): + value_type = float + entity = Person + label = "SSI resources deemed from ineligible parents" + unit = USD + definition_period = MONTH + quantity_type = STOCK + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202#p-416.1202(b)", + "https://www.ecfr.gov/current/title-20/section-416.1205", + "https://www.ecfr.gov/current/title-20/section-416.1167", + "https://secure.ssa.gov/poms.nsf/lnx/0501330200", + "https://secure.ssa.gov/poms.nsf/lnx/0501330280", + "https://secure.ssa.gov/poms.nsf/lnx/0501330310", + "https://secure.ssa.gov/poms.nsf/lnx/0501330340", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.ssa.ssi.eligibility.resources.limit + # Under 416.1167 a temporarily absent person should be entered in + # the household they remain a member of; tax-unit membership alone + # does not bring permanently nonresident relatives into that home. + parental_resources, parent_count, pool = _ssi_parental_resource_pool( + person, period, person("ssi_resources_for_deeming", period) + ) + # 'only to the extent that those resources exceed the resource limits + # described in § 416.1205': use the reformable individual/couple limits. + allowance = where(parent_count > 1, p.couple, p.individual) + excess = max_(0, parental_resources - allowance) + # Do not discard ABD parents. SI 01330.310 B-C says zero if parent(s) + # meets resource eligibility, usual deeming if not. The Ryder example + # (.340) has an ABD mother failing the couple limit: 3100-3000=100. + own_resources = person("ssi_countable_resources", period) + active = person("is_ssi_resource_deeming_child", period) & ( + own_resources <= p.individual + ) + # SI 01330.200 B divides among SSI-eligible children. A child whose + # countable income (with income deemed from parents) already reaches + # the payment is ineligible 'for any reason': they are still deemed a + # share, but do not count in the division. Neither variable reads the + # resource test. + income_eligible = person( + "ssi_countable_income", period.this_year + ) / MONTHS_IN_YEAR < person("ssi_amount_if_eligible", period) + deemed = np.zeros(person.count) + # SI 01330.200 B: 'equally divide' among eligible children; when a + # child is ineligible 'divide ... among the remaining eligible children'. + # A child outside the division is deemed the eligible children's share, + # or the whole excess when none is left. Retain the last tested share + # for a child who fails on resources, then redistribute. + # Each round removes at least one child or leaves the result stable; + # at most the largest household size rounds are needed (Goode, .280). + rounds = int(np.max(person.household.nb_persons(), initial=0)) + pool_count = int(np.max(pool, initial=-1)) + 1 + for _ in range(rounds): + count = np.bincount(pool[active & income_eligible], minlength=pool_count) + share = excess / max_(1, count[pool]) + deemed = where(active, share, deemed) + remaining = active & (own_resources + share <= p.individual) + if np.array_equal(active, remaining): + break + active = remaining + return deemed diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py new file mode 100644 index 00000000000..6def145a7ea --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py @@ -0,0 +1,43 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) + + +class ssi_resources_deemed_from_ineligible_spouse(Variable): + value_type = float + entity = Person + label = "SSI resources counted from a spouse" + unit = USD + definition_period = MONTH + quantity_type = STOCK + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202#p-416.1202(a)", + "https://secure.ssa.gov/poms.nsf/lnx/0501330100", + ) + + def formula(person, period, parameters): + spouse = np.maximum(_ssi_spouse_index(person, period), 0) + # 416.1202(a): an ineligible spouse's resources count 'whether or not + # such resources are available', less the exclusions 416.1202(a)(2) + # grants an ineligible spouse. An aged, blind or disabled spouse who + # meets the immigration condition is tested as part of a couple under + # 416.1205(b), with only the exclusions an eligible individual gets. + # A spouse who fails SSI's citizenship or qualified-noncitizen + # condition is 'not eligible for SSI benefits' (416.1160) whatever + # their age or disability, so keeps the ineligible-spouse exclusions. + immigration_status = person("immigration_status", period) + could_be_eligible = person("is_ssi_aged_blind_disabled", period) & ( + (immigration_status == immigration_status.possible_values.CITIZEN) + | person("is_ssi_qualified_noncitizen", period) + ) + resources = where( + could_be_eligible[spouse], + person("ssi_countable_resources", period)[spouse], + person("ssi_resources_for_deeming", period)[spouse], + ) + return where( + person("is_ssi_spousal_resource_deeming_applies", period), + resources, + 0, + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_for_deeming.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_for_deeming.py new file mode 100644 index 00000000000..eb292f1e111 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_for_deeming.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class ssi_resources_for_deeming(Variable): + value_type = float + entity = Person + label = "Countable SSI resources of a spouse or parent for deeming" + unit = USD + definition_period = MONTH + quantity_type = STOCK + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202", + "https://secure.ssa.gov/poms.nsf/lnx/0501330220", + ) + + def formula(person, period, parameters): + # 416.1202(a)(1), (b)(1)(i): 'Pension funds' include IRAs and + # work-related pension plans. ssi_countable_resources already excludes + # retirement assets, the home, vehicle and other noncountable assets. + resources = person("ssi_countable_resources", period) + excluded = person("ssi_resource_deeming_excluded_military_resources", period) + return max_(0, resources - max_(0, excluded)) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py index 313ec6353db..8a593952788 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py @@ -6,19 +6,35 @@ class meets_ssi_resource_test(Variable): entity = Person label = "Meets SSI resource test" definition_period = MONTH - reference = "https://secure.ssa.gov/poms.nsf/lnx/0501110000" + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1202", + "https://www.ecfr.gov/current/title-20/section-416.1205", + "https://secure.ssa.gov/poms.nsf/lnx/0501330110", + ) def formula(person, period, parameters): p = parameters(period).gov.ssa.ssi joint_claim = person("ssi_claim_is_joint", period) personal_resources = person("ssi_countable_resources", period) + spousal_deeming = person("is_ssi_spousal_resource_deeming_applies", period) + deemed_resources = add( + person, + period, + [ + "ssi_resources_deemed_from_ineligible_spouse", + "ssi_resources_deemed_from_ineligible_parent", + ], + ) countable_resources = where( joint_claim, person.marital_unit.sum(personal_resources), - personal_resources, + personal_resources + deemed_resources, ) + # 416.1205(a): an individual living with an ineligible spouse counts + # 'including the resources of the spouse' against the spouse limit. + # This applies even when no income is deemed or the spouse owns zero. resource_limit = where( - joint_claim, + joint_claim | spousal_deeming, p.eligibility.resources.limit.couple, p.eligibility.resources.limit.individual, ) 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" diff --git a/policyengine_us/variables/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.py b/policyengine_us/variables/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.py index 3995a7e53a6..343ff6dcb8d 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/alternative_minimum_tax/amti/ca_pre_exemption_amti.py @@ -8,9 +8,12 @@ class ca_pre_exemption_amti(Variable): defined_for = StateCode.CA unit = USD definition_period = YEAR - reference = "https://www.ftb.ca.gov/forms/2024/2024-540-p.pdf#page=1" + reference = ( + "https://www.ftb.ca.gov/forms/2024/2024-540-p.pdf#page=1", + "https://www.ftb.ca.gov/forms/2025/2025-540-p-instructions.html", + ) - # Schedule P (540) Part I, line 19: combine regular taxable income + # Schedule P (540) Part I, line 19: combine signed taxable income # (line 15) with the total AMT adjustments and preferences (line 14) and # subtract the restored itemized deductions limitation (line 18). # @@ -19,10 +22,15 @@ class ca_pre_exemption_amti(Variable): # pre-limitation itemized deductions must NOT be added on top; doing so # double-counts disallowed items and adds back deductions (e.g. charity, # acquisition mortgage interest) that AMT still allows. - adds = [ - "ca_amti_adjustments", - "ca_taxable_income", - ] - subtracts = [ - "ca_itemized_deductions_limitation", - ] + def formula(tax_unit, period, parameters): + taxable_income = tax_unit("ca_taxable_income", period) + # Line 15: when Form 540 line 19 is zero, use the signed difference + # between AGI (line 17) and deductions (line 18) before AMT add-backs. + signed_taxable_income = where( + taxable_income == 0, + tax_unit("ca_agi", period) - tax_unit("ca_deductions", period), + taxable_income, + ) + adjustments = tax_unit("ca_amti_adjustments", period) + limitation = tax_unit("ca_itemized_deductions_limitation", period) + return signed_taxable_income + adjustments - limitation diff --git a/policyengine_us/variables/gov/states/ca/tax/income/ca_taxable_wages.py b/policyengine_us/variables/gov/states/ca/tax/income/ca_taxable_wages.py new file mode 100644 index 00000000000..89fd3068d65 --- /dev/null +++ b/policyengine_us/variables/gov/states/ca/tax/income/ca_taxable_wages.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class ca_taxable_wages(Variable): + value_type = float + entity = Person + label = "California taxable wages for earned income credits" + unit = USD + definition_period = YEAR + reference = ( + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17052", # (c)(4) + "https://www.ftb.ca.gov/forms/2025/2025-3514-booklet.html", # Lines 13 and 23a + "https://edd.ca.gov/siteassets/files/pdf_pub_ctr/de231eb.pdf#page=3", # HSA payroll contributions + ) + + def formula(person, period, parameters): + # FTB 3514 line 13 uses California wages (W-2 box 16), which also + # enter the YCTC wage ceiling on line 23a. Traditional elective + # deferrals and pre-tax health premiums are excluded, but payroll + # HSA contributions remain California-taxable wages. Floor state + # wages once, before netting business income or losses. + excluded_wages = add( + person, + period, + [ + "traditional_401k_contributions", + "traditional_403b_contributions", + "pre_tax_health_insurance_premiums", + ], + ) + return max_(0, person("employment_income", period) - excluded_wages) diff --git a/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.py b/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.py index 385bb883fdb..1127c1335fc 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_earned_income.py @@ -11,17 +11,29 @@ class ca_eitc_earned_income(Variable): "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17052", # (c)(4) "https://www.ftb.ca.gov/forms/2023/2023-3514-instructions.html", # Lines 13-19, Worksheet 3 "https://www.ftb.ca.gov/forms/2025/2025-3514-booklet.html", + "https://edd.ca.gov/siteassets/files/pdf_pub_ctr/de231eb.pdf#page=3", # HSA payroll contributions ) defined_for = StateCode.CA def formula(tax_unit, period, parameters): - # FTB 3514 line 19 adds wages (line 16), nontaxable combat pay (line 17) - # and business income or (loss) (line 18) for the whole return. - # Worksheet 3 adds Schedule C income (Schedule 1 line 3), farm income - # (Schedule 1 line 6) and partnership self-employment earnings (K-1 - # box 14, code A), and subtracts the deductible part of SE tax - # (Schedule 1 line 15). A loss of one spouse therefore offsets the - # other spouse's wages before the result is floored at zero. R&TC - # 17052(c)(4) applies IRC 32(c)(2)(A), which the federal EITC earned - # income computes from these same sources. - return tax_unit("eitc_earned_income", period) + # FTB 3514 line 13: filers' California taxable wages (W-2 box 16). + filer_wages = tax_unit_non_dep_sum("ca_taxable_wages", tax_unit, period) + + # FTB 3514 line 19 nets wages and business income or losses for the + # whole return. Worksheet 3 includes farm income and partnership + # self-employment earnings, less the deductible part of SE tax. A loss + # of one spouse offsets the other spouse's wages before the final floor. + self_employment_sources = [ + "self_employment_income", + "sstb_self_employment_income", + "farm_operations_income", + "partnership_self_employment_net_earnings", + ] + self_employment_income = sum( + tax_unit_non_dep_sum(source, tax_unit, period) + for source in self_employment_sources + ) + self_employment_tax_ald = tax_unit_non_dep_sum( + "self_employment_tax_ald_person", tax_unit, period + ) + return max_(0, filer_wages + self_employment_income - self_employment_tax_ald) diff --git a/policyengine_us/variables/gov/states/ca/tax/income/credits/young_child/ca_yctc.py b/policyengine_us/variables/gov/states/ca/tax/income/credits/young_child/ca_yctc.py index 3705e68b1df..b25632b5374 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/credits/young_child/ca_yctc.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/credits/young_child/ca_yctc.py @@ -13,6 +13,7 @@ class ca_yctc(Variable): "https://www.ftb.ca.gov/forms/2021/2021-3514.pdf#page=3", "https://www.ftb.ca.gov/forms/2022/2022-3514-instructions.html", "https://www.ftb.ca.gov/forms/2022/2022-3514.pdf#page=3", + "https://www.ftb.ca.gov/forms/2025/2025-3514-booklet.html", # Lines 13, 23 and 23a ) defined_for = StateCode.CA @@ -54,8 +55,9 @@ def formula(tax_unit, period, parameters): has_limited_losses = total_net_loss <= p.loss_threshold # ... ... (B)(iii): wages, salaries, tips, and other employee # compensation within the same threshold (FTB 3514 line 23a, which - # carries the filers' wages from line 13) - wages = tax_unit_non_dep_sum("employment_income", tax_unit, period) + # carries the filers' California taxable wages, W-2 box 16, from + # line 13 rather than gross pay before elective deferrals). + wages = tax_unit_non_dep_sum("ca_taxable_wages", tax_unit, period) has_limited_wages = wages <= p.loss_threshold # ... ... combine all the (b) elements where appropriate is_loss_eligible = where( diff --git a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_casualty_loss_deduction.py b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_casualty_loss_deduction.py new file mode 100644 index 00000000000..1e4ef8389cb --- /dev/null +++ b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_casualty_loss_deduction.py @@ -0,0 +1,56 @@ +from policyengine_us.model_api import * + + +class ca_casualty_loss_deduction(Variable): + value_type = float + entity = TaxUnit + label = "California casualty loss deduction" + unit = USD + definition_period = YEAR + reference = ( + # Cal. Rev. & Tax. Code § 17201: the IRC itemized deductions, + # including 26 U.S.C. 165, apply + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17201", + # Cal. Rev. & Tax. Code § 17204(b): 26 U.S.C. 165(h)(5) shall not apply + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17204", + # 2024 Schedule CA (540) instructions, line 15 + "https://www.ftb.ca.gov/forms/2024/2024-540-booklet.pdf#page=66", + # 2025 Schedule CA (540) instructions, line 15 + "https://www.ftb.ca.gov/forms/2025/2025-540-ca-instructions.html", + # FTB Publication 1034, How to Calculate a Disaster Loss - Individuals + # (2025 edition; the 2024 edition has the same text on the same page) + "https://www.ftb.ca.gov/forms/misc/1034.pdf#page=4", + "https://web.archive.org/web/20250529011540/https://www.ftb.ca.gov/forms/misc/1034.pdf#page=4", + # 2024 Form 4684, lines 10-18 + "https://www.irs.gov/pub/irs-prior/f4684--2024.pdf#page=1", + ) + defined_for = StateCode.CA + documentation = """ + California adopts 26 U.S.C. 165 (R&TC 17201) but not the federal limit of + personal casualty losses to declared disasters (R&TC 17204(b)). Schedule CA + (540) line 15 has filers complete a second federal Form 4684 with California + amounts, so a non-disaster personal casualty or theft loss stays + deductible: each casualty is reduced by $100, and the rest is deductible + above 10% of federal adjusted gross income. + """ + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.deductions.itemized.casualty + # California never applies the federal suspension: its IRC conformity + # date was January 1, 2015 through 2024, before 26 U.S.C. 165(h)(5) + # was enacted, and R&TC 17204(b) turns (h)(5) off from 2025. + # Only the owner of the property claims the loss. A dependent's loss + # belongs on the dependent's own return, as the dependent's income + # does: adjusted gross income leaves it out. + loss = tax_unit_non_dep_add(tax_unit, period, ["casualty_loss"]) + # Form 4684 line 11 reduces each casualty by $100. California states no + # amount of its own; it takes 165(h)(1) through R&TC 17201. A joint + # return is one individual for the $100 rule. The model records one + # loss amount per person, not separate casualty events, so the + # return's losses are treated as one casualty and the reduction + # applies once. + reduced_loss = max_(loss - p.per_casualty_reduction, 0) + # Form 4684 line 17 and FTB Publication 1034: 10% of federal adjusted + # gross income (Form 540 line 13), not California AGI. + positive_agi = tax_unit("positive_agi", period) + return max_(reduced_loss - positive_agi * p.floor, 0) diff --git a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_charitable_deduction.py b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_charitable_deduction.py new file mode 100644 index 00000000000..abfa969ca06 --- /dev/null +++ b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_charitable_deduction.py @@ -0,0 +1,36 @@ +from policyengine_us.model_api import * + + +class ca_charitable_deduction(Variable): + value_type = float + entity = TaxUnit + label = "California charitable contribution deduction" + unit = USD + definition_period = YEAR + defined_for = StateCode.CA + reference = ( + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17024.5", + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17250.1", + "https://www.ftb.ca.gov/forms/2025/2025-540-ca-instructions.html", + ) + + def formula(tax_unit, period, parameters): + cash = add(tax_unit, period, ["charitable_cash_donations"]) + non_cash = add(tax_unit, period, ["charitable_non_cash_donations"]) + non_cash_non_50 = add( + tax_unit, period, ["charitable_non_cash_donations_non_50_pct_orgs"] + ) + non_cash_50 = non_cash - non_cash_non_50 + agi = tax_unit("positive_agi", period) + p = parameters(period).gov.states.ca.tax.income.deductions.itemized.charity + + # Retain the existing donation categories: the 50% category models + # the basis-reduction election for capital-gain property. The default + # 30% capital-gain-property cap, 20% private-foundation category, cash + # split by organization, and carryovers need more granular inputs. + capped_non_cash = min_(non_cash_50, p.ceiling.non_cash * agi) + min_( + non_cash_non_50, p.ceiling.non_cash_to_non_50_pct_org * agi + ) + # Schedule CA Part II lines 11-12: California retains the 50% ceiling + # and does not adopt the post-conformity-date federal charitable floor. + return min_(cash + capped_non_cash, p.ceiling.all * agi) diff --git a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py index ed87c390fac..091b5150824 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py @@ -10,12 +10,40 @@ class ca_itemized_deductions_pre_limitation(Variable): reference = ( "https://www.ftb.ca.gov/forms/2021/2021-540-ca-instructions.html", "https://www.ftb.ca.gov/forms/2022/2022-540-ca-instructions.html", + "https://www.ftb.ca.gov/forms/2025/2025-540-ca-instructions.html", + "https://www.ftb.ca.gov/about-ftb/data-reports-plans/summary-of-federal-income-tax-changes/index.html", + # 2024 Schedule CA (540) instructions, line 15 + "https://www.ftb.ca.gov/forms/2024/2024-540-booklet.pdf#page=66", ) defined_for = StateCode.CA - adds = [ - "itemized_deductions_less_salt", - "ca_investment_interest_expense_deduction", - "real_estate_taxes", - ] - subtracts = ["investment_interest_expense"] + def formula(tax_unit, period, parameters): + # Exclude the replaced federal amounts before summation, so changes + # to federal charity/misc/casualty rules cannot cause cancellation + # errors. Schedule CA line 15 replaces the federal casualty loss, + # which is limited to declared disasters from 2018, with California's + # own. + federal_components = parameters(period).gov.irs.deductions.itemized_deductions + deductions = [ + variable + for variable in federal_components + if variable + not in ( + "salt_deduction", + "charitable_deduction", + "charitable_deduction_for_non_itemizers", + "misc_deduction", + "casualty_loss_deduction", + ) + ] + deductions += [ + "ca_investment_interest_expense_deduction", + "real_estate_taxes", + "ca_charitable_deduction", + "ca_misc_deduction", + "ca_casualty_loss_deduction", + ] + # Federal investment interest is nested in interest_deduction. + return add(tax_unit, period, deductions) - add( + tax_unit, period, ["investment_interest_expense"] + ) diff --git a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_misc_deduction.py b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_misc_deduction.py new file mode 100644 index 00000000000..7006fd92a98 --- /dev/null +++ b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_misc_deduction.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class ca_misc_deduction(Variable): + value_type = float + entity = TaxUnit + label = "California miscellaneous itemized deduction" + unit = USD + definition_period = YEAR + defined_for = StateCode.CA + reference = ( + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17076", + "https://www.ftb.ca.gov/forms/2025/2025-540-ca.pdf#page=6", + ) + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.ca.tax.income.deductions.itemized.misc + expenses = tax_unit("total_misc_deductions", period) + # Schedule CA Part II lines 23-25 use federal AGI and floor the + # percentage amount at zero. R&TC 17076(c) excludes IRC 67(g). + floor = p.floor * tax_unit("positive_agi", period) + return max_(expenses - floor, 0) diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py index d10534624fe..62450596743 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py @@ -9,13 +9,14 @@ class id_deductions(Variable): definition_period = YEAR reference = ( "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022/", # (j) - "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-01-2023.pdf#page=8", + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=10", ) defined_for = StateCode.ID def formula(tax_unit, period, parameters): itm_ded = tax_unit("id_itemized_deductions", period) std_ded = tax_unit("standard_deduction", period) - # Idaho qualified business income and federal Schedule 1-A deductions - # are modeled separately. - return max_(itm_ded, std_ded) + itemizes = tax_unit("id_itemizes", period) + # The election accounts for the associated health premium subtraction. + # Qualified business income and Schedule 1-A deductions are separate. + return where(itemizes, itm_ded, std_ded) diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py index 9d51f1abe43..b406cb6a7ed 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py @@ -12,10 +12,19 @@ class id_itemized_deductions(Variable): "https://tax.idaho.gov/wp-content/uploads/forms/EFO00089/EFO00089_09-23-2021.pdf", "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022/", # Idaho Code 63-3022 (subtractions/itemized framework) "https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_03-02-2026.pdf#page=13", # Idaho Form 39R - Additions and Subtractions instructions (foreign-tax addback rule) + "https://www.govinfo.gov/content/pkg/PLAW-119publ21/html/PLAW-119publ21.htm", # P.L. 119-21, sec. 70111 (IRC 68, from 2026) ) defined_for = StateCode.ID def formula(tax_unit, period, parameters): + # Supplied Schedule A totals already represent the claimant's + # actual deductions, even if component expenses are incomplete. + # Check their provenance before calculating either aggregate. + supplied_itemized = has_input_for_period( + tax_unit.simulation, "itemized_taxable_income_deductions", period + ) or has_input_for_period( + tax_unit.simulation, "total_itemized_taxable_income_deductions", period + ) # Idaho reduces the federal itemized deductions by the SALT deduction. # When foreign tax is claimed as a federal itemized deduction, it is # already in itemized_taxable_income_deductions; when claimed as a @@ -26,4 +35,36 @@ def formula(tax_unit, period, parameters): # are consistent with no unconditional addback here. id_salt_ded = tax_unit("id_salt_deduction", period) itemized_ded = tax_unit("itemized_taxable_income_deductions", period) - return max_(itemized_ded - id_salt_ded, 0) + if supplied_itemized: + return max_(0, itemized_ded - id_salt_ded) + federal_medical = tax_unit("medical_expense_deduction", period) + claimant_medical = tax_unit( + "id_health_insurance_premiums_medical_deduction", period + ) + medical_adjustment = federal_medical - claimant_medical + + # Correct the medical component of derived itemized totals as well as + # premium overlap, so dependent-paid costs do not remain deducted here. + limitation_adjustment = 0 + p = parameters(period).gov.irs.deductions.itemized.limitation + if p.applies and p.obbb.applies: + # IRC 68 applies after the medical deduction is limited. Removing + # invalid medical costs can also reduce the 2/37 limitation. + total = tax_unit("total_itemized_taxable_income_deductions", period) + corrected_total = max_(0, total - medical_adjustment) + filing_status = tax_unit("filing_status", period) + threshold = parameters(period).gov.irs.income.bracket.thresholds["6"][ + filing_status + ] + agi = tax_unit("adjusted_gross_income", period) + exemptions = tax_unit("exemptions", period) + excess = max_(0, agi - exemptions - threshold) + limitation_adjustment = p.obbb.rate * ( + min_(total, excess) - min_(corrected_total, excess) + ) + # The legacy Pease ceiling excludes medical deductions, so its + # reduction does not change when this medical component is corrected. + return max_( + 0, + itemized_ded - medical_adjustment + limitation_adjustment - id_salt_ded, + ) diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py new file mode 100644 index 00000000000..0b719d1bf76 --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class id_itemizes(Variable): + value_type = bool + entity = TaxUnit + label = "Tax unit itemizes deductions for Idaho" + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022/", # (j) + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 10, 48-49 (Form 40 election and Form 39R premiums). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=10", + ) + documentation = ( + "Choose the larger combined Idaho deduction and health premium " + "subtraction. Itemization removes health premiums already included " + "in the medical deduction; the standard election preserves them. " + "Ties retain the standard deduction, unless itemization is required." + ) + + def formula(tax_unit, period, parameters): + premiums = tax_unit("id_qualified_health_insurance_premiums", period) + medical = tax_unit("id_health_insurance_premiums_medical_deduction", period) + # Form 39R allocates itemized medical deductions to health premiums first. + overlap = min_(premiums, medical) + itemized = tax_unit("id_itemized_deductions", period) + standard = tax_unit("standard_deduction", period) + mandatory = tax_unit("separate_filer_itemizes", period) + return mandatory | (itemized > standard + overlap) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py new file mode 100644 index 00000000000..04860677ccf --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py @@ -0,0 +1,56 @@ +from policyengine_us.model_api import * + + +class id_health_insurance_premiums_medical_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Claimant medical deduction for Idaho health premium subtraction" + unit = USD + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 48-49 (Form 39R, line 18 and worksheet lines 1-8). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + "https://www.irs.gov/instructions/i1040sca", + ) + documentation = ( + "Medical deduction used to allocate the claimant's health premium " + "overlap. Only costs paid by the head and spouse enter the medical " + "floor calculation, including their payments covering dependents. " + "Premium inputs already exclude pretax payroll premiums; premiums " + "deducted on Schedule 1 are removed first. The " + "actual federal medical deduction limits the derived result. A " + "federal medical deduction or expense subtotal supplied for this " + "period represents the claimant's actual Schedule A amounts and is " + "honored even when its underlying expenses are not represented in " + "person inputs. A supplied Schedule A expense subtotal already " + "excludes pretax premiums and premiums deducted on Schedule 1." + ) + + def formula(tax_unit, period, parameters): + # Actual Schedule A deductions and expense subtotals are authoritative. + # Pretax payroll premiums are separate from the premium inputs. + # Supplied subtotals already exclude Schedule 1 premiums, so do not + # remove those amounts again. Check the source before calculation: + # cached results and other periods or branches do not describe this claim. + supplied_medical = any( + has_input_for_period(tax_unit.simulation, variable, period) + for variable in ( + "medical_expense_deduction", + "itemized_medical_expenses", + ) + ) + federal_medical = max_(0, tax_unit("medical_expense_deduction", period)) + if supplied_medical: + return federal_medical + premiums = tax_unit("id_qualified_health_insurance_premiums", period) + other_medical = tax_unit_non_dep_add( + tax_unit, period, ["other_medical_expenses"] + ) + p = parameters(period).gov.irs.deductions.itemized.medical + medical_floor = p.floor * tax_unit("positive_agi", period) + # Form 39R lines 1-6 use costs claimed on this return, not payments + # independently made by dependents and aggregated by the federal model. + claimant_medical = max_(0, premiums + other_medical - medical_floor) + return min_(federal_medical, claimant_medical) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py new file mode 100644 index 00000000000..fbe4fcebf28 --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class id_health_insurance_premiums_subtraction(Variable): + value_type = float + entity = TaxUnit + label = "Idaho health insurance premiums subtraction" + unit = USD + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 48-49 (Form 39R, line 18 and its worksheet). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + ) + + def formula(tax_unit, period, parameters): + premiums = tax_unit("id_qualified_health_insurance_premiums", period) + medical = tax_unit("id_health_insurance_premiums_medical_deduction", period) + overlap = min_(premiums, medical) + itemizes = tax_unit("id_itemizes", period) + return premiums - where(itemizes, overlap, 0) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py new file mode 100644 index 00000000000..c523305214a --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class id_qualified_health_insurance_premiums(Variable): + value_type = float + entity = TaxUnit + label = "Idaho health insurance premiums not excluded or deducted elsewhere" + unit = USD + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 48-49 (Form 39R, line 18 and its worksheet). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + ) + documentation = ( + "Premiums paid by the taxpayer and spouse for themselves and their " + "dependents, before removing the portion used in Idaho itemized " + "deductions. The premium inputs exclude premiums paid through " + "pretax payroll deductions. Those premiums are reported separately " + "in pre_tax_health_insurance_premiums, which never enters this " + "subtraction. " + "Self-employed premiums already deducted on the filers' federal " + "return are excluded. Dependents' own payments are excluded " + "regardless of their federal deduction." + ) + + def formula(tax_unit, period, parameters): + premiums = tax_unit_non_dep_add( + tax_unit, period, ["medical_expense_health_insurance_premiums"] + ) + # Dependents' federal deductions concern their excluded payments, + # so they must not reduce the filers' qualifying premiums again. + deducted_elsewhere = tax_unit("self_employed_health_insurance_ald", period) + return max_(0, premiums - deducted_elsewhere) diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 8380194837e..103dffc9cdc 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -13,15 +13,28 @@ class mi_household_resources(Variable): "statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/" "section-206-508/", "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508", + # MCL 206.510(1): "Income", including premiums paid for the family. + "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510", "https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", + # 2025 MI-1040CR-7 book (archived; michigan.gov removed it): + # "Total Household Resources" (page 4) and lines 17 to 23 (page 8) + # and 24 to 33 (page 9). They match MI-1040CR lines 14 to 31 on whose + # income counts: CR-7 lines 24, 25 and 30 are MI-1040CR lines 21, 22 + # and 27. + "https://web.archive.org/web/20260218211009/https://www.michigan.gov/" + "taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/" + "MI-1040CR-7-Book.pdf?rev=e4ba4fedc63942e48442037517312d4a" + "&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=8", # 2025 MI-1040 book: "Total Household Resources" (page 26) and - # MI-1040CR lines 16 and 17 (page 31), 19 and 30 (page 32). + # MI-1040CR lines 14 to 17 (page 31) and 18 to 31 (pages 32 and 33). "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=26", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=31", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=32", + "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=33", ) def formula(tax_unit, period, parameters): @@ -47,22 +60,38 @@ def formula(tax_unit, period, parameters): "farm_rent_income", } - # MCL 206.508(3): "'Household' means a claimant and spouse." A - # dependent's business and rental items belong on the dependent's own - # return, so they neither add to nor net against lines 16 and 17, as - # loss_ald leaves them off this return. tax_unit_non_dep_add sums a - # person-level source over the head and spouse and takes a - # tax-unit-level one (other_net_gain) as is. + # MCL 206.508(3): "'Household' means a claimant and spouse", and + # (4) counts "all income received by all persons of a household". + # The MI-1040CR says "Include all taxable and nontaxable income you + # and your spouse received" (2025 MI-1040 book, page 31). A + # dependent's own income belongs on the dependent's return, so a + # source is summed over the head and spouse only, as in + # irs_gross_income. A tax-unit-level source (other_net_gain, + # filer_loss_limited_net_capital_gains) already describes the + # filer's return. + # The sources in household_resources_all_members are amounts the + # claimant receives for others in the household (lines 21, 22 and + # 27), so they are summed over every member. So is a source defined + # for a larger group than the tax unit, such as tanf for the SPM + # unit: add gives the tax unit its members' shares. + all_members = p.household_resources_all_members + business_income = 0 rental_income = 0 other_income = 0 for source in p.household_resources: + entity = tax_unit.entity.get_variable(source).entity + own_return = entity.is_person or entity.key == tax_unit.entity.key + if source in all_members or not own_return: + amount = add(tax_unit, period, [source]) + else: + amount = tax_unit_non_dep_add(tax_unit, period, [source]) if source in business_sources: - business_income += tax_unit_non_dep_add(tax_unit, period, [source]) + business_income += amount elif source in rental_sources: - rental_income += tax_unit_non_dep_add(tax_unit, period, [source]) + rental_income += amount else: - other_income += add(tax_unit, period, [source]) + other_income += amount total = other_income + max_(business_income, 0) + max_(rental_income, 0) # Line 30: "Enter total adjustments from your U.S. Form 1040, @@ -85,7 +114,8 @@ def formula(tax_unit, period, parameters): # The Schedule 1 is the claimant's own (MCL 206.508(3)): a person-level # adjustment, such as a dependent's IRA contribution or early withdrawal # penalty, is summed over the head and spouse, as above_the_line_deductions - # does. A tax-unit-level adjustment already describes the filer's return. + # does. A tax-unit-level adjustment already describes the filer's return, + # including educator_expense_ald, capped per eligible educator. ald = parameters(period).gov.irs.ald adjustments = tax_unit_non_dep_add( tax_unit, @@ -95,8 +125,11 @@ def formula(tax_unit, period, parameters): for deduction in p.household_resources_adjustments if deduction in ald.deductions ], - include_dependents=ald.filer_amounts_recorded_on_dependents, ) - # Line 31: health insurance premiums. + # Line 31: "insurance premiums you paid for yourself and your + # family". MCL 206.510(1) lets a person deduct "the amount that + # person paid in premiums ... for that insurance plan for the + # person's family". A premium on any member's record is read as one + # the claimant or spouse paid for the family's coverage. health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) return max_(0, total - adjustments - health_insurance_premiums) diff --git a/policyengine_us/variables/gov/states/mn/tax/income/amt/mn_amt_taxable_income.py b/policyengine_us/variables/gov/states/mn/tax/income/amt/mn_amt_taxable_income.py index 50dc8cc7602..83736e79da5 100644 --- a/policyengine_us/variables/gov/states/mn/tax/income/amt/mn_amt_taxable_income.py +++ b/policyengine_us/variables/gov/states/mn/tax/income/amt/mn_amt_taxable_income.py @@ -10,6 +10,8 @@ class mn_amt_taxable_income(Variable): reference = ( "https://www.revenue.state.mn.us/sites/default/files/2023-02/m1mt_21.pdf", "https://www.revenue.state.mn.us/sites/default/files/2023-01/m1mt_22.pdf", + # 2024 Schedule M1MT, line 12 + "https://www.revenue.state.mn.us/sites/default/files/2025-07/m1mt-24.pdf#page=1", ) defined_for = StateCode.MN @@ -19,7 +21,9 @@ def formula(tax_unit, period, parameters): SOME_DEDUCTIONS = [ "charitable_deduction", "medical_expense_deduction", - "casualty_loss_deduction", + # M1MT line 12 takes the casualty and theft loss from Schedule + # M1SA line 19, Minnesota's own amount. + "mn_casualty_loss_deduction", ] some_itm_deds = itemizing * add(tax_unit, period, SOME_DEDUCTIONS) AMT_SUBTRACTIONS = [ diff --git a/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_casualty_loss_deduction.py b/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_casualty_loss_deduction.py new file mode 100644 index 00000000000..e8d3b9bfdf1 --- /dev/null +++ b/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_casualty_loss_deduction.py @@ -0,0 +1,62 @@ +from policyengine_us.model_api import * + + +class mn_casualty_loss_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Minnesota casualty loss deduction" + unit = USD + definition_period = YEAR + reference = ( + # Minn. Stat. § 290.0122, subd. 8: losses under 26 U.S.C. 165(c)(3), + # including 165(h) but disregarding (h)(5) + "https://www.revisor.mn.gov/statutes/cite/290.0122#stat.290.0122.8", + # 2024 Schedule M1SA, line 19, and its instructions + # PDF pages 1, 7 + "https://www.revenue.state.mn.us/sites/default/files/2024-12/m1sa-24.pdf#page=1", + # 2024 Schedule M1CAT, lines 10-20 + "https://www.revenue.state.mn.us/sites/default/files/2024-12/m1cat-24.pdf#page=1", + # 2018 Schedule M1CAT, lines 10-20 + "https://www.revenue.state.mn.us/sites/default/files/2018-11/m1cat_18.pdf#page=1", + # 2020 Schedule M1SA instructions, line 19, qualified disaster loss + # PDF pages 6-7 + "https://www.revenue.state.mn.us/sites/default/files/2023-01/m1sa_20.pdf#page=6", + ) + defined_for = StateCode.MN + documentation = """ + Minnesota allows personal casualty and theft losses under 26 U.S.C. 165(h) + but disregards the federal limit to declared disasters (Minn. Stat. + 290.0122, subd. 8). Schedule M1CAT reduces each casualty by $100 and allows + the rest above 10% of Form M1 line 1 (federal adjusted gross income); the + result goes on Schedule M1SA line 19. Two cases on the forms are not + modeled: for 2018, filers of Schedule M1NC (federal adjustments) use its + line 38 in place of Form M1 line 1; and the 2020 Schedule M1SA takes a + net qualified disaster loss from federal Form 4684 line 15 ($500 per + casualty, no 10% floor), which can be deducted without itemizing. + """ + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.deductions.itemized.casualty + # For 2018 Minnesota computed tax under the Internal Revenue Code as + # amended through December 16, 2016 (Minn. Stat. 290.993), before + # 26 U.S.C. 165(h)(5) existed, and Schedule M1CAT has applied the $100 + # and 10% to an ordinary (not qualified disaster) loss in every year + # since, so the federal suspension never applies. + # Only the owner of the property claims the loss. A dependent's loss + # belongs on the dependent's own return, as the dependent's income + # does: adjusted gross income leaves it out. + loss = tax_unit_non_dep_add(tax_unit, period, ["casualty_loss"]) + # M1CAT line 11: "Enter $100". A joint return is one individual for the + # $100 rule (165(h)(4)(B)). The model records one loss amount per + # person, not separate casualty events, so the return's losses are + # treated as one casualty and the reduction applies once. + reduced_loss = max_(loss - p.per_casualty_reduction, 0) + # M1CAT lines 18-20: subtract 10% of Form M1 line 1 (federal adjusted + # gross income). The 2018 form takes Schedule M1NC line 38 instead + # from filers of that schedule; the model has no Schedule M1NC, so + # 2018 also uses federal adjusted gross income. The form does not say + # to enter 0 for a negative AGI; like Form 4684 and the federal + # deduction, the floor is not allowed to go below zero, so the + # deduction never exceeds the reduced loss. + positive_agi = tax_unit("positive_agi", period) + return max_(reduced_loss - positive_agi * p.floor, 0) diff --git a/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_itemized_deductions.py b/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_itemized_deductions.py index bbd8191185d..7fc89edb407 100644 --- a/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/mn/tax/income/deductions/mn_itemized_deductions.py @@ -12,6 +12,11 @@ class mn_itemized_deductions(Variable): "https://www.revenue.state.mn.us/sites/default/files/2023-12/m1_inst_21.pdf", "https://www.revenue.state.mn.us/sites/default/files/2023-12/m1_22.pdf", "https://www.revenue.state.mn.us/sites/default/files/2024-02/m1-inst-22.pdf", + # 2024 Schedule M1SA, line 19, and the line 26 limitation worksheets + # PDF pages 1, 8 + "https://www.revenue.state.mn.us/sites/default/files/2024-12/m1sa-24.pdf#page=1", + # Minn. Stat. § 290.0122, subd. 2(d) and subd. 8 + "https://www.revisor.mn.gov/statutes/cite/290.0122", ) defined_for = StateCode.MN @@ -24,13 +29,26 @@ def formula(tax_unit, period, parameters): # ... calculate pre-limitation itemized deductions itm_deds_less_salt = tax_unit("itemized_deductions_less_salt", period) capped_property_taxes = tax_unit("capped_property_taxes", period) - mn_itm_deds = itm_deds_less_salt + capped_property_taxes + # Schedule M1SA line 19 takes Minnesota's own casualty and theft loss + # (Schedule M1CAT) in place of the federal one, which is limited to + # declared disasters from 2018. + federal_casualty_loss = tax_unit("casualty_loss_deduction", period) + mn_casualty_loss = tax_unit("mn_casualty_loss_deduction", period) + mn_itm_deds = ( + itm_deds_less_salt + - federal_casualty_loss + + mn_casualty_loss + + capped_property_taxes + ) # ... calculate itemized deductions offset p = parameters(period).gov.states.mn.tax.income.deductions.itemized + # Minn. Stat. 290.0122, subd. 2(d), and the M1SA line 26 worksheets + # (step 2: lines 4, 13 and 19) leave medical expenses and casualty + # and theft losses out of the reduction. exempt_deds = add( tax_unit, period, - ["medical_expense_deduction", "casualty_loss_deduction"], + ["medical_expense_deduction", "mn_casualty_loss_deduction"], ) net_deds = max_(0, mn_itm_deds - exempt_deds) filing_status = tax_unit("filing_status", period) diff --git a/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py b/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py index 416d57eeb7a..cdaa9b234fa 100644 --- a/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py +++ b/policyengine_us/variables/gov/states/mn/tax/property/homestead_credit_refund/mn_homestead_credit_refund_household_income.py @@ -57,13 +57,19 @@ def formula(tax_unit, period, parameters): "self_employed_pension_contributions", ], ) + # M1PR line 3 adds back "your deductions for your qualified retirement + # plan contributions from lines 16 and 20 of federal Schedule 1", so + # the IRA term is the federal deduction (Schedule 1 line 20), not total + # traditional contributions: nondeductible contributions never left + # federal AGI. 2025 M1PR instructions, PDF page 7: + # https://www.taxformfinder.org/forms/2025/2025-minnesota-form-m1pr-instructions.pdf#page=7 retirement_additions = tax_unit_non_dep_add( tax_unit, period, [ "traditional_401k_contributions", "traditional_403b_contributions", - "traditional_ira_contributions", + "traditional_ira_deduction", "self_employed_pension_contributions", ], ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py index d233f4ad92c..f2cbf8e8162 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py @@ -8,16 +8,23 @@ class mt_elderly_homeowner_or_renter_credit(Variable): unit = USD definition_period = YEAR defined_for = "mt_elderly_homeowner_or_renter_credit_eligible" + reference = ( + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0370/0150-0300-0230-0370.html", + # 2024 Form 2, Schedule 2EC + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=10", + ) def formula(person, period, parameters): p = parameters( period ).gov.states.mt.tax.income.credits.elderly_homeowner_or_renter - # § 15-30-2337(4) defines gross household income at the household - # (tax-unit) level, so the multiplier phase-out must be applied to - # the aggregated amount rather than each person's share. + # § 15-30-2337(4) defines gross household income as "all income + # received by all individuals of a household", including members + # outside the claimant's return (2024 Schedule 2EC line 17), so the + # multiplier phase-out applies to the household's total income rather + # than each person's share. gross_household_income = add( - person.tax_unit, + person.household, period, ["mt_elderly_homeowner_or_renter_credit_gross_household_income"], ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_federal_refundable_credits.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_federal_refundable_credits.py new file mode 100644 index 00000000000..25342808b61 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_federal_refundable_credits.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class mt_elderly_homeowner_or_renter_credit_federal_refundable_credits(Variable): + value_type = float + entity = Person + label = "Federal refundable credits in Montana elderly homeowner/renter credit gross household income" + unit = USD + definition_period = YEAR + defined_for = StateCode.MT + reference = ( + # ARM 42.4.301(2)(b): "federal refundable tax credits received" + "https://rules.mt.gov/api/policy-library-public/collections/aec52c46-128e-4279-9068-8af5d5432d74/policies/c90ea93a-03a2-4605-a0f8-f2f3760198bf/document/768c8344-24ee-4950-9d5e-9d8fc97ec0f7", + # 2023 instructions, line 7: "the federal and Montana Earned Income Tax + # Credit, the federal Child Tax Credits" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=51", + # 2024 Schedule 2EC, line 8: "the federal and Montana earned income tax + # credits, the refundable portion of the federal child tax credit" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=47", + ) + + def formula(person, period, parameters): + # Federal refundable credits are claimed on the tax unit's return. + # Gross household income sums this person-level amount over the + # household, so count each return's credits once, on its head. + head = person("is_tax_unit_head", period) + return head * person.tax_unit("income_tax_refundable_credits", period) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py index e4a6976f246..c6f1065035f 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.irs_gross_income.social_security.dependent_taxable_ss_magi import ( + PERSON_ABOVE_THE_LINE_DEDUCTIONS, +) class mt_elderly_homeowner_or_renter_credit_gross_household_income(Variable): @@ -8,16 +11,63 @@ class mt_elderly_homeowner_or_renter_credit_gross_household_income(Variable): unit = USD definition_period = YEAR defined_for = StateCode.MT - reference = "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-23/section-15-30-2337/" + reference = ( + "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-23/section-15-30-2337/", + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0370/0150-0300-0230-0370.html", + # 2023 Form 2 instructions, PDF pages 51-52: each household member's + # return (line 1), nonfilers' wages (line 8), no losses (line 9). + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=51", + # 2024 Schedule 2EC, line 17: income received by other members of + # the household + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=48", + # The statutory federal-AGI definition remains after the 2024 form + # began displaying separate income categories: HB191 § 1(9), p.2. + "https://archive.legmt.gov/bills/2021/HB0199/HB0191_X.pdf#page=2", + ) def formula(person, period, parameters): p = parameters( period ).gov.states.mt.tax.income.credits.elderly_homeowner_or_renter sources = add(person, period, p.gross_income_sources) - # AGI only captures the taxable portion of Social Security; add the - # untaxed portion so all SS is counted per § 15-30-2337(9)(a)(viii). + # Gross household income counts every member's federal AGI without + # losses (§ 15-30-2337(4), (9)(a)). The dependent's gross income is + # omitted from this return's AGI, but their own valid non-loss + # adjustments still belong on their own return (2023 instructions, + # line 1). Do not subtract the parent's TaxUnit-level deductions. + ald = parameters(period).gov.irs.ald + deduction_variables = [ + PERSON_ABOVE_THE_LINE_DEDUCTIONS[name] + for name in ald.deductions + if name in PERSON_ABOVE_THE_LINE_DEDUCTIONS + and name not in ald.filer_amounts_recorded_on_dependents + ] + # The shared mapping uses capped/eligible person-level deductions, + # including the dependent's own IRA deduction. It excludes loss_ald: + # business/capital losses never reduce Montana household income. + # Parent-owned adoption/bond exclusions recorded on dependents + # remain with the filer under the existing convention. + own_deductions = add(person, period, deduction_variables) + is_dependent = person("is_tax_unit_dependent", period) + dependent_income = is_dependent * ( + person("dependent_gross_income", period) - own_deductions + ) + # The income above counts only the taxable portion of Social + # Security: a filer's in federal AGI, a dependent's in their own + # gross income. Add the untaxed portion so all SS is counted per + # § 15-30-2337(9)(a)(viii). social_security = person("social_security", period) - taxable_social_security = person("taxable_social_security", period) + taxable_social_security = add( + person, + period, + ["taxable_social_security", "dependent_taxable_social_security"], + ) untaxed_social_security = max_(social_security - taxable_social_security, 0) - return sources + untaxed_social_security + # Income is federal AGI "without regard to loss" (§ 15-30-2337(9)(a)), + # so the business and capital losses deducted in computing federal + # AGI are added back (2023 Elderly Homeowner/Renter Credit Schedule, + # line 9: "The gross household income cannot be reduced by any + # losses."). The return's losses are added once, to the head. + head = person("is_tax_unit_head", period) + losses = head * person.tax_unit("loss_ald", period) + return sources + dependent_income + untaxed_social_security + losses diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py index 4c996aded7b..a46ccf6f5c6 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py @@ -8,6 +8,11 @@ class mt_elderly_homeowner_or_renter_credit_net_household_income(Variable): unit = USD definition_period = YEAR defined_for = "mt_elderly_homeowner_or_renter_credit_eligible" + reference = ( + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0370/0150-0300-0230-0370.html", + # 2024 Form 2, Schedule 2EC + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=10", + ) def formula(person, period, parameters): p = parameters( @@ -16,10 +21,12 @@ def formula(person, period, parameters): # Only one claim is allowed per household # married taxpayer who are living apart may qualify for only one credit per year standard_exclusion = p.net_household_income.standard_exclusion - # Allocate the income to the head + # Gross household income counts every member of the household, + # including those outside the claimant's return (§ 15-30-2337(4); + # 2024 Schedule 2EC line 17). Allocate it to the head. head = person("is_tax_unit_head", period) gross_household_income = add( - person.tax_unit, + person.household, period, ["mt_elderly_homeowner_or_renter_credit_gross_household_income"], ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received.py new file mode 100644 index 00000000000..d359822c279 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received(Variable): + value_type = float + entity = Person + label = "Montana property tax rebate received, in elderly homeowner/renter credit gross household income" + documentation = ( + "Montana property tax rebate cash actually received by this person " + "during the year. Defaults to zero: prior-year property taxes or a " + "calculated entitlement do not establish eligibility, a claim, or receipt." + ) + unit = USD + definition_period = YEAR + defined_for = StateCode.MT + reference = ( + # SB542 eligibility and claim requirements, PDF pages 2-3. + "https://archive.legmt.gov/content/Sessions/69th/Contractor_index/CH0767.pdf#page=2", + "https://archive.legmt.gov/bills/2021/HB0199/HB0191_X.pdf#page=2", + # 2024 Schedule 2EC, line 8: "the 2023 Montana property tax rebate" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=47", + # 2025 Schedule 2EC, line 8: "the 2024 Montana property tax rebate" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2025_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=45", + ) + + # Actual receipt input: SB542 sections 1(3) and 3(5) require qualifying + # ownership/occupancy, paid taxes, and a claim. The model's separate + # mt_property_tax_rebate amount calculation omits those requirements. diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_received.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_received.py new file mode 100644 index 00000000000..5bfbabeb8b0 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_received.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class mt_elderly_homeowner_or_renter_credit_received(Variable): + value_type = float + entity = Person + label = "Montana elderly homeowner/renter credit received" + documentation = ( + "Montana elderly homeowner/renter credit refunds received during the " + "year, normally the credit claimed on the prior year's return. It " + "counts in the current year's gross household income." + ) + unit = USD + definition_period = YEAR + reference = ( + # ARM 42.4.301(2)(c): "any state refundable tax credits received, + # including elderly homeowner/renter credit refunds" + "https://rules.mt.gov/api/policy-library-public/collections/aec52c46-128e-4279-9068-8af5d5432d74/policies/c90ea93a-03a2-4605-a0f8-f2f3760198bf/document/768c8344-24ee-4950-9d5e-9d8fc97ec0f7", + # 2024 Schedule 2EC, line 8: "including your elderly homeowner/renter + # credit received in 2024" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=47", + ) + # An input rather than this credit read at period.last_year. This year's + # credit cannot count without a circular reference. Reading last year's + # credit would rerun the whole prior-year tax computation, every member's + # federal refundable credits included, for each record, and it reads 0 + # whenever the inputs cover only one year. diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.py index 2efb5ad2407..229fe2db8e6 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction.py @@ -7,9 +7,16 @@ class mt_child_dependent_care_expense_deduction(Variable): label = "Montana child dependent care expense deduction" unit = USD definition_period = YEAR + documentation = ( + "The deduction in each filer's column: an unmarried filer's return, or " + "each spouse's column when spouses file separately on the same form. " + "A joint return uses mt_child_dependent_care_expense_deduction_joint." + ) reference = ( "https://mca.legmt.gov/bills/2019/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", "https://web.archive.org/web/20230914140156/https://mtrevenue.gov/wp-content/uploads/dlm_uploads/2022/12/2441-M_2022.pdf#page=1", + # 2022 Form 2 instructions, Itemized Deductions Schedule, line 14 + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=35", ) defined_for = StateCode.MT @@ -19,26 +26,26 @@ def formula(person, period, parameters): ).gov.states.mt.tax.income.deductions.child_dependent_care_expense if not p.in_effect: return 0 - tax_unit = person.tax_unit - members = tax_unit.members - qualifying_individual = members( - "mt_child_dependent_care_expense_deduction_qualifying_individual", - period, - ) - # Form 2441-M line 1 - qualifying_individuals = tax_unit.sum(qualifying_individual) - qualifying_care_expenses = tax_unit.sum( - members("care_expenses", period) * qualifying_individual + # Form 2441-M line 2 + eligible_expenses = person.tax_unit( + "mt_child_dependent_care_expense_deduction_eligible_expenses", period ) - childcare_expenses = tax_unit("tax_unit_childcare_expenses", period) - total_expenses = childcare_expenses + qualifying_care_expenses - # Form 2441-M line 2: the lesser of actual dependent care expenses - # or the cap based on the number of qualifying individuals. - capped_expenses = min_(total_expenses, p.cap.calc(qualifying_individuals)) - # Line 3 - agi = person("mt_agi_indiv", period) - # Line 6 - reduction = p.phase_out.calc(agi) - # The deduction has to be allocated equally between spouses + # Line 3: add the Montana adjusted gross income in columns A and B, + # that is, the couple's combined income, not each spouse's own. head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return head_or_spouse * (max_(0, capped_expenses - reduction) * 0.5) + combined_agi = person.tax_unit.sum( + head_or_spouse * person("mt_agi_indiv", period) + ) + # Lines 4-7 + deduction = max_(eligible_expenses - p.phase_out.calc(combined_agi), 0) + # Spouses filing separately on the same form each enter one-half of + # line 7 in their column; an unmarried filer enters all of it. + married = person.tax_unit("tax_unit_married", period) + head = person("is_tax_unit_head", period) + share = where(married, head_or_spouse * 0.5, head * 1.0) + # Married couples must file a joint return or file separately on the + # same form (MCA 15-30-2131(1)(c)(vi)(A)); a spouse filing on a + # separate form cannot take the deduction. + filing_status = person.tax_unit("filing_status", period) + separate_form = filing_status == filing_status.possible_values.SEPARATE + return deduction * share * ~separate_form diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_eligible_expenses.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_eligible_expenses.py new file mode 100644 index 00000000000..642214c3e55 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_eligible_expenses.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * + + +class mt_child_dependent_care_expense_deduction_eligible_expenses(Variable): + value_type = float + entity = TaxUnit + label = "Montana child and dependent care expenses eligible for the deduction" + unit = USD + definition_period = YEAR + reference = ( + "https://mca.legmt.gov/bills/2019/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + "https://web.archive.org/web/20230914140156/https://mtrevenue.gov/wp-content/uploads/dlm_uploads/2022/12/2441-M_2022.pdf#page=1", + ) + defined_for = StateCode.MT + + def formula(tax_unit, period, parameters): + p = parameters( + period + ).gov.states.mt.tax.income.deductions.child_dependent_care_expense + person = tax_unit.members + qualifying_individual = person( + "mt_child_dependent_care_expense_deduction_qualifying_individual", + period, + ) + # Form 2441-M line 1 + qualifying_individuals = tax_unit.sum(qualifying_individual) + qualifying_care_expenses = tax_unit.sum( + person("care_expenses", period) * qualifying_individual + ) + childcare_expenses = tax_unit("tax_unit_childcare_expenses", period) + total_expenses = childcare_expenses + qualifying_care_expenses + # Form 2441-M line 2: the lesser of actual dependent care expenses + # or the cap based on the number of qualifying individuals. + return min_(total_expenses, p.cap.calc(qualifying_individuals)) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_joint.py new file mode 100644 index 00000000000..967ce181cb0 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_joint.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class mt_child_dependent_care_expense_deduction_joint(Variable): + value_type = float + entity = TaxUnit + label = "Montana child dependent care expense deduction on a joint return" + unit = USD + definition_period = YEAR + reference = ( + "https://mca.legmt.gov/bills/2019/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + "https://web.archive.org/web/20230914140156/https://mtrevenue.gov/wp-content/uploads/dlm_uploads/2022/12/2441-M_2022.pdf#page=1", + # 2022 Form 2 instructions, Itemized Deductions Schedule, line 14 + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=35", + ) + defined_for = StateCode.MT + + def formula(tax_unit, period, parameters): + p = parameters( + period + ).gov.states.mt.tax.income.deductions.child_dependent_care_expense + if not p.in_effect: + return 0 + # Form 2441-M line 2 + eligible_expenses = tax_unit( + "mt_child_dependent_care_expense_deduction_eligible_expenses", period + ) + # Line 3: the return's Montana adjusted gross income; lines 4-7. + agi = tax_unit("mt_agi_joint", period) + deduction = max_(eligible_expenses - p.phase_out.calc(agi), 0) + # A married person filing on a separate form cannot take the deduction + # (MCA 15-30-2131(1)(c)(vi)(A)). + filing_status = tax_unit("filing_status", period) + separate_form = filing_status == filing_status.possible_values.SEPARATE + return deduction * ~separate_form diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.py index 94951d1ad9c..967ce5bdfb5 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_federal_income_tax_deduction_for_federal_itemization_indiv.py @@ -7,28 +7,24 @@ class mt_federal_income_tax_deduction_for_federal_itemization_indiv(Variable): definition_period = YEAR label = "Montana federal income tax deduction when married couples are filing separately" reference = ( - "https://law.justia.com/codes/montana/2021/title-15/chapter-30/part-21/section-15-30-2131/" + "https://law.justia.com/codes/montana/2021/title-15/chapter-30/part-21/section-15-30-2131/", # MT Code § 15-30-2131 (2021) (1)(b) + # 2022 Form 2 instructions, Itemized Deductions Schedule, line 4 + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", ) unit = USD defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - unit_deds = person.tax_unit( - "mt_federal_income_tax_deduction_for_federal_itemization", period - ) - person_agi = person("mt_agi_indiv", period) - total_agi = person.tax_unit.sum(person_agi) - - prorate = np.zeros_like(total_agi) - mask = total_agi > 0 - prorate[mask] = person_agi[mask] / total_agi[mask] + p = parameters( + period + ).gov.states.mt.tax.income.deductions.itemized.federal_income_tax + # The withholding estimate is computed on each person's own income, so + # each spouse deducts their own amount, up to their own cap. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + withheld_income_tax = head_or_spouse * person("mt_withheld_income_tax", period) filing_status = person.tax_unit( "state_filing_status_if_married_filing_separately_on_same_return", period, ) - p = parameters( - period - ).gov.states.mt.tax.income.deductions.itemized.federal_income_tax - cap = p.cap[filing_status] - return min_(unit_deds * prorate, cap) + return min_(withheld_income_tax, p.cap[filing_status]) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py index 1c1a1e3c8e4..8bda67333ca 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py @@ -38,6 +38,7 @@ def formula(person, period, parameters): period, [ "mt_misc_deductions", + "mt_child_dependent_care_expense_deduction", "mt_medical_expense_deduction_indiv", "mt_salt_deduction", "mt_federal_income_tax_deduction_for_federal_itemization_indiv", diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py index c56b1d7272e..ba977871a6f 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py @@ -33,6 +33,7 @@ def formula(person, period, parameters): period, [ "mt_misc_deductions", + "mt_child_dependent_care_expense_deduction_joint", "mt_medical_expense_deduction_joint", "mt_salt_deduction", "mt_federal_income_tax_deduction_for_federal_itemization", diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_indiv.py index 792174d9142..f84c008f403 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_indiv.py @@ -7,26 +7,31 @@ class mt_federal_income_tax_deduction_indiv(Variable): definition_period = YEAR label = "Montana federal income tax deduction when married couples are filing separately" reference = ( - "https://law.justia.com/codes/montana/2021/title-15/chapter-30/part-21/section-15-30-2131/" + "https://law.justia.com/codes/montana/2021/title-15/chapter-30/part-21/section-15-30-2131/", # MT Code § 15-30-2131 (2021) (1)(b) + # 2022 Form 2 instructions, Itemized Deductions Schedule, line 4 + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", ) unit = USD defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - unit_deds = person.tax_unit("mt_federal_income_tax_deduction_unit", period) - person_agi = person("mt_agi_indiv", period) - total_agi = person.tax_unit.sum(person_agi) - - prorate = np.zeros_like(total_agi) - mask = total_agi > 0 - prorate[mask] = person_agi[mask] / total_agi[mask] + p = parameters( + period + ).gov.states.mt.tax.income.deductions.itemized.federal_income_tax + if not p.applies: + return 0 + # Federal income tax is reported by the spouse who earned the income + # it was paid on, and each spouse is held to their own cap; the + # deduction cannot be arbitrarily allocated. Without each spouse's + # federal withholding, attribute the couple's federal income tax by + # each spouse's share of federal adjusted gross income, then cap it. + federal_income_tax = max_( + person.tax_unit("income_tax_before_refundable_credits", period), 0 + ) + share = person("mt_federal_income_tax_deduction_share", period) filing_status = person.tax_unit( "state_filing_status_if_married_filing_separately_on_same_return", period, ) - p = parameters( - period - ).gov.states.mt.tax.income.deductions.itemized.federal_income_tax - cap = p.cap[filing_status] - return min_(unit_deds * prorate, cap) + return min_(federal_income_tax * share, p.cap[filing_status]) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_share.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_share.py new file mode 100644 index 00000000000..a2d93c5cc6f --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_tax/mt_federal_income_tax_deduction_share.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * + + +class mt_federal_income_tax_deduction_share(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Share of the couple's federal income tax attributed to each spouse for the Montana deduction" + reference = ( + # 2022 Form 2 instructions, Itemized Deductions Schedule, line 4a: + # "If you are married filing separately with your spouse, the federal + # income tax withheld should be reported by the spouse who earned the + # income." + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", + ) + unit = "/1" + defined_for = "mt_married_filing_separately_on_same_return_eligible" + + def formula(person, period, parameters): + # Each filer's share of the federal income the tax was paid on, + # counting only positive federal adjusted gross income. A dependent's + # income is on the dependent's own return. With no positive filer + # income, retain main's zero allocation; the instructions do not + # establish which spouse made the payments in that case. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + income = head_or_spouse * max_( + person("adjusted_gross_income_person", period), 0 + ) + total_income = person.tax_unit.sum(income) + share = np.zeros_like(total_income) + mask = total_income > 0 + share[mask] = income[mask] / total_income[mask] + return share diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py index 2ce15dcaaac..aa99346184a 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py @@ -40,6 +40,7 @@ def formula(person, period, parameters): period, [ "mt_misc_deductions", + "mt_child_dependent_care_expense_deduction", "mt_medical_expense_deduction_indiv", "mt_salt_deduction", "mt_federal_income_tax_deduction_indiv", diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py index c13d9dbd54e..5f503e42a9b 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py @@ -33,6 +33,7 @@ def formula(person, period, parameters): period, [ "mt_misc_deductions", + "mt_child_dependent_care_expense_deduction_joint", "mt_medical_expense_deduction_joint", "mt_salt_deduction", "mt_federal_income_tax_deduction_unit", diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 28d8b020611..182f474d45d 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -8,17 +8,27 @@ class mt_medical_expense_deduction_joint(Variable): label = "Montana medical expense deduction when married couples are filing jointly" reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15", "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # MT Code § 15-30-2131 (2022) (1)(g)(i) + # MT Code § 15-30-2131 (2022) (1)(a) ) unit = USD defined_for = StateCode.MT def formula(person, period, parameters): - expense = person.tax_unit("itemized_medical_expenses", period) - p = parameters(period).gov.irs.deductions.itemized.medical - # Law does not define Montana AGI as the cap. - # Tax form points to page 1, line 14, which is Montana AGI. - medical_floor = p.floor * add(person.tax_unit, period, ["mt_agi_indiv"]) + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + if p.state_specific_deduction_applies: + expense = person.tax_unit("itemized_medical_expenses", period) + floor = parameters(period).gov.irs.deductions.itemized.medical.floor + # Itemized Deductions Schedule line 1b takes Montana AGI from + # page 1, line 14. A joint return reports that line in a single + # column, so the floor applies to the return's pooled Montana AGI. + montana_agi = person.tax_unit("mt_agi_joint", period) + deduction = max_(0, expense - floor * montana_agi) + else: + # From 2024, Form 2 line 2 takes federal itemized deductions + # (Worksheet A), so the federal floor on federal AGI applies. + deduction = person.tax_unit("medical_expense_deduction", period) is_head = person("is_tax_unit_head", period) - return is_head * max_(0, expense - medical_floor) + return is_head * deduction diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py index 9f5279e5caa..c471e6c9ac1 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py @@ -17,7 +17,9 @@ def formula(person, period, parameters): if p.state_specific_deduction_applies: # ── Pre-2024 MT-specific calculation ─────────────────────────────── - agi = add(person.tax_unit, period, ["mt_agi_indiv"]) + # Standard Deduction Worksheet line 1 takes Montana AGI from + # page 1, line 14, which pools both spouses on a joint return. + agi = person.tax_unit("mt_agi_joint", period) floor = p.floor[filing_status] cap = p.cap[filing_status] uncapped = p.rate * agi diff --git a/policyengine_us/variables/gov/states/nj/tax/income/adjusted_gross_income/nj_gross_income.py b/policyengine_us/variables/gov/states/nj/tax/income/adjusted_gross_income/nj_gross_income.py index 6a45290aec1..bad7256143f 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/adjusted_gross_income/nj_gross_income.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/adjusted_gross_income/nj_gross_income.py @@ -22,5 +22,6 @@ def formula(person, period, parameters): total = total + max_(add(person, period, cats.category_b), 0) total = total + max_(add(person, period, cats.category_c), 0) total = total + max_(add(person, period, cats.category_d), 0) + total = total + max_(add(person, period, cats.category_h), 0) total = total + max_(add(person, period, cats.category_k_p), 0) return total diff --git a/policyengine_us/variables/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.py b/policyengine_us/variables/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.py index 41a17339a91..bbe42d239f8 100644 --- a/policyengine_us/variables/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.py +++ b/policyengine_us/variables/gov/states/nj/tax/payroll/nj_employee_state_payroll_tax.py @@ -7,7 +7,8 @@ class nj_employee_state_payroll_tax(Variable): label = "New Jersey employee state payroll tax" documentation = ( "Employee-side New Jersey payroll-funded contributions, including " - "temporary disability insurance and family leave insurance." + "temporary disability insurance, family leave insurance, unemployment " + "insurance, and workforce development contributions." ) definition_period = YEAR unit = USD @@ -15,4 +16,6 @@ class nj_employee_state_payroll_tax(Variable): adds = [ "nj_employee_temporary_disability_insurance_contribution", "nj_employee_family_leave_insurance_contribution", + "nj_employee_unemployment_insurance_contribution", + "nj_employee_workforce_fund_contribution", ] diff --git a/policyengine_us/variables/gov/states/nj/tax/payroll/unemployment/nj_employee_unemployment_insurance_contribution.py b/policyengine_us/variables/gov/states/nj/tax/payroll/unemployment/nj_employee_unemployment_insurance_contribution.py new file mode 100644 index 00000000000..114311e50a0 --- /dev/null +++ b/policyengine_us/variables/gov/states/nj/tax/payroll/unemployment/nj_employee_unemployment_insurance_contribution.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class nj_employee_unemployment_insurance_contribution(Variable): + value_type = float + entity = Person + label = "New Jersey employee unemployment insurance contribution" + documentation = ( + "Total mandatory worker unemployment contribution. For governmental " + "reimbursable employers, this includes both the 0.0825 percent remittance " + "and the 0.30 percent withheld in the employer's benefit trust fund." + ) + definition_period = YEAR + unit = USD + defined_for = StateCode.NJ + reference = ( + # PDF pages 60-61, 81 + "https://www.nj.gov/labor/myunemployment/assets/pdfs/UI_statute.pdf#page=60", + "https://www.nj.gov/labor/ea/assets/PDFs/EmployerAcctsGuide.pdf#page=22", + ) + + def formula_2015_01_01(person, period, parameters): + p = parameters(period).gov.states.nj.tax.payroll.unemployment + taxable_wages = person("nj_employee_unemployment_taxable_wages", period) + return p.employee_rate * taxable_wages diff --git a/policyengine_us/variables/gov/states/nj/tax/payroll/unemployment/nj_employee_unemployment_taxable_wages.py b/policyengine_us/variables/gov/states/nj/tax/payroll/unemployment/nj_employee_unemployment_taxable_wages.py new file mode 100644 index 00000000000..c3d229bff10 --- /dev/null +++ b/policyengine_us/variables/gov/states/nj/tax/payroll/unemployment/nj_employee_unemployment_taxable_wages.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class nj_employee_unemployment_taxable_wages(Variable): + value_type = float + entity = Person + label = "New Jersey employee unemployment and workforce taxable wages" + documentation = ( + "Covered wages subject to New Jersey employee UI and WF/SWF contributions, " + "including federal pre-tax payroll deductions. The employee wage base is " + "separate from the larger TDI and FLI employee wage base." + ) + definition_period = YEAR + unit = USD + defined_for = StateCode.NJ + reference = ( + "https://www.nj.gov/labor/myunemployment/assets/pdfs/UI_statute.pdf#page=47", + "https://www.nj.gov/labor/ea/assets/PDFs/EmployerAcctsGuide.pdf#page=20", + ) + + def formula_2015_01_01(person, period, parameters): + p = parameters(period).gov.states.nj.tax.payroll.unemployment + return min_( + max_(0, person("employment_income", period)), + p.employee_taxable_wage_base, + ) diff --git a/policyengine_us/variables/gov/states/nj/tax/payroll/workforce_development/nj_employee_workforce_fund_contribution.py b/policyengine_us/variables/gov/states/nj/tax/payroll/workforce_development/nj_employee_workforce_fund_contribution.py new file mode 100644 index 00000000000..000f09a07ca --- /dev/null +++ b/policyengine_us/variables/gov/states/nj/tax/payroll/workforce_development/nj_employee_workforce_fund_contribution.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class nj_employee_workforce_fund_contribution(Variable): + value_type = float + entity = Person + label = "New Jersey employee workforce development contribution" + documentation = ( + "Combined employee contributions under N.J.S.A. 34:15D-13 and 34:15D-22 " + "to the Workforce Development Partnership and Supplemental Workforce " + "Funds, using the employee UI wage base." + ) + definition_period = YEAR + unit = USD + defined_for = StateCode.NJ + reference = ( + "https://www.nj.gov/labor/ea/employer-services/rate-info/", + "https://pub.njleg.gov/bills/2000/PL01/152_.PDF#page=2", + ) + + def formula_2015_01_01(person, period, parameters): + p = parameters(period).gov.states.nj.tax.payroll.workforce_development + taxable_wages = person("nj_employee_unemployment_taxable_wages", period) + return p.employee_rate * taxable_wages diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py index 69366a1932e..c6b5d9dc9d8 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py @@ -18,6 +18,7 @@ class ny_itemized_deductions_max(Variable): Public Law 115-97 (TCJA). This means: - No SALT cap ($10,000 limit does not apply) - State/local income taxes are NOT deductible (only sales + property) + - Medical expenses deductible above 10% of AGI (not the 7.5% floor) - Miscellaneous deductions still allowed with 2% AGI floor - Casualty losses not limited to federally declared disasters - NY-specific college tuition deduction addition per § 615(d) @@ -26,8 +27,8 @@ class ny_itemized_deductions_max(Variable): # Federal deductions that don't differ from pre-TCJA "charitable_deduction", "interest_deduction", - "medical_expense_deduction", # NY-specific pre-TCJA versions + "ny_medical_expense_deduction", "ny_salt_deduction", "ny_misc_deduction", "ny_casualty_loss_deduction", diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py index 27565cc41af..9e160b62047 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py @@ -35,7 +35,7 @@ class ny_itemized_deductions_phase_out(Variable): Worksheet line 2 (L2) = form lines 4, 14, 16a, 20, 29, 30, 37, i.e. the deductions NOT subject to the limitation (26 U.S.C. 68(c)): - - line 4 medical/dental -> medical_expense_deduction + - line 4 medical/dental -> ny_medical_expense_deduction - line 14 investment interest -> investment_interest_expense - line 20 casualty/theft loss -> ny_casualty_loss_deduction - lines 16a/29/30/37 (qualified contributions, gambling losses, @@ -62,7 +62,7 @@ def formula(tax_unit, period, parameters): # Worksheet line 2 (L2): deductions not subject to the limitation # (26 U.S.C. 68(c)) - medical, investment interest, casualty/theft. - medical = tax_unit("medical_expense_deduction", period) + medical = tax_unit("ny_medical_expense_deduction", period) investment_interest = add(tax_unit, period, ["investment_interest_expense"]) casualty = tax_unit("ny_casualty_loss_deduction", period) l2 = medical + investment_interest + casualty diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py new file mode 100644 index 00000000000..eb74491e9f4 --- /dev/null +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * + + +class ny_medical_expense_deduction(Variable): + value_type = float + entity = TaxUnit + label = "NY medical expense deduction" + unit = USD + definition_period = YEAR + reference = ( + "https://www.nysenate.gov/legislation/laws/TAX/615", + "https://www.tax.ny.gov/pdf/2025/inc/it196_2025_fill_in.pdf#page=1", + ) + defined_for = StateCode.NY + documentation = """ + NY Tax Law § 615 requires itemized deductions to be computed using + pre-TCJA federal rules from 2018. Pre-TCJA 26 U.S.C. 213(a) allowed + medical and dental expenses only above 10% of federal AGI; NY does not + follow the federal 7.5% floor in place since 2017 (Public Law 115-97 + § 11027 for 2017-2018, later law after that). Form IT-196 lines 1-4 apply + this floor to federal AGI (Form IT-201 or IT-203 line 19; line 19a, + recomputed federal AGI, in 2020-2022, which PolicyEngine does not + distinguish from federal AGI). Through 2017, NY took the federal + Schedule A amount, so the floor parameter follows the federal floor then. + """ + + def formula(tax_unit, period, parameters): + # From 2018 NY uses pre-TCJA rules: medical expenses are deductible + # above 10% of AGI, not the federal 7.5% floor in place since 2017 + p = parameters(period).gov.states.ny.tax.income.deductions.itemized.medical + expenses = tax_unit("itemized_medical_expenses", period) + medical_floor = p.floor * tax_unit("positive_agi", period) + return max_(0, expenses - medical_floor) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/eligibility/or_liheap_eligible.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/eligibility/or_liheap_eligible.py new file mode 100644 index 00000000000..0273768c7a3 --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/eligibility/or_liheap_eligible.py @@ -0,0 +1,38 @@ +from policyengine_us.model_api import * + + +class or_liheap_eligible(Variable): + value_type = bool + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP regular heating eligibility" + defined_for = StateCode.OR + # Manual PDF pages 11, 30, 36, 61, 77, 93-94; OAR 813-200-0020(1)(b). + reference = ( + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=61", + "https://www.law.cornell.edu/regulations/oregon/Or-Admin-Code-SS-813-200-0020", + ) + documentation = ( + "SPM members approximate the economic household, with identity and SSN " + "verification assumed complete. The energy burden test follows OAR " + "813-200-0020(1)(b) and the manual's energy burden table: subsidized housing " + "with heat included in rent is excluded even when the household pays a " + "separate non-heating utility bill. The Oregon sources do not prescribe a " + "SNAP immigration test. No asset or categorical income test applies. " + "Institutional residence, tribal duplicate awards, documentation and " + "application timing are not modeled. A zero benefit above size 12 or for an " + "unknown county denotes unsupported schedule coverage, not a legal denial." + ) + + def formula(spm_unit, period, parameters): + heat_in_rent = spm_unit("heat_expense_included_in_rent", period) + subsidized = spm_unit( + "receives_housing_assistance", period + ) | spm_unit.household("is_in_public_housing", period) + heating_cost = spm_unit("has_heating_expense", period) | heat_in_rent + income = spm_unit("or_liheap_countable_income", period) + return ( + heating_cost + & ~(heat_in_rent & subsidized) + & (income <= spm_unit("or_liheap_income_limit", period)) + ) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/eligibility/or_liheap_income_limit.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/eligibility/or_liheap_income_limit.py new file mode 100644 index 00000000000..f587bc50125 --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/eligibility/or_liheap_income_limit.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class or_liheap_income_limit(Variable): + value_type = float + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP income limit" + unit = USD + defined_for = StateCode.OR + reference = ( + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=57", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=77", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=71", + ) + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states["or"].ohcs.liheap.eligibility + # Ceiling reproduces every published limit for program years 2025 to + # 2027; the manual does not prescribe a rounding rule. The product is + # rounded to cents in float64 first: float32 0.6 is slightly above 0.6, + # which lifts a whole-dollar product above the integer and would add $1. + # hhs_smi uses the requested year's SMI. + smi = spm_unit("hhs_smi", period).astype(np.float64) + return np.ceil(np.round(smi * p.smi_rate, 2)) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/income/or_liheap_countable_earned_income.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/income/or_liheap_countable_earned_income.py new file mode 100644 index 00000000000..ee74591d645 --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/income/or_liheap_countable_earned_income.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class or_liheap_countable_earned_income(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Oregon LIHEAP countable earned income" + unit = USD + defined_for = StateCode.OR + # PDF pages 35, 41, 45, 53. + reference = "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=35" + documentation = ( + "Existing net self-employment inputs are used without an additional expense " + "deduction. Flooring each source at zero is a modeling convention; the manual " + "does not specify current-period loss offsets." + ) + + def formula(person, period, parameters): + p = parameters(period).gov.states["or"].ohcs.liheap.income + earned = 0 + for source in p.sources.earned: + earned = earned + max_(person(source, period), 0) + counted = (person("age", period) >= p.earned_income_min_age) & ~person( + "is_in_secondary_school", period + ) + return where(counted, earned, 0) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/income/or_liheap_countable_income.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/income/or_liheap_countable_income.py new file mode 100644 index 00000000000..d5797efd478 --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/income/or_liheap_countable_income.py @@ -0,0 +1,50 @@ +from policyengine_us.model_api import * + + +class or_liheap_countable_income(Variable): + value_type = float + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP countable household income" + unit = USD + defined_for = StateCode.OR + # PDF pages 35-56. + reference = "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=35" + documentation = ( + "Annual income approximates the allowed one-, three- or twelve-month " + "observation window. Interest is assumed withdrawn, and the $200 test is " + "applied to each member's interest, a reading the manual does not specify. " + "Each member's unearned sources are floored at zero like the earned sources, " + "so a rental or estate loss does not offset other income, another member's " + "included. Enter disability_benefits as the " + "private insurer's payment after any Social Security offset; Social Security " + "is counted separately, reproducing the manual's examples without deducting " + "the offset twice. Unsupported details include regular gifts, foster and " + "adoption payments, tribal receipts, work-study and caregiver payments within " + "the household. Military wages should be included in employment_income. Use a " + "direct countable-income override when these details matter." + ) + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states["or"].ohcs.liheap.income + person = spm_unit.members + interest = person("interest_income", period) + counted_interest = spm_unit.sum( + where(interest > p.interest_threshold, interest, 0) + ) + # Landlords complete the self-employment form (page 35), so a loss in one + # source cannot offset another, as in the earned-income formula; each + # member's source is floored before the household sum. + unearned = 0 + for source in p.sources.unearned: + unearned = unearned + spm_unit.sum(max_(person(source, period), 0)) + # TANF counts receipt after take-up, not or_tanf entitlement. It is a + # unit-level amount, so it is added here rather than listed with the + # person-level sources. + tanf = max_(spm_unit("tanf", period), 0) + return ( + add(spm_unit, period, ["or_liheap_countable_earned_income"]) + + unearned + + tanf + + counted_interest + ) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/or_liheap.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/or_liheap.py new file mode 100644 index 00000000000..191e8078b17 --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/or_liheap.py @@ -0,0 +1,65 @@ +from policyengine_us.model_api import * + + +class or_liheap(Variable): + value_type = float + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP regular heating benefit" + unit = USD + defined_for = "or_liheap_eligible" + reference = ( + # PDF pages 47, 58-59. + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58", + # PDF pages 61-62, 67, 78-81, 93-94. + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=61", + # PDF pages 59, 72-75. + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72", + ) + documentation = ( + "Annual regular heating assistance, verified for program years 2025 to 2027. " + "Earlier years use parameter backfilling and are unverified historical " + "estimates. No actual-expense cap applies. Heat-in-rent households need the " + "building heating fuel; a fuel without a matrix column pays zero as a coverage " + "gap, not a denial. Discretionary bulk-fuel minimum-delivery payments and local " + "supplements are unsupported because the necessary award information is " + "absent. Cooling, crisis and equipment assistance are excluded." + ) + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states["or"].ohcs.liheap.payment + region = max_(spm_unit("or_liheap_region", period), 1) + size = clip(spm_unit("spm_unit_size", period), 1, p.max_payment_size) + # Out-of-state units carry band 0; the floor keeps them on a valid key. + band = max_(spm_unit("or_liheap_income_band", period), 1) + fuel = spm_unit("heating_type", period) + types = fuel.possible_values + # Kerosene is billed as heating oil and a grid-tied solar home pays an + # electricity bill, the same pairing heating_expense uses. + amount = select( + [ + (fuel == types.ELECTRICITY) | (fuel == types.SOLAR), + (fuel == types.FUEL_OIL) | (fuel == types.KEROSENE), + fuel == types.PROPANE, + fuel == types.NATURAL_GAS, + fuel == types.WOOD, + ], + [ + p.amount.electricity[region][size][band], + p.amount.fuel_oil[region][size][band], + p.amount.propane[region][size][band], + p.amount.natural_gas[region][size][band], + p.amount.wood[region][size][band], + ], + # Coal, other and unspecified fuels have no matrix column; the + # unspecified default is tracked in #9754. + default=0, + ) + dwelling = spm_unit("or_liheap_dwelling_type", period) + separate = dwelling == dwelling.possible_values.ROOMER_BOARDER_OWNER + # Missing payment-table dimensions are coverage gaps, not eligibility + # restrictions. Keep them separate from the legal income/housing test. + supported = (spm_unit("or_liheap_region", period) > 0) & ( + spm_unit("spm_unit_size", period) <= p.max_household_size + ) + return where(supported, amount * where(separate, p.roomer_rate, 1), 0) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/or_liheap_dwelling_type.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/or_liheap_dwelling_type.py new file mode 100644 index 00000000000..857de41c96c --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/or_liheap_dwelling_type.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class ORLIHEAPDwellingType(Enum): + STANDARD = "Standard household" + ROOMER_BOARDER_OWNER = ( + "Roomer, boarder or owner applying separately from other residents" + ) + + +class or_liheap_dwelling_type(Variable): + value_type = Enum + possible_values = ORLIHEAPDwellingType + default_value = ORLIHEAPDwellingType.STANDARD + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP dwelling arrangement" + # PDF pages 30, 61-62. + reference = "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=30" + documentation = ( + "The half-payment arrangement applies when a roomer, boarder or owner applies " + "as a separate economic household and the other residents do not apply " + "together. It is not a reduction for all renters." + ) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/payment/or_liheap_income_band.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/payment/or_liheap_income_band.py new file mode 100644 index 00000000000..5e48a025c64 --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/payment/or_liheap_income_band.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class or_liheap_income_band(Variable): + value_type = int + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP payment income band" + defined_for = StateCode.OR + reference = ( + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=79", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=73", + ) + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states["or"].ohcs.liheap.payment + size = clip(spm_unit("spm_unit_size", period), 1, p.max_household_size) + income = spm_unit("or_liheap_countable_income", period) + # Keep the printed inclusive tops, including their $1 irregularities. + return ( + 1 + + (income > p.income_band["1"][size]) + + (income > p.income_band["2"][size]) + + (income > p.income_band["3"][size]) + ) diff --git a/policyengine_us/variables/gov/states/or/ohcs/liheap/payment/or_liheap_region.py b/policyengine_us/variables/gov/states/or/ohcs/liheap/payment/or_liheap_region.py new file mode 100644 index 00000000000..295b2db8b9e --- /dev/null +++ b/policyengine_us/variables/gov/states/or/ohcs/liheap/payment/or_liheap_region.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class or_liheap_region(Variable): + value_type = int + entity = SPMUnit + definition_period = YEAR + label = "Oregon LIHEAP payment region" + defined_for = StateCode.OR + reference = ( + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=58", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/PY25%20EA%20Operations%20%20Policy%20Manual-%20FINAL%209-27-24.pdf#page=59", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=78", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/2026%20Final%20Energy%20Assistance%20Intake%20Operations%20%26%20Policy%20Manual.pdf#page=80", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=72", + "https://www.oregon.gov/ohcs/energy-weatherization/Documents/manuals/09-23-2027-PY-2027-EA-Operations-Policy-Manual.pdf#page=74", + ) + documentation = ( + "Zero denotes a county reported as UNKNOWN or an out-of-state household. A " + "missing county input falls back to the state's first county in the enum, " + "Baker County, which is in Region 2." + ) + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states["or"].ohcs.liheap.payment.regions + county = spm_unit.household("county_str", period) + return select( + [np.isin(county, p.region_1), np.isin(county, p.region_2)], + [1, 2], + default=0, + ) diff --git a/policyengine_us/variables/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.py b/policyengine_us/variables/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.py index a69dffa25c4..0567f3df8f0 100644 --- a/policyengine_us/variables/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.py +++ b/policyengine_us/variables/gov/states/ri/tax/income/credits/property_tax/ri_property_tax_credit_eligible.py @@ -14,15 +14,19 @@ class ri_property_tax_credit_eligible(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.ri.tax.income.credits.property_tax - # minimum age eligibility - greater_head_or_spouse_age = tax_unit("greater_age_head_spouse", period) - age_eligible = greater_head_or_spouse_age >= p.age_threshold - # disability eligibility - head_is_disabled = tax_unit("head_is_disabled", period) - spouse_is_disabled = tax_unit("spouse_is_disabled", period) - head_or_spouse_disabled = head_is_disabled | spouse_is_disabled + person = tax_unit.members + # R.I. Gen. Laws 44-33-3: the claimant is a homeowner or renter aged + # 65 or older or disabled, and "does not include any person claimed as + # a dependent by any taxpayer". Members of a household choose who + # claims, so either spouse may be the claimant, but the age or + # disability and the dependency test belong to the same person. + filer = person("is_tax_unit_head_or_spouse", period) + age_eligible = person("age", period) >= p.age_threshold + disabled = person("is_disabled", period) + claimed = person("claimed_as_dependent_on_another_return", period) + eligible_claimant = filer & (age_eligible | disabled) & ~claimed household_income = tax_unit("ri_property_tax_household_income", period) income_threshold = p.rate.one_person.thresholds[-1] # The tax form RI-1040H specifies the income of a household as a eligibility requirement household_income_eligible = household_income <= income_threshold - return (age_eligible | head_or_spouse_disabled) & household_income_eligible + return tax_unit.any(eligible_claimant) & household_income_eligible diff --git a/policyengine_us/variables/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.py b/policyengine_us/variables/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.py index 32313e54694..75b23bceb44 100644 --- a/policyengine_us/variables/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.py +++ b/policyengine_us/variables/gov/states/ri/tax/income/credits/rebates/ri_child_tax_rebate.py @@ -18,7 +18,15 @@ def formula(tax_unit, period, parameters): dependent = person("is_tax_unit_dependent", period) p = parameters(period).gov.states.ri.tax.income.credits.child_tax_rebate age_eligibility = age <= p.limit.age - eligible_child = age_eligibility & dependent + # R.I. Gen. Laws 44-30-103(b)(1) pays the rebate for each child "whom + # the eligible taxpayer validly claims as a dependent" on the 2021 + # return. Under IRC 152(b)(1) a return on which the filer (or, if + # joint, either spouse) can be claimed as a dependent has none, unless + # the would-be claimant meets the filing exception (Publication 501). + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + eligible_child = age_eligibility & dependent & ~filer_is_dependent child_count = tax_unit.sum(eligible_child) capped_children = min_(child_count, p.limit.child) return capped_children * p.amount diff --git a/policyengine_us/variables/gov/states/sc/tax/income/deductions/sc_young_child_deduction.py b/policyengine_us/variables/gov/states/sc/tax/income/deductions/sc_young_child_deduction.py index 2e827e9eeea..c1dff53f03b 100644 --- a/policyengine_us/variables/gov/states/sc/tax/income/deductions/sc_young_child_deduction.py +++ b/policyengine_us/variables/gov/states/sc/tax/income/deductions/sc_young_child_deduction.py @@ -20,7 +20,19 @@ def formula(tax_unit, period, parameters): # Determine eligibility for each person in the tax unit. person = tax_unit.members meets_age_limit = person("age", period) < p.ineligible_age - eligible_dependent = meets_age_limit & person("is_tax_unit_dependent", period) + # Section 12-6-1160 requires a "dependent eligible for the South + # Carolina dependent exemption", which a return on which the filer (or, + # if joint, either spouse) can be claimed as a dependent has none of + # (IRC 152(b)(1), incorporated by Section 12-6-1140(13)), unless the + # would-be claimant meets the filing exception (Publication 501). + filer_is_dependent = person.tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + eligible_dependent = ( + meets_age_limit + & person("is_tax_unit_dependent", period) + & ~filer_is_dependent + ) # Count number of eligible people in the tax unit. total_eligible_dependents = tax_unit.sum(eligible_dependent) # Multiply by the amount per eligible dependent. diff --git a/policyengine_us/variables/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.py b/policyengine_us/variables/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.py index 77c51754adb..1a4a75cab4e 100644 --- a/policyengine_us/variables/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.py +++ b/policyengine_us/variables/gov/states/sc/tax/income/exemptions/sc_dependent_exemption.py @@ -17,7 +17,16 @@ class sc_dependent_exemption(Variable): def formula(tax_unit, period, parameters): # Section 12-6-1140(13): a dependent exemption for every dependent. p = parameters(period).gov.states.sc.tax.income.deductions.dependent_exemption - # every dependent is eligible - dependents = tax_unit("tax_unit_dependents", period) + # Section 12-6-1140(13): "each dependent must meet the eligibility + # requirements of Section 151 and 152", and under IRC 152(b)(1) a + # return on which the filer (or, if joint, either spouse) can be + # claimed as a dependent has no dependents, unless the would-be + # claimant meets the filing exception (Publication 501). + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + dependents = where( + filer_is_dependent, 0, tax_unit("tax_unit_dependents", period) + ) # Multiply by the amount per exemption. return dependents * p.amount diff --git a/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_personal_exemption_additional_dependent_eligible.py b/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_personal_exemption_additional_dependent_eligible.py index bf3e43f098f..18d7646016b 100644 --- a/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_personal_exemption_additional_dependent_eligible.py +++ b/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_personal_exemption_additional_dependent_eligible.py @@ -13,4 +13,11 @@ def formula(person, period, parameters): is_dependent = person("is_tax_unit_dependent", period) birth_year = person("birth_year", period) born_this_year = birth_year == period.start.year - return is_dependent & born_this_year + # The additional dependent must also be a dependent of the return, and + # a return on which the filer (or, if joint, either spouse) can be + # claimed as a dependent has none (IRC 152(b)(1)), unless the would-be + # claimant meets the filing exception (Publication 501). + filer_is_dependent = person.tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + return is_dependent & born_this_year & ~filer_is_dependent diff --git a/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_total_dependents.py b/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_total_dependents.py index f9be89959c4..8949b0840ca 100644 --- a/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_total_dependents.py +++ b/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_total_dependents.py @@ -9,4 +9,17 @@ class ut_total_dependents(Variable): documentation = "Form TC-40, line 2c" definition_period = YEAR defined_for = StateCode.UT - adds = ["tax_unit_count_dependents"] + reference = "https://le.utah.gov/xcode/Title59/Chapter10/59-10-S1018.html" + + def formula(tax_unit, period, parameters): + # Utah Code 59-10-1018(1)(c) counts "qualifying dependents": those + # "with respect to whom the claimant is allowed to claim a tax credit + # under Section 24". Under IRC 152(b)(1) a return on which the filer + # (or, if joint, either spouse) can be claimed as a dependent has no + # dependents, and so no such credit, unless the would-be claimant + # meets the filing exception (Publication 501). + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + dependents = tax_unit("tax_unit_count_dependents", period) + return where(filer_is_dependent, 0, dependents) diff --git a/policyengine_us/variables/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.py b/policyengine_us/variables/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.py index 6e18a6211b7..6202e2f34bd 100644 --- a/policyengine_us/variables/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.py +++ b/policyengine_us/variables/gov/states/ut/tax/income/credits/ut_at_home_parent_credit_potential.py @@ -18,7 +18,15 @@ def formula(tax_unit, period, parameters): age = person("age", period) is_dependent = person("is_tax_unit_dependent", period) p = parameters(period).gov.states.ut.tax.income.credits.at_home_parent - qualifying_child = (age < p.max_child_age) & is_dependent + # Utah Code 59-10-1005 requires that the parent claim the qualifying + # child as a dependent. Under IRC 152(b)(1) a return on which the filer + # (or, if joint, either spouse) can be claimed as a dependent has no + # dependents, unless the would-be claimant meets the filing exception + # (Publication 501). + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + qualifying_child = (age < p.max_child_age) & is_dependent & ~filer_is_dependent count_qualifying_children = tax_unit.sum(qualifying_child) # Multiply by each qualifying parent; they can claim it separately. diff --git a/policyengine_us/variables/gov/states/vt/tax/income/credits/ctc/vt_ctc.py b/policyengine_us/variables/gov/states/vt/tax/income/credits/ctc/vt_ctc.py index 6d58a34dfe1..a98deba7e14 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/credits/ctc/vt_ctc.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/credits/ctc/vt_ctc.py @@ -11,11 +11,22 @@ class vt_ctc(Variable): defined_for = StateCode.VT def formula(tax_unit, period, parameters): - # Get age status of all people in the tax unit. + # 32 V.S.A. 5830f: a taxpayer entitled to a child tax credit under the + # laws of the United States (or who would be but for the taxpayer + # identification number requirements of IRC 24(e) and (h)(7)) gets the + # credit for each qualifying child under IRC 152(c) at or under the + # age limit. The identification waiver does not reach IRC 152(b)(1): + # a return on which the filer (or, if joint, either spouse) can be + # claimed as a dependent has no dependents and no federal credit, + # unless the would-be claimant meets the filing exception. person = tax_unit.members age = person("age", period) p = parameters(period).gov.states.vt.tax.income.credits.ctc - eligible = age <= p.age_limit + dependent = person("is_tax_unit_dependent", period) + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + eligible = (age <= p.age_limit) & dependent & ~filer_is_dependent count_eligible = tax_unit.sum(eligible) # Get maximum credit amount. max_credit = p.amount * count_eligible diff --git a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py index c8fd1ec7368..ec949227d2e 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py @@ -8,6 +8,7 @@ class vt_renter_credit(Variable): unit = USD definition_period = YEAR reference = ( + "https://legislature.vermont.gov/statutes/fullchapter/32/154", # 6066(b)(2), (c)(2) "https://law.justia.com/codes/vermont/2022/title-32/chapter-154/section-6066/", # b "https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2022.pdf#page=35", # the formula used in this file is based on the excel sheet provided on the official Vermont government website @@ -20,6 +21,11 @@ def formula(tax_unit, period, parameters): # get tax unit size and county. tax_unit_size = tax_unit("tax_unit_size", period) county = tax_unit.household("county", period) + # 32 V.S.A. 6066(b)(2): the fair market rent is for "a number of + # bedrooms equal to the number of personal exemptions" under + # 5811(21)(C), which allows none for a filer who can be claimed as a + # dependent and no dependents on a return where either filer can be. + exemptions = clip(tax_unit("exemptions_count", period), 1, 8) # locate the values by family size and county full_credit_income_limit = p.income_limit_ami.thirty_percent[tax_unit_size][ county @@ -27,7 +33,7 @@ def formula(tax_unit, period, parameters): partial_credit_income_limit = p.income_limit_ami.fifty_percent[tax_unit_size][ county ] - fmr = p.fair_market_rent[tax_unit_size][county] + fmr = p.fair_market_rent[exemptions][county] base_credit_amount = fmr * MONTHS_IN_YEAR * p.fmr_rate # Compute what the spreadsheet calls the "percent rebate claimable" @@ -71,4 +77,12 @@ def formula(tax_unit, period, parameters): # 6066(b)(1)); 2026 Vt. Act 169 raises this for claim year 2027 only. capped_credit = min_(credit_value, p.max_credit) unrounded = capped_credit * (1 - shared_residence_reduction) - return np.round(unrounded) + # 6066(c)(2): the claimant "may not be a person claimed as a dependent + # by any taxpayer", an actual claim. On a joint return either spouse + # may be the claimant, so only a return on which every filer is + # claimed loses it. + person = tax_unit.members + filer = person("is_tax_unit_head_or_spouse", period) + claimed = person("claimed_as_dependent_on_another_return", period) + claimant_eligible = tax_unit.any(filer & ~claimed) + return claimant_eligible * np.round(unrounded) diff --git a/policyengine_us/variables/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.py b/policyengine_us/variables/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.py index c93c3526bca..484a862d1a5 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/exemptions/vt_personal_exemptions.py @@ -18,7 +18,17 @@ def formula(tax_unit, period, parameters): eligible_head = (~elsewhere_head).astype(int) eligible_spouse = (~elsewhere_spouse).astype(int) eligible_count = eligible_head + (eligible_spouse * is_joint) - # add number of other dependents claimed on federal Form 1040 (line 5c) - dependents = tax_unit("tax_unit_count_dependents", period) + # add number of other dependents claimed on federal Form 1040 (line 5c). + # 32 V.S.A. 5811(21)(C): "no exemption may be claimed" for a dependent + # individual, and under IRC 152(b)(1) a return on which the filer (or, + # if joint, either spouse) can be claimed as a dependent has no + # dependents, unless the would-be claimant meets the filing exception + # (Publication 501). + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + dependents = where( + filer_is_dependent, 0, tax_unit("tax_unit_count_dependents", period) + ) total_exemption_count = eligible_count + dependents return total_exemption_count * p.personal diff --git a/policyengine_us/variables/gov/states/wi/tax/income/credits/childcare_expense/wi_childcare_expense_credit_potential.py b/policyengine_us/variables/gov/states/wi/tax/income/credits/childcare_expense/wi_childcare_expense_credit_potential.py index 3df0c6e0477..749e29a9271 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/credits/childcare_expense/wi_childcare_expense_credit_potential.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/credits/childcare_expense/wi_childcare_expense_credit_potential.py @@ -31,7 +31,10 @@ def formula(tax_unit, period, parameters): # limits, so the §21 employment-related expense definition # applies: childcare plus care for a disabled adult qualifying # individual (care_expenses). - childcare = tax_unit("tax_unit_childcare_expenses", period) + # Only childcare for a qualifying individual counts (IRC + # 21(b)(2)(A)(ii)): a return whose filer can be claimed has no + # qualifying child under 13 (152(b)(1)), so its childcare drops out. + childcare = tax_unit("cdcc_qualifying_childcare_expenses", period) adult_care = add(tax_unit, period, ["care_expenses"]) raw_expenses = childcare + adult_care # Cap to WI limit, then also cap to min head/spouse earned (§21(d)) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/credits/homestead/wi_homestead_income.py b/policyengine_us/variables/gov/states/wi/tax/income/credits/homestead/wi_homestead_income.py index 7ab07ce19be..01e5cc637f9 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/credits/homestead/wi_homestead_income.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/credits/homestead/wi_homestead_income.py @@ -18,5 +18,14 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.states.wi.tax.income.credits income = add(tax_unit, period, p.homestead.income.sources) disqualified_losses = tax_unit("wi_homestead_disqualified_losses", period) - dependents = tax_unit("tax_unit_dependents", period) + # The $500 deduction is for each dependent under IRC 152; a return on + # which the claimant (or, if joint, either spouse) can be claimed as a + # dependent has none (IRC 152(b)(1)), unless the would-be claimant + # meets the filing exception (Publication 501). + filer_is_dependent = tax_unit( + "head_or_spouse_is_dependent_elsewhere_without_filing_exception", period + ) + dependents = where( + filer_is_dependent, 0, tax_unit("tax_unit_dependents", period) + ) return income + disqualified_losses - dependents * p.homestead.income.exemption diff --git a/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py b/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py index daf50dfc0bd..342cba945a8 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py @@ -13,6 +13,15 @@ class wi_refundable_credits(Variable): "https://www.revenue.wi.gov/TaxForms2022/2022-Form1f.pdf", "https://www.revenue.wi.gov/TaxForms2022/2022-Form1-Inst.pdf", "https://docs.legis.wisconsin.gov/misc/lfb/informational_papers/january_2023/0002_individual_income_tax_informational_paper_2.pdf", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/d", + "https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2", ) defined_for = StateCode.WI - adds = "gov.states.wi.tax.income.credits.refundable" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.wi.tax.income.credits + standard = add(tax_unit, period, p.refundable) + permitted_credits = p.retirement_income_exclusion_refundable + permitted = add(tax_unit, period, permitted_credits) if permitted_credits else 0 + elected = tax_unit("wi_retirement_income_exclusion_elected", period) + return where(elected, permitted, standard) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/exemptions/wi_additional_exemption.py b/policyengine_us/variables/gov/states/wi/tax/income/exemptions/wi_additional_exemption.py index c270c849cba..312251d4477 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/exemptions/wi_additional_exemption.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/exemptions/wi_additional_exemption.py @@ -13,14 +13,23 @@ class wi_additional_exemption(Variable): "https://www.revenue.wi.gov/TaxForms2022/2022-Form1f.pdf", "https://www.revenue.wi.gov/TaxForms2022/2022-Form1-Inst.pdf", "https://docs.legis.wisconsin.gov/misc/lfb/informational_papers/january_2023/0002_individual_income_tax_informational_paper_2.pdf", + # Lines 10a and 10b + # PDF pages 15-16 + "https://www.revenue.wi.gov/TaxForms2025/2025-Form1-Inst.pdf#page=15", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): # compute extra exemption amount p = parameters(period).gov.states.wi.tax.income - elderly_head = (tax_unit("age_head", period) >= p.exemption.old_age).astype(int) - elderly_spouse = (tax_unit("age_spouse", period) >= p.exemption.old_age).astype( - int - ) - return (elderly_head + elderly_spouse) * p.exemption.extra + person = tax_unit.members + filer = person("is_tax_unit_head_or_spouse", period) + elderly = person("age", period) >= p.exemption.old_age + # Form 1 instructions, line 10b: the $250 exemption is for "you and/or + # your spouse only if you and/or your spouse are 65 years of age or + # older and are allowed the $700 exemption on line 10a", which a + # filer who can be claimed as a dependent is not, whether or not + # anyone claims that filer. Each spouse is tested separately. + claimable = person("claimable_as_dependent_on_another_return", period) + eligible_filers = tax_unit.sum(filer & elderly & ~claimable) + return eligible_filers * p.exemption.extra diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py index 3c001340416..6544108a00f 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py @@ -10,6 +10,9 @@ class wi_capital_gain_loss_subtraction(Variable): reference = ( "https://www.revenue.wi.gov/TaxForms2021/2021-ScheduleWDf.pdf#page=2", "https://www.revenue.wi.gov/TaxForms2022/2022-ScheduleWDf.pdf#page=2", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/9", + "https://www.law.cornell.edu/uscode/text/26/852#b_3_B", + "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=2", ) defined_for = StateCode.WI @@ -17,7 +20,11 @@ def formula(tax_unit, period, parameters): # calculate Schedule WD, Line 8 stcg_net = add(tax_unit, period, ["short_term_capital_gains"]) # calculate Schedule WD, Line 17 - ltcg_net = add(tax_unit, period, ["long_term_capital_gains"]) + # Capital gain distributions retain their long-term character when + # reported directly on Form 1040, without a federal Schedule D. + ltcg_net = add( + tax_unit, period, ["long_term_capital_gains", "non_sch_d_capital_gains"] + ) # calculate Schedule WD, Line 18 totcg = max_(0, stcg_net + ltcg_net) # calculate Schedule WD, Line 20, the capital gain reduction diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_childcare_expense_subtraction.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_childcare_expense_subtraction.py index 739f18d564c..f6b9b9e0d6d 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_childcare_expense_subtraction.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_childcare_expense_subtraction.py @@ -19,7 +19,10 @@ def formula(tax_unit, period, parameters): # §71.05(6)(b)43. adopts the IRC §21 employment-related expense # definition, which covers childcare plus care for a disabled adult # qualifying individual (care_expenses). - childcare = tax_unit("tax_unit_childcare_expenses", period) + # Only childcare for a qualifying individual counts (IRC + # 21(b)(2)(A)(ii)): a return whose filer can be claimed has no + # qualifying child under 13 (152(b)(1)), so its childcare drops out. + childcare = tax_unit("cdcc_qualifying_childcare_expenses", period) adult_care = add(tax_unit, period, ["care_expenses"]) uncapped_expenses = childcare + adult_care eligible_dependents = tax_unit("count_cdcc_eligible", period) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py index 8515ab0c805..80043667b6e 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py @@ -15,10 +15,8 @@ class wi_retirement_income_exclusion_amount(Variable): def formula(tax_unit, period, parameters): # Schedule SB Line 16: qualifying retirement income, capped. - # This is NOT subtracted from income directly — instead it - # feeds into wi_retirement_income_exclusion_tax_reduction, - # which compares tax computed two ways (with vs without the - # exclusion) because claiming Line 16 forfeits all credits. + # Compute both paths before electing the subtraction: claiming Line + # 16 restricts credits, so the exclusion can increase net tax. psri = parameters( period ).gov.states.wi.tax.income.subtractions.retirement_income diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py new file mode 100644 index 00000000000..b26f8b1a465 --- /dev/null +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class wi_retirement_income_exclusion_elected(Variable): + value_type = bool + entity = TaxUnit + label = "Wisconsin retirement income exclusion is elected" + definition_period = YEAR + reference = ( + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/d", + "https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2", + "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7", + ) + defined_for = StateCode.WI + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.wi.tax.income + if not p.subtractions.retirement_income.exclusion.in_effect: + return tax_unit.filled_array(False) + + # Compare net tax on each return before publishing the elected path. + # Reading the credit components avoids a cycle through the reported + # before-refundable tax and refundable credits. + standard_before_refundable = max_( + 0, + tax_unit("wi_income_tax_before_credits", period) + - tax_unit("wi_non_refundable_credits", period), + ) + standard_refundable = add(tax_unit, period, p.credits.refundable) + permitted_credits = p.credits.retirement_income_exclusion_refundable + exclusion_refundable = ( + add(tax_unit, period, permitted_credits) if permitted_credits else 0 + ) + exclusion_net = ( + tax_unit("wi_retirement_income_exclusion_tax", period) + - exclusion_refundable + ) + standard_net = standard_before_refundable - standard_refundable + line16 = tax_unit("wi_retirement_income_exclusion_amount", period) + # Keep the standard path on a tie and require qualifying income. + return (line16 > 0) & (exclusion_net < standard_net) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py new file mode 100644 index 00000000000..61bbf919b5e --- /dev/null +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py @@ -0,0 +1,54 @@ +from policyengine_us.model_api import * + + +class wi_retirement_income_exclusion_line17_offset(Variable): + value_type = float + entity = TaxUnit + label = "Wisconsin retirement subtraction lost when the larger exclusion is claimed" + unit = USD + definition_period = YEAR + reference = ( + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/c", + # PDF pages 7-8 + "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7", + ) + defined_for = "wi_retirement_income_exclusion_eligible" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.wi.tax.income.subtractions.retirement_income + person = tax_unit.members + age = person("age", period) + retirement = add(person, period, p.sources) + eligible16 = (age >= p.exclusion.min_age) & person( + "is_tax_unit_head_or_spouse", period + ) + retirement16 = retirement * eligible16 + line16_person = min_(p.exclusion.max_amount.single, retirement16) + + # Use the same qualifying income sources as both existing retirement + # subtractions, removing amounts claimed on line 16 (worksheet step 4). + eligible17 = (age >= p.min_age) & ~person("is_tax_unit_dependent", period) + line17_person = min_(p.max_amount, retirement * eligible17) * tax_unit.project( + tax_unit("wi_retirement_income_subtraction_agi_eligible", period) + ) + remaining = max_(0, retirement - line16_person) + nonpooled_offset = tax_unit.sum(line17_person - min_(line17_person, remaining)) + + filing_status = tax_unit("filing_status", period) + pooled = (filing_status == filing_status.possible_values.JOINT) & ( + tax_unit.sum(eligible16) >= 2 + ) + # The joint limit is pooled regardless of each spouse's income. Assign + # line 16 to income above the existing line 17 entitlements first; no + # proportional allocation is required by the statute or worksheet. + pooled_line17 = tax_unit.sum(line17_person * eligible16) + pooled_remaining = max_( + 0, + tax_unit.sum(retirement16) + - tax_unit("wi_retirement_income_exclusion_amount", period), + ) + pooled_offset = max_(0, pooled_line17 - pooled_remaining) + offset = where(pooled, pooled_offset, nonpooled_offset) + # Only add back subtraction actually included in the standard return. + return min_(offset, tax_unit("wi_retirement_income_subtraction", period)) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py index ac660aef9e6..5580f9b5ca3 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py @@ -18,11 +18,4 @@ class wi_income_tax(Variable): def formula(tax_unit, period, parameters): before_refundable = tax_unit("wi_income_tax_before_refundable_credits", period) refundable = tax_unit("wi_refundable_credits", period) - standard_tax = before_refundable - refundable - p = parameters( - period - ).gov.states.wi.tax.income.subtractions.retirement_income.exclusion - if p.in_effect: - exclusion_tax = tax_unit("wi_retirement_income_exclusion_tax", period) - return min_(standard_tax, exclusion_tax) - return standard_tax + return before_refundable - refundable diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py index e0ffc34505f..73320130abc 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py @@ -20,4 +20,7 @@ class wi_income_tax_before_refundable_credits(Variable): def formula(tax_unit, period, parameters): income_tax_before = tax_unit("wi_income_tax_before_credits", period) nonrefundable_credits = tax_unit("wi_non_refundable_credits", period) - return max_(0, income_tax_before - nonrefundable_credits) + standard = max_(0, income_tax_before - nonrefundable_credits) + elected = tax_unit("wi_retirement_income_exclusion_elected", period) + exclusion_tax = tax_unit("wi_retirement_income_exclusion_tax", period) + return where(elected, exclusion_tax, standard) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py index ff92acb6368..6e2f61491cd 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py @@ -1,5 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.states.wi.tax.income.wi_standard_deduction import ( + wi_dependent_standard_deduction_limit, wi_standard_deduction_for_income, ) @@ -12,17 +13,19 @@ class wi_retirement_income_exclusion_tax(Variable): definition_period = YEAR reference = ( "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/a", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/22", + # PDF pages 7-8 "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): # Schedule SB Line 16 path: tax on income reduced by the - # exclusion amount, with zero credits applied. - # wi_taxable_income already includes Line 17; per the Line 17 - # worksheet (step 4), Line 17 self-adjusts downward when - # Line 16 is claimed, so we simply subtract Line 16 here. + # exclusion amount, before any permitted refundable credits. + # The standard path's Line 17 must be reduced by its overlap with + # Line 16, as required by step 4 of the Line 17 worksheet. line16 = tax_unit("wi_retirement_income_exclusion_amount", period) + line17_offset = tax_unit("wi_retirement_income_exclusion_line17_offset", period) taxinc = tax_unit("wi_taxable_income", period) # The Line 16 subtraction reduces WI income (Form 1 line 7), and the @@ -35,11 +38,18 @@ def formula(tax_unit, period, parameters): fstatus = tax_unit("filing_status", period) wi_agi = tax_unit("wi_agi", period) standard_deduction_full = tax_unit("wi_standard_deduction", period) - standard_deduction_reduced = wi_standard_deduction_for_income( - max_(0, wi_agi - line16), fstatus, parameters, period + # The dependent limit (71.05(22)(f)) caps the deduction on this path + # too; it depends on earned income, which line 16 does not change. + standard_deduction_reduced = min_( + wi_standard_deduction_for_income( + max_(0, wi_agi - line16 + line17_offset), fstatus, parameters, period + ), + wi_dependent_standard_deduction_limit(tax_unit, period, parameters), ) extra_standard_deduction = standard_deduction_reduced - standard_deduction_full - exclusion_taxinc = max_(0, taxinc - line16 - extra_standard_deduction) + exclusion_taxinc = max_( + 0, taxinc - line16 + line17_offset - extra_standard_deduction + ) statuses = fstatus.possible_values p = parameters(period).gov.states.wi.tax.income diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py index 362bdf7f14b..fe0d2008d2d 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py @@ -29,6 +29,23 @@ def wi_standard_deduction_for_income(income, filing_status, parameters, period): return max_(0, max_amount - phase_out_amount) +def wi_dependent_standard_deduction_limit(tax_unit, period, parameters): + # Wis. Stat. 71.05(22)(f): for a taxpayer whom another person can claim + # as a dependent, the standard deduction is the lesser of the ordinary + # deduction and the greater of $500 or earned income (IRC 911(d)(2)) plus + # $250, both indexed "in the manner prescribed by sections 1 (f) (3) to + # (6) and 63 (c) (4)" of the IRC, so they equal the federal amounts (Form + # 1 instructions: $1,100 + $350 in 2021 through $1,350 + $450 in 2025). + # The Form 1 worksheet applies when "you (or your spouse if filing a + # joint return) can be claimed", using the return's earned income. + # Returns infinity when no filer can be claimed. + p = parameters(period).gov.irs.deductions.standard.dependent + earned_income = max_(tax_unit("tax_unit_earned_income", period), 0) + limit = max_(p.amount, earned_income + p.additional_earned_income) + filer_is_dependent = tax_unit("head_or_spouse_is_dependent_elsewhere", period) + return where(filer_is_dependent, limit, np.inf) + + class wi_standard_deduction(Variable): value_type = float entity = TaxUnit @@ -44,10 +61,18 @@ class wi_standard_deduction(Variable): # Standard Deduction Table (keyed to WI income, line 7) and the statute, corroborating the phaseout: "https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf#page=35", "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/22", + # Standard Deduction Worksheet for Dependents + "https://www.revenue.wi.gov/TaxForms2025/2025-Form1-Inst.pdf#page=15", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): fstatus = tax_unit("filing_status", period) agi = tax_unit("wi_agi", period) - return wi_standard_deduction_for_income(agi, fstatus, parameters, period) + standard_deduction = wi_standard_deduction_for_income( + agi, fstatus, parameters, period + ) + return min_( + standard_deduction, + wi_dependent_standard_deduction_limit(tax_unit, period, parameters), + ) diff --git a/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.py b/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.py index 28551572e43..0b45e1eb847 100644 --- a/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.py +++ b/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_eligible.py @@ -6,10 +6,19 @@ class wv_low_income_family_tax_credit_eligible(Variable): entity = TaxUnit label = "Eligible for the West Virginia low-income family tax credit" unit = USD - reference = "https://code.wvlegislature.gov/11-21-22/" + reference = ( + "https://code.wvlegislature.gov/11-21-22/", + "https://code.wvlegislature.gov/11-21-22A/", + "https://tax.wv.gov/Documents/PIT/2025/it140.PersonalIncomeTaxFormsAndInstructions.2025.pdf#page=13", + ) definition_period = YEAR defined_for = StateCode.WV def formula(tax_unit, period, parameters): alternative_minimum_tax = tax_unit("alternative_minimum_tax", period) - return alternative_minimum_tax == 0 + # Family size is the number of exemptions that may be legally claimed + # (W. Va. Code 11-21-22A), so a return with zero exemptions, such as a + # filer who can be claimed as a dependent, cannot claim the credit + # (IT-140 instructions). + has_exemption = tax_unit("exemptions_count", period) > 0 + return (alternative_minimum_tax == 0) & has_exemption diff --git a/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_fpg.py b/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_fpg.py index d61d8af8840..3e4e341b9d0 100644 --- a/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_fpg.py +++ b/policyengine_us/variables/gov/states/wv/tax/income/credits/liftc/wv_low_income_family_tax_credit_fpg.py @@ -9,6 +9,7 @@ class wv_low_income_family_tax_credit_fpg(Variable): ) unit = USD definition_period = YEAR + reference = "https://code.wvlegislature.gov/11-21-22A/" defined_for = "wv_low_income_family_tax_credit_eligible" def formula(tax_unit, period, parameters): @@ -16,8 +17,11 @@ def formula(tax_unit, period, parameters): period ).gov.states.wv.tax.income.credits.liftc # low_income_family_tax_credit - # max family size limit - n = tax_unit("tax_unit_size", period) + # W. Va. Code 11-21-22A: family size is "the total number of exemptions + # that may be legally claimed", not the number of people in the unit. + # A filer who can be claimed as a dependent has no exemption, and a + # return where either filer can be has no dependent exemptions. + n = tax_unit("exemptions_count", period) state_group = tax_unit.household("state_group_str", period) p_fpg = parameters(period).gov.hhs.fpg diff --git a/policyengine_us/variables/gov/states/wv/tax/income/exemptions/wv_personal_exemption.py b/policyengine_us/variables/gov/states/wv/tax/income/exemptions/wv_personal_exemption.py index 4e685595093..4c729af5874 100644 --- a/policyengine_us/variables/gov/states/wv/tax/income/exemptions/wv_personal_exemption.py +++ b/policyengine_us/variables/gov/states/wv/tax/income/exemptions/wv_personal_exemption.py @@ -8,9 +8,21 @@ class wv_personal_exemption(Variable): defined_for = StateCode.WV unit = USD definition_period = YEAR - reference = "https://code.wvlegislature.gov/11-21/" + reference = ( + "https://code.wvlegislature.gov/11-21-16/", + # Form IT-140 exemptions box (e) and line 6 instructions + # PDF pages 3, 26 + "https://tax.wv.gov/Documents/PIT/2025/it140.PersonalIncomeTaxFormsAndInstructions.2025.pdf#page=3", + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.wv.tax.income.exemptions - tax_unit_size = tax_unit("tax_unit_size", period) - return where(tax_unit_size == 0, p.base_personal, p.personal * tax_unit_size) + # W. Va. Code 11-21-16 allows $2,000 per federal exemption. The IT-140 + # instructions allow none for a filer who "can be claimed as a + # dependent on another person's return" and add: "You cannot claim + # any dependents if you can be claimed as a dependent" (IRC 151(d)(2), + # 152(b)(1)). 11-21-16(d) gives an individual whose federal exemption + # is zero under 151(d)(2) a single $500 exemption; the form applies it + # once per return: "If box e is zero, enter $500 on line 6". + exemptions = tax_unit("exemptions_count", period) + return where(exemptions == 0, p.base_personal, p.personal * exemptions) diff --git a/policyengine_us/variables/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.py b/policyengine_us/variables/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.py index 9f613c457d1..a13eabbf8cb 100644 --- a/policyengine_us/variables/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.py +++ b/policyengine_us/variables/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.py @@ -7,6 +7,12 @@ class wv_senior_citizen_disability_deduction_person(Variable): label = "West Virginia senior citizen or disability deduction for each person" unit = USD definition_period = YEAR + reference = ( + "https://code.wvlegislature.gov/11-21-12/", + "https://tax.wv.gov/Documents/PIT/2025/it140.PersonalIncomeTaxFormsAndInstructions.2025.pdf#page=6", + "https://tax.wv.gov/Documents/PIT/2024/it140.PersonalIncomeTaxFormsAndInstructions.2024.pdf#page=6", + "https://tax.wv.gov/Documents/TaxForms/2018/it140.booklet.pdf#page=5", + ) defined_for = "wv_senior_citizen_disability_deduction_eligible_person" def formula(person, period, parameters): @@ -15,12 +21,13 @@ def formula(person, period, parameters): ).gov.states.wv.tax.income.subtractions.senior_citizen_disability_deduction agi_person = person("adjusted_gross_income_person", period) - capped_agi = min_(p.cap, agi_person) - # While the legal code specifies that the total value of the modifications can not exceed - # a certain amount, we can mathematically derive this condition by subtracting the modification - # amount from the adjusted gross income as specified in subdivision (c) (9) (ii) + # Box (c) excludes the person's own amounts on lines 35-46 before + # applying the cap, including income already deducted on line 39. + excluded_amounts = add(person, period, p.excluded_income_sources) + capped_income = min_(p.cap, agi_person - excluded_amounts) + # Box (d) then subtracts the person's own lines 29-34 modifications. total_modifications = person( "wv_senior_citizen_disability_deduction_total_modifications", period, ) - return max_(0, capped_agi - total_modifications) + return max_(0, capped_income - total_modifications) diff --git a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py index 57064157628..1f1a9e813e4 100644 --- a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py @@ -9,7 +9,11 @@ class pre_tax_health_insurance_premiums(Variable): documentation = ( "Health insurance premiums paid through pre-tax payroll deductions. " "This excludes Medicare Part B premiums and other post-tax medical " - "out-of-pocket premiums." + "out-of-pocket premiums. These are employees' shares of " + "employer-sponsored insurance premiums, so they are uprated by CMS's " + "employer-sponsored insurance premium per enrollee, holding the " + "number of people covered per policy and the employee's share of the " + "premium at their base-year levels." ) definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" diff --git a/policyengine_us/variables/household/expense/retirement/_ira_supplied_contributions.py b/policyengine_us/variables/household/expense/retirement/_ira_supplied_contributions.py new file mode 100644 index 00000000000..990a0a17e93 --- /dev/null +++ b/policyengine_us/variables/household/expense/retirement/_ira_supplied_contributions.py @@ -0,0 +1,78 @@ +from policyengine_us.model_api import * + +IRA_CONTRIBUTION_VARIABLES = ( + "traditional_ira_contributions", + "roth_ira_contributions", +) + + +def _is_supplied(simulation, name, period): + """Whether the caller set ``name`` for ``period`` as an input. + + Inputs given when the simulation is built are listed in + ``simulation.input_variables``. Core records later ``set_input`` calls in + ``_user_input_keys`` by variable, branch and period. Neither lists a value + that a formula calculated. + """ + if name in simulation.input_variables: + return True + keys = getattr(simulation, "_user_input_keys", None) or () + if hasattr(simulation, "_get_visible_branch_names"): + branches = set(simulation._get_visible_branch_names()) + else: + branches = {getattr(simulation, "branch_name", "default"), "default"} + return any( + variable == name and branch in branches and set_period == period + for variable, branch, set_period in keys + ) + + +def supplied_ira_contributions(person, period): + """Actual IRA contributions the caller supplied as inputs, by variable. + + Both variables are derived from ``ira_contribution_limit`` through + ``ira_contribution_scale``, so neither can be calculated while the limit + or the scale is being calculated. This reads only values the caller set + as inputs, at construction or later through ``set_input``, and never runs + a formula, so a value another formula calculated is never mistaken for a + supplied one. A variable that is not supplied for the period maps to + ``None``. Core stores an input for every person once any person sets it, + so a supplied variable replaces its formula for the whole population. + """ + simulation = person.simulation + supplied = {} + for name in IRA_CONTRIBUTION_VARIABLES: + array = None + if _is_supplied(simulation, name, period): + array = simulation.get_array(name, period) + supplied[name] = array + return supplied + + +def traditional_ira_contributions_without_spousal_rule(person, period, own_limit): + """Traditional IRA contributions under a limit that ignores the spousal rule. + + Supplied traditional contributions are returned as given (floored at + zero). Otherwise desired traditional contributions are scaled, as + ``ira_contribution_scale`` does, to fit what the limit leaves after any + supplied Roth contributions, sharing it with desired Roth contributions in + proportion. This matches ``traditional_ira_contributions`` for anyone the + IRC 219(c) spousal rule cannot reach, such as a tax unit dependent, without + reading the tax unit's filing status. + """ + supplied = supplied_ira_contributions(person, period) + supplied_traditional = supplied["traditional_ira_contributions"] + if supplied_traditional is not None: + return max_(supplied_traditional, 0) + desired_traditional = person("traditional_ira_contributions_desired", period) + supplied_roth = supplied["roth_ira_contributions"] + if supplied_roth is None: + generated_desired = desired_traditional + person( + "roth_ira_contributions_desired", period + ) + available = own_limit + else: + generated_desired = desired_traditional + available = max_(own_limit - max_(supplied_roth, 0), 0) + denominator = where(generated_desired > 0, generated_desired, 1) + return desired_traditional * min_(available / denominator, 1) diff --git a/policyengine_us/variables/household/expense/retirement/ira_compensation.py b/policyengine_us/variables/household/expense/retirement/ira_compensation.py new file mode 100644 index 00000000000..a9eeff746ee --- /dev/null +++ b/policyengine_us/variables/household/expense/retirement/ira_compensation.py @@ -0,0 +1,38 @@ +from policyengine_us.model_api import * + + +class ira_compensation(Variable): + value_type = float + entity = Person + label = "Compensation for IRA contribution limits" + unit = USD + documentation = ( + "Taxable wages, taxable alimony, and net self-employment earnings " + "less deductible self-employment taxes and self-employed retirement " + "plan contributions. A net self-employment loss does not reduce wages." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/219#f_1", + "https://www.law.cornell.edu/uscode/text/26/401#c_2", + "https://www.irs.gov/publications/p590a", + ) + + def formula(person, period, parameters): + wages = person("irs_employment_income", period) + alimony = person("taxable_alimony_income", period) + self_employment_earnings = ( + add( + person, + period, + [ + "self_employment_income", + "sstb_self_employment_income", + "farm_operations_income", + "partnership_self_employment_net_earnings", + ], + ) + - person("self_employment_tax_ald_person", period) + - person("self_employed_pension_contribution_ald_person", period) + ) + return max_(wages, 0) + max_(alimony, 0) + max_(self_employment_earnings, 0) diff --git a/policyengine_us/variables/household/expense/retirement/ira_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/ira_contribution_limit.py index cd342234af1..a53cb854e56 100644 --- a/policyengine_us/variables/household/expense/retirement/ira_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/ira_contribution_limit.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.expense.retirement._ira_supplied_contributions import ( + supplied_ira_contributions, +) class ira_contribution_limit(Variable): @@ -6,18 +9,72 @@ class ira_contribution_limit(Variable): entity = Person label = "IRA contribution limit" unit = USD + documentation = ( + "Combined traditional and Roth IRA contribution limit, including the " + "dollar and compensation limits and compensation sharing for the " + "lower-compensation spouse on a joint return." + ) definition_period = YEAR reference = ( - "https://www.law.cornell.edu/uscode/text/26/219#b", + "https://www.law.cornell.edu/uscode/text/26/219#b_1", + "https://www.law.cornell.edu/uscode/text/26/219#c", "https://www.law.cornell.edu/uscode/text/26/408A#c_2", + "https://www.irs.gov/publications/p590a", ) def formula(person, period, parameters): p = parameters(period).gov.irs.gross_income.retirement_contributions - age = person("age", period) - catch_up_eligible = age >= p.catch_up.age_threshold - return p.limit.ira + where( - catch_up_eligible, - p.catch_up.limit.ira, - 0, + catch_up_eligible = person("age", period) >= p.catch_up.age_threshold + dollar_limit = p.limit.ira + where(catch_up_eligible, p.catch_up.limit.ira, 0) + compensation = person("ira_compensation", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + joint = person.tax_unit("tax_unit_is_joint", period) + joint_compensation = person.tax_unit.sum(compensation * head_or_spouse) + spouse_compensation = where( + head_or_spouse, joint_compensation - compensation, 0 + ) + + own_limit = min_(dollar_limit, compensation) + # Supplied actual contributions consume compensation as given: + # traditional amounts up to this person's own limit, Roth amounts in + # full, as in traditional_ira_deduction. Generated contributions then + # fill only what remains of the person's own limit. + supplied = supplied_ira_contributions(person, period) + supplied_traditional = supplied["traditional_ira_contributions"] + supplied_roth = supplied["roth_ira_contributions"] + supplied_consumed = 0 + generated_desired = 0 + if supplied_traditional is None: + generated_desired = generated_desired + person( + "traditional_ira_contributions_desired", period + ) + else: + supplied_consumed = supplied_consumed + min_( + max_(supplied_traditional, 0), own_limit + ) + if supplied_roth is None: + generated_desired = generated_desired + person( + "roth_ira_contributions_desired", period + ) + else: + supplied_consumed = supplied_consumed + max_(supplied_roth, 0) + # Only the lower-compensation spouse can use the spousal rule. The + # higher-compensation spouse's generated contributions therefore use + # their own compensation limit. Generated amounts are calculated + # without referring to actual contributions, which depend on this + # limit through the scale. + own_contributions = supplied_consumed + min_( + max_(generated_desired, 0), max_(own_limit - supplied_consumed, 0) + ) + joint_own_contributions = person.tax_unit.sum( + own_contributions * head_or_spouse + ) + spouse_contributions = where( + head_or_spouse, joint_own_contributions - own_contributions, 0 + ) + use_spousal_rule = joint & head_or_spouse & (compensation < spouse_compensation) + spousal_compensation = compensation + max_( + spouse_compensation - spouse_contributions, 0 ) + compensation_limit = where(use_spousal_rule, spousal_compensation, compensation) + return min_(dollar_limit, compensation_limit) diff --git a/policyengine_us/variables/household/expense/retirement/ira_contribution_scale.py b/policyengine_us/variables/household/expense/retirement/ira_contribution_scale.py index a5578385cfd..6b298c95b93 100644 --- a/policyengine_us/variables/household/expense/retirement/ira_contribution_scale.py +++ b/policyengine_us/variables/household/expense/retirement/ira_contribution_scale.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.expense.retirement._ira_supplied_contributions import ( + supplied_ira_contributions, +) class ira_contribution_scale(Variable): @@ -19,15 +22,20 @@ class ira_contribution_scale(Variable): ) def formula(person, period, parameters): - total_desired = add( - person, - period, - [ - "traditional_ira_contributions_desired", - "roth_ira_contributions_desired", - ], - ) - return min_( - person("ira_contribution_limit", period) / max_(total_desired, 1), - 1, - ) + # Supplied actual contributions use up the limit first; generated + # contributions share what remains, in proportion to their desired + # amounts. + supplied = supplied_ira_contributions(person, period) + supplied_own = 0 + generated_desired = 0 + for name, desired in ( + ("traditional_ira_contributions", "traditional_ira_contributions_desired"), + ("roth_ira_contributions", "roth_ira_contributions_desired"), + ): + if supplied[name] is None: + generated_desired = generated_desired + person(desired, period) + else: + supplied_own = supplied_own + max_(supplied[name], 0) + available = max_(person("ira_contribution_limit", period) - supplied_own, 0) + denominator = where(generated_desired > 0, generated_desired, 1) + return min_(available / denominator, 1) diff --git a/policyengine_us/variables/household/income/person/estate/estate_income_would_be_qualified.py b/policyengine_us/variables/household/income/person/estate/estate_income_would_be_qualified.py index dd4eb30b820..17192f849ad 100644 --- a/policyengine_us/variables/household/income/person/estate/estate_income_would_be_qualified.py +++ b/policyengine_us/variables/household/income/person/estate/estate_income_would_be_qualified.py @@ -4,10 +4,18 @@ class estate_income_would_be_qualified(Variable): value_type = bool entity = Person - label = "Estate income would be qualified" + label = "Estate and trust income would be qualified" documentation = ( - "Whether income from estates would be considered qualified business income." + "Whether the estate and trust income amount consists of the beneficiary's " + "allocated qualified business items reported on Form 1041 Schedule K-1, " + "box 14, code I. Taxable estate or trust income alone does not establish " + "QBI qualification." ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/199A#c_3_A" - default_value = True + reference = ( + "https://www.law.cornell.edu/uscode/text/26/199A#c", + "https://www.law.cornell.edu/uscode/text/26/662", + "https://www.law.cornell.edu/cfr/text/26/1.199A-6#d", + "https://www.irs.gov/pub/irs-pdf/i1041sk1.pdf#page=4", + ) + default_value = False diff --git a/policyengine_us/variables/household/marginal_tax_rate.py b/policyengine_us/variables/household/marginal_tax_rate.py index 879241a29ab..e6f38c8c647 100644 --- a/policyengine_us/variables/household/marginal_tax_rate.py +++ b/policyengine_us/variables/household/marginal_tax_rate.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + user_set_variables, +) class marginal_tax_rate(Variable): @@ -41,13 +44,11 @@ def formula(person, period, parameters): 0, ) + inputs = user_set_variables(sim) for adult_index in range(1, 1 + adult_count): alt_sim = sim.get_branch(f"mtr_for_adult_{adult_index}") for variable in sim.tax_benefit_system.variables: - if ( - variable not in sim.input_variables - or variable == "employment_income" - ): + if variable not in inputs or variable == "employment_income": alt_sim.delete_arrays(variable) mask = adult_index == adult_indexes alt_sim.set_input( diff --git a/policyengine_us/variables/household/marginal_tax_rate_helpers.py b/policyengine_us/variables/household/marginal_tax_rate_helpers.py index 3ac38a5a532..6a105fc2a3a 100644 --- a/policyengine_us/variables/household/marginal_tax_rate_helpers.py +++ b/policyengine_us/variables/household/marginal_tax_rate_helpers.py @@ -1,4 +1,34 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.expense.retirement._ira_supplied_contributions import ( + IRA_CONTRIBUTION_VARIABLES, +) + + +def user_set_variables(sim): + """Names of the variables a counterfactual branch must keep. + + ``sim.input_variables`` lists inputs given when the simulation was built. + The IRA contribution variables are also kept when the caller set them + later through ``set_input`` (core records those in ``_user_input_keys``): + ``ira_contribution_limit`` reads them as supplied, so a branch that + deleted them would regenerate a different IRA allocation alongside the + earnings bump. Other later ``set_input`` calls are not kept, because + model formulas also call ``set_input`` internally (the SSA revenue + formulas set ``tax_unit_taxable_social_security``), and a branch must + recompute those. + """ + names = set(sim.input_variables) + keys = getattr(sim, "_user_input_keys", None) or () + if hasattr(sim, "_get_visible_branch_names"): + branches = set(sim._get_visible_branch_names()) + else: + branches = {getattr(sim, "branch_name", "default"), "default"} + names.update( + name + for name, branch, _period in keys + if name in IRA_CONTRIBUTION_VARIABLES and branch in branches + ) + return names def compute_component_mtr(person, period, parameters, tax_variable, branch_prefix): @@ -31,11 +61,12 @@ def compute_component_mtr(person, period, parameters, tax_variable, branch_prefi self_employment_income = person("self_employment_income", period) emp_self_emp_ratio = person("emp_self_emp_ratio", period) + inputs = user_set_variables(sim) for adult_index in range(1, 1 + adult_count): branch_name = f"{branch_prefix}_for_adult_{adult_index}" alt_sim = sim.get_branch(branch_name) for variable in sim.tax_benefit_system.variables: - if variable not in sim.input_variables or variable == "employment_income": + if variable not in inputs or variable == "employment_income": alt_sim.delete_arrays(variable) mask = adult_index == adult_indexes alt_sim.set_input( diff --git a/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py b/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py index b6b5580fae0..02616ff8eb7 100644 --- a/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py +++ b/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + user_set_variables, +) class marginal_tax_rate_including_health_benefits(Variable): @@ -43,13 +46,11 @@ def formula(person, period, parameters): 0, ) + inputs = user_set_variables(sim) for adult_index in range(1, 1 + adult_count): alt_sim = sim.get_branch(f"mtr_for_adult_{adult_index}") for variable in sim.tax_benefit_system.variables: - if ( - variable not in sim.input_variables - or variable == "employment_income" - ): + if variable not in inputs or variable == "employment_income": alt_sim.delete_arrays(variable) mask = adult_index == adult_indexes alt_sim.set_input( diff --git a/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py b/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py index 15cb910e830..bf21c62d1a2 100644 --- a/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py +++ b/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py @@ -7,8 +7,14 @@ class employer_sponsored_insurance_premiums(Variable): label = "Employer-sponsored insurance premiums" documentation = ( "Annual employer-paid health insurance premiums. CBO treats this " - "as part of household market income." + "as part of household market income. Uprated by CMS's " + "employer-sponsored insurance premium per enrollee, so each " + "policyholder's premium grows with premiums rather than wages. This " + "is a premium-growth proxy: it holds the number of people covered " + "per policy and the employer's share of the premium, which CMS " + "projects as roughly constant, at their base-year levels. The number " + "of policyholders follows the weights." ) definition_period = YEAR unit = USD - uprating = "calibration.gov.cbo.income_by_source.employment_income" + uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" diff --git a/policyengine_us/variables/input/employment_income.py b/policyengine_us/variables/input/employment_income.py index 499dd2c3ab3..66cbd7f3b30 100644 --- a/policyengine_us/variables/input/employment_income.py +++ b/policyengine_us/variables/input/employment_income.py @@ -5,7 +5,11 @@ class employment_income(Variable): value_type = float entity = Person label = "employment income" - documentation = "Wages and salaries, including tips and commissions." + documentation = ( + "Gross wages and salaries, including tips and commissions, before " + "pre-tax payroll deductions such as traditional elective retirement " + "deferrals. Federal taxable wages are irs_employment_income." + ) unit = USD definition_period = YEAR adds = [ diff --git a/pyproject.toml b/pyproject.toml index fe9ef20f9a6..a24313f371f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-us" -version = "2.37.2" +version = "2.38.23" description = "US federal and state tax-benefit microsimulation model." readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index 406cfb36a15..09f9f330dc9 100644 --- a/uv.lock +++ b/uv.lock @@ -1818,7 +1818,7 @@ wheels = [ [[package]] name = "policyengine-us" -version = "2.37.2" +version = "2.38.23" source = { editable = "." } dependencies = [ { name = "microdf-python" },