diff --git a/changelog.d/hsa-payroll-contribution-uprating.changed.md b/changelog.d/hsa-payroll-contribution-uprating.changed.md new file mode 100644 index 00000000000..c1539dfa3a9 --- /dev/null +++ b/changelog.d/hsa-payroll-contribution-uprating.changed.md @@ -0,0 +1 @@ +Uprate health savings account payroll contributions by the 26 U.S.C. 223(b)(2) family contribution limit, now encoded for 2004–2027 and projected from the statute under 223(g), instead of CMS per-capita out-of-pocket health spending. diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index b8562e63b9c..f488b83bae5 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, 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. +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; and health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums. 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. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. 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/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml b/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml new file mode 100644 index 00000000000..982b037d35e --- /dev/null +++ b/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml @@ -0,0 +1,106 @@ +description: The IRS limits annual contributions to a health savings account for an individual with family high deductible health plan coverage to this amount. +values: + # 26 U.S.C. 223(b)(2)(B): $4,500, indexed under 223(g) from a 1997 base. + # Through 2006 the limit was the lesser of this amount and the plan's annual + # deductible; Pub. L. 109-432 § 303 removed the deductible test from 2007. + 2004-01-01: 5_150 + 2005-01-01: 5_250 + 2006-01-01: 5_450 + 2007-01-01: 5_650 + 2008-01-01: 5_800 + 2009-01-01: 5_950 + 2010-01-01: 6_150 + 2011-01-01: 6_150 + 2012-01-01: 6_250 + 2013-01-01: 6_450 + 2014-01-01: 6_550 + 2015-01-01: 6_650 + 2016-01-01: 6_750 + 2017-01-01: 6_750 + # Rev. Proc. 2018-18 cut the 2018 family limit to $6,850 on the chained + # CPI-U; Rev. Proc. 2018-27 let taxpayers treat $6,900 as the limit. + 2018-01-01: 6_900 + 2019-01-01: 7_000 + 2020-01-01: 7_100 + 2021-01-01: 7_200 + 2022-01-01: 7_300 + 2023-01-01: 7_750 + 2024-01-01: 8_300 + 2025-01-01: 8_550 + 2026-01-01: 8_750 + 2027-01-01: 9_000 +metadata: + unit: currency-USD + period: year + label: HSA contribution limit, family coverage + # Values after the last IRS publication are computed in + # parameters/uprating_extensions.py per 26 U.S.C. 223(g): the statutory + # $4,500 increased by the chained CPI-U cost-of-living adjustment with 1997 + # as the base year and the 12 months ending March 31 as the averaging + # window, each increase rounded to the nearest multiple of $50. Those values + # supersede this uprating rule, which names the index family and rounding + # (and lets reforms that re-index gov.irs.uprating parameters find it). + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 50 + reference: + - title: 26 U.S. Code § 223(b)(2)(B) - Monthly limitation, family coverage + href: https://www.law.cornell.edu/uscode/text/26/223#b_2_B + - title: 26 U.S. Code § 223(g) - Cost-of-living adjustment + href: https://www.law.cornell.edu/uscode/text/26/223#g + - title: Pub. L. 109-432, § 303 - Repeal of annual deductible limitation (2007) + href: https://www.congress.gov/109/plaws/publ432/PLAW-109publ432.pdf#page=29 + - title: Notice 2004-2, Q&A 12 (2004) + href: https://www.irs.gov/irb/2004-02_IRB#NOT-2004-2 + - title: Rev. Proc. 2004-71 (2005) + href: https://www.irs.gov/pub/irs-drop/rp-04-71.pdf#page=11 + - title: Rev. Proc. 2005-70 (2006) + href: https://www.irs.gov/pub/irs-drop/rp-05-70.pdf#page=14 + - title: Rev. Proc. 2007-36 (2007, after Pub. L. 109-432) + href: https://www.irs.gov/pub/irs-drop/rp-07-36.pdf#page=2 + - title: Rev. Proc. 2007-36 (2008) + href: https://www.irs.gov/pub/irs-drop/rp-07-36.pdf#page=2 + - title: Rev. Proc. 2008-29 (2009) + href: https://www.irs.gov/pub/irs-drop/rp-08-29.pdf#page=1 + - title: Rev. Proc. 2009-29 (2010) + href: https://www.irs.gov/pub/irs-drop/rp-09-29.pdf#page=1 + - title: Rev. Proc. 2010-22 (2011) + href: https://www.irs.gov/pub/irs-drop/rp-10-22.pdf#page=2 + - title: Rev. Proc. 2011-32 (2012) + href: https://www.irs.gov/pub/irs-drop/rp-11-32.pdf#page=1 + - title: Rev. Proc. 2012-26 (2013) + href: https://www.irs.gov/pub/irs-drop/rp-12-26.pdf#page=1 + - title: Rev. Proc. 2013-25 (2014) + href: https://www.irs.gov/pub/irs-drop/rp-13-25.pdf#page=1 + - title: Rev. Proc. 2014-30 (2015) + href: https://www.irs.gov/pub/irs-drop/rp-14-30.pdf#page=1 + - title: Rev. Proc. 2015-30 (2016) + href: https://www.irs.gov/pub/irs-drop/rp-15-30.pdf#page=1 + - title: Rev. Proc. 2016-28 (2017) + href: https://www.irs.gov/pub/irs-drop/rp-16-28.pdf#page=1 + - title: Rev. Proc. 2017-37 (2018) + href: https://www.irs.gov/pub/irs-drop/rp-17-37.pdf#page=1 + - title: Rev. Proc. 2018-18 (2018 family limit of $6,850) + href: https://www.irs.gov/irb/2018-10_IRB#RP-2018-18 + - title: Rev. Proc. 2018-27 (2018 family limit of $6,900) + href: https://www.irs.gov/pub/irs-drop/rp-18-27.pdf#page=3 + - title: Rev. Proc. 2018-30 (2019) + href: https://www.irs.gov/pub/irs-drop/rp-18-30.pdf#page=1 + - title: Rev. Proc. 2019-25 (2020) + href: https://www.irs.gov/irb/2019-22_IRB#REV-PROC-2019-25 + - title: Rev. Proc. 2020-32 (2021) + href: https://www.irs.gov/pub/irs-drop/rp-20-32.pdf#page=1 + - title: Rev. Proc. 2021-25 (2022) + href: https://www.irs.gov/pub/irs-drop/rp-21-25.pdf#page=1 + - title: Rev. Proc. 2022-24 (2023) + href: https://www.irs.gov/pub/irs-drop/rp-22-24.pdf#page=1 + - title: Rev. Proc. 2023-23 (2024) + href: https://www.irs.gov/pub/irs-drop/rp-23-23.pdf#page=2 + - title: Rev. Proc. 2024-25 (2025) + href: https://www.irs.gov/pub/irs-drop/rp-24-25.pdf#page=2 + - title: Rev. Proc. 2025-19 (2026) + href: https://www.irs.gov/pub/irs-drop/rp-25-19.pdf#page=2 + - title: Rev. Proc. 2026-24 (2027) + href: https://www.irs.gov/pub/irs-drop/rp-26-24.pdf#page=2 diff --git a/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/self_only.yaml b/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/self_only.yaml new file mode 100644 index 00000000000..b03bb6effa2 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/self_only.yaml @@ -0,0 +1,100 @@ +description: The IRS limits annual contributions to a health savings account for an individual with self-only high deductible health plan coverage to this amount. +values: + # 26 U.S.C. 223(b)(2)(A): $2,250, indexed under 223(g) from a 1997 base. + # Through 2006 the limit was the lesser of this amount and the plan's annual + # deductible; Pub. L. 109-432 § 303 removed the deductible test from 2007. + 2004-01-01: 2_600 + 2005-01-01: 2_650 + 2006-01-01: 2_700 + 2007-01-01: 2_850 + 2008-01-01: 2_900 + 2009-01-01: 3_000 + 2010-01-01: 3_050 + 2011-01-01: 3_050 + 2012-01-01: 3_100 + 2013-01-01: 3_250 + 2014-01-01: 3_300 + 2015-01-01: 3_350 + 2016-01-01: 3_350 + 2017-01-01: 3_400 + 2018-01-01: 3_450 + 2019-01-01: 3_500 + 2020-01-01: 3_550 + 2021-01-01: 3_600 + 2022-01-01: 3_650 + 2023-01-01: 3_850 + 2024-01-01: 4_150 + 2025-01-01: 4_300 + 2026-01-01: 4_400 + 2027-01-01: 4_500 +metadata: + unit: currency-USD + period: year + label: HSA contribution limit, self-only coverage + # Values after the last IRS publication are computed in + # parameters/uprating_extensions.py per 26 U.S.C. 223(g): the statutory + # $2,250 increased by the chained CPI-U cost-of-living adjustment with 1997 + # as the base year and the 12 months ending March 31 as the averaging + # window, each increase rounded to the nearest multiple of $50. Those values + # supersede this uprating rule, which names the index family and rounding + # (and lets reforms that re-index gov.irs.uprating parameters find it). + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 50 + reference: + - title: 26 U.S. Code § 223(b)(2)(A) - Monthly limitation, self-only coverage + href: https://www.law.cornell.edu/uscode/text/26/223#b_2_A + - title: 26 U.S. Code § 223(g) - Cost-of-living adjustment + href: https://www.law.cornell.edu/uscode/text/26/223#g + - title: Pub. L. 109-432, § 303 - Repeal of annual deductible limitation (2007) + href: https://www.congress.gov/109/plaws/publ432/PLAW-109publ432.pdf#page=29 + - title: Notice 2004-2, Q&A 12 (2004) + href: https://www.irs.gov/irb/2004-02_IRB#NOT-2004-2 + - title: Rev. Proc. 2004-71 (2005) + href: https://www.irs.gov/pub/irs-drop/rp-04-71.pdf#page=11 + - title: Rev. Proc. 2005-70 (2006) + href: https://www.irs.gov/pub/irs-drop/rp-05-70.pdf#page=14 + - title: Rev. Proc. 2007-36 (2007, after Pub. L. 109-432) + href: https://www.irs.gov/pub/irs-drop/rp-07-36.pdf#page=2 + - title: Rev. Proc. 2007-36 (2008) + href: https://www.irs.gov/pub/irs-drop/rp-07-36.pdf#page=2 + - title: Rev. Proc. 2008-29 (2009) + href: https://www.irs.gov/pub/irs-drop/rp-08-29.pdf#page=1 + - title: Rev. Proc. 2009-29 (2010) + href: https://www.irs.gov/pub/irs-drop/rp-09-29.pdf#page=1 + - title: Rev. Proc. 2010-22 (2011) + href: https://www.irs.gov/pub/irs-drop/rp-10-22.pdf#page=1 + - title: Rev. Proc. 2011-32 (2012) + href: https://www.irs.gov/pub/irs-drop/rp-11-32.pdf#page=1 + - title: Rev. Proc. 2012-26 (2013) + href: https://www.irs.gov/pub/irs-drop/rp-12-26.pdf#page=1 + - title: Rev. Proc. 2013-25 (2014) + href: https://www.irs.gov/pub/irs-drop/rp-13-25.pdf#page=1 + - title: Rev. Proc. 2014-30 (2015) + href: https://www.irs.gov/pub/irs-drop/rp-14-30.pdf#page=1 + - title: Rev. Proc. 2015-30 (2016) + href: https://www.irs.gov/pub/irs-drop/rp-15-30.pdf#page=1 + - title: Rev. Proc. 2016-28 (2017) + href: https://www.irs.gov/pub/irs-drop/rp-16-28.pdf#page=1 + - title: Rev. Proc. 2017-37 (2018) + href: https://www.irs.gov/pub/irs-drop/rp-17-37.pdf#page=1 + - title: Rev. Proc. 2018-30 (2019) + href: https://www.irs.gov/pub/irs-drop/rp-18-30.pdf#page=1 + - title: Rev. Proc. 2019-25 (2020) + href: https://www.irs.gov/irb/2019-22_IRB#REV-PROC-2019-25 + - title: Rev. Proc. 2020-32 (2021) + href: https://www.irs.gov/pub/irs-drop/rp-20-32.pdf#page=1 + - title: Rev. Proc. 2021-25 (2022) + href: https://www.irs.gov/pub/irs-drop/rp-21-25.pdf#page=1 + - title: Rev. Proc. 2022-24 (2023) + href: https://www.irs.gov/pub/irs-drop/rp-22-24.pdf#page=1 + - title: Rev. Proc. 2023-23 (2024) + href: https://www.irs.gov/pub/irs-drop/rp-23-23.pdf#page=2 + - title: Rev. Proc. 2024-25 (2025) + href: https://www.irs.gov/pub/irs-drop/rp-24-25.pdf#page=1 + - title: Rev. Proc. 2025-19 (2026) + href: https://www.irs.gov/pub/irs-drop/rp-25-19.pdf#page=2 + - title: Rev. Proc. 2026-24 (2027) + href: https://www.irs.gov/pub/irs-drop/rp-26-24.pdf#page=2 diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 23ec45e9904..c57b2344949 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -210,6 +210,121 @@ def extend_educator_expense_cap(parameters: ParameterNode, end_year: int) -> Non ) +def get_average_for_12_months_ending_march(cpi: Parameter, year: int) -> float: + """Average a monthly price index over the 12 months ending March 31. + + Covers April of ``year - 1`` through March of ``year``: the calendar-year + index for the HSA amounts, which 26 U.S.C. 223(g)(1) obtains by applying + 1(f)(4) "by substituting 'March 31' for 'August 31'". Observed months + are read as published; unobserved months are interpolated toward the + index's calendar-year projection points by + ``get_projected_cpi_u_for_month``, so a window that straddles the last + observation is neither held flat nor read from a point centred months + away from it. + """ + end = instant(f"{year}-03-01") + return ( + sum( + get_projected_cpi_u_for_month(cpi, end.offset(-month, MONTH)) + for month in range(MONTHS_IN_YEAR) + ) + / MONTHS_IN_YEAR + ) + + +# 26 U.S.C. 223(b)(2) dollar amounts by coverage (parameter name, amount) and +# the base year 223(g)(1)(B)(i) substitutes for calendar year 2016. +HSA_CONTRIBUTION_LIMIT_STATUTORY_BASES = ( + ("self_only", 2_250), # 223(b)(2)(A) + ("family", 4_500), # 223(b)(2)(B) +) +HSA_CONTRIBUTION_LIMIT_BASE_YEAR = 1997 + + +def get_hsa_cola_denominator(parameters: ParameterNode) -> float: + """Denominator of the 26 U.S.C. 223(g)(1) cost-of-living adjustment. + + The CPI for 1997, the base year 223(g)(1)(B)(i) substitutes for calendar + year 2016, averaged over the 12 months ending March 31 as 223(g)(1) + applies 1(f)(4), times the 1(f)(3)(B) ratio of the C-CPI-U to the CPI for + calendar year 2016. That ratio keeps its 12 months ending August 31, as + for every other 1(f)(3) amount; see ``get_hsa_cola``. + """ + cpi = parameters.gov.bls.cpi + ratio_2016 = get_average_for_12_months_ending_august( + cpi.c_cpi_u, 2016 + ) / get_average_for_12_months_ending_august(cpi.cpi_u, 2016) + return ( + get_average_for_12_months_ending_march( + cpi.cpi_u, HSA_CONTRIBUTION_LIMIT_BASE_YEAR + ) + * ratio_2016 + ) + + +def get_hsa_cola( + parameters: ParameterNode, + tax_year: int, + denominator: Optional[float] = None, +) -> float: + """The 26 U.S.C. 223(g)(1) cost-of-living adjustment for ``tax_year``. + + The 1(f)(3) adjustment with calendar year 1997 in place of 2016, and with + 1(f)(4) applied by substituting March 31 for August 31: the percentage + (if any) by which the C-CPI-U for the calendar year before ``tax_year``, + averaged over the 12 months ending March 31, exceeds + ``get_hsa_cola_denominator``. These windows reproduce every self-only and + family limit the IRS published for 2019 through 2027, and Rev. Proc. + 2018-18's limits for 2018, from the BLS series in ``gov.bls.cpi`` + (``tests/core/test_hsa_contribution_limits.py``). Taking the 2016 ratio + over the 12 months ending March 31 as well misses 3 of the 18 values for + 2019 to 2027. ``denominator`` lets a caller computing many years pass it + once. + """ + if denominator is None: + denominator = get_hsa_cola_denominator(parameters) + chained = get_average_for_12_months_ending_march( + parameters.gov.bls.cpi.c_cpi_u, tax_year - 1 + ) + return max(chained / denominator - 1, 0) + + +def extend_hsa_contribution_limits(parameters: ParameterNode, end_year: int) -> None: + """Project the HSA contribution limits for self-only and family coverage. + + 26 U.S.C. 223(g) increases the 223(b)(2) amounts ($2,250 self-only and + $4,500 family) by the cost-of-living adjustment in ``get_hsa_cola`` and + rounds each increase to the nearest multiple of $50. Each year is + computed from the statutory base rather than chained from the last + rounded value; IRS values encoded in the YAML take precedence for their + own years. + """ + ROUNDING_INTERVAL = 50 + limit = parameters.gov.irs.gross_income.health_savings_account_contributions.limit + denominator = get_hsa_cola_denominator(parameters) + colas = {} + for name, base in HSA_CONTRIBUTION_LIMIT_STATUTORY_BASES: + parameter = getattr(limit, name) + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in parameter.values_list + ) + for year in range(first_projected_year, end_year + 1): + if year not in colas: + colas[year] = get_hsa_cola(parameters, year, denominator) + # Nearest multiple of $50; an exact half rounds up. + increase = ( + math.floor(base * colas[year] / ROUNDING_INTERVAL + 0.5) + * ROUNDING_INTERVAL + ) + parameter.update( + period=f"year:{year}-01-01:1", value=float(base + increase) + ) + parameter.update( + start=instant(f"{end_year}-01-01"), + value=parameter(f"{end_year}-01-01"), + ) + + def extend_or_ctc_parameters(parameters: ParameterNode, end_year: int) -> None: """Project the Oregon Kids' Credit amount and phase-out start. @@ -915,6 +1030,12 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # after the Chained CPI-U extension above. extend_educator_expense_cap(parameters, end_year=END_YEAR) + # The HSA contribution limits follow 26 U.S.C. 223(g)'s chained CPI-U + # schedule (12 months ending March 31), computed from the statutory + # $2,250 and $4,500 and the 1997 base year. Must run after the Chained + # CPI-U and CPI-U extensions above so projected windows are available. + extend_hsa_contribution_limits(parameters, end_year=END_YEAR) + # NYC income tax elimination credit thresholds follow the Tax Law # § 1310(h)(1)(B)(iii) schedule (CPI-U for the 12 months ending August 31 # over the 2024 index), computed from the statutory 2025 amounts. Must run diff --git a/policyengine_us/tests/core/test_hsa_contribution_limits.py b/policyengine_us/tests/core/test_hsa_contribution_limits.py new file mode 100644 index 00000000000..d561c34c3cc --- /dev/null +++ b/policyengine_us/tests/core/test_hsa_contribution_limits.py @@ -0,0 +1,175 @@ +"""The HSA contribution limits in 26 U.S.C. 223(b)(2) and their 223(g) +indexation. + +``health_savings_account_payroll_contributions`` uprates by the family limit, +so these tests check the series an input follows as well as the law. + +Invariants: + +1. Transcription: the parameters hold the self-only and family limits the IRS + published for each year. +2. Differential: an independent implementation of 223(g), written here from + the monthly BLS values in ``gov.bls.cpi``, reproduces every published limit + for 2019 to 2027. It also reproduces the 2018 limits of Rev. Proc. 2018-18; + the family parameter instead holds the $6,900 that Rev. Proc. 2018-27 let + taxpayers use (an intended difference). The model's ``get_hsa_cola`` + agrees with the independent implementation in every year whose window is + observed. +3. Extension rule: every year after the last IRS publication equals the + statutory base plus the adjustment applied to it, rounded to the nearest + $50. +4. Bounds and order (Hypothesis): both limits are positive multiples of $50, + never fall from one year to a later one, and the family limit is within + $50 of twice the self-only limit. The March-window price index the + projection reads is positive and rises in every projected year. +""" + +import math + +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.parameters.uprating_extensions import ( + HSA_CONTRIBUTION_LIMIT_STATUTORY_BASES, + get_average_for_12_months_ending_march, + get_hsa_cola, +) +from policyengine_us.system import system + +LIMIT = "gov.irs.gross_income.health_savings_account_contributions.limit" +END_YEAR = 2100 +BASES = dict(HSA_CONTRIBUTION_LIMIT_STATUTORY_BASES) + +# (self-only, family) limits from the IRS publication for each year: Notice +# 2004-2 (2004), then the Rev. Proc. for each year cited in the parameter files +# (Rev. Proc. 2004-71 for 2005 through Rev. Proc. 2026-24 for 2027). +PUBLISHED = { + 2004: (2_600, 5_150), + 2005: (2_650, 5_250), + 2006: (2_700, 5_450), + 2007: (2_850, 5_650), + 2008: (2_900, 5_800), + 2009: (3_000, 5_950), + 2010: (3_050, 6_150), + 2011: (3_050, 6_150), + 2012: (3_100, 6_250), + 2013: (3_250, 6_450), + 2014: (3_300, 6_550), + 2015: (3_350, 6_650), + 2016: (3_350, 6_750), + 2017: (3_400, 6_750), + # Rev. Proc. 2018-27 restored the $6,900 family limit that Rev. Proc. + # 2018-18 had cut to $6,850. + 2018: (3_450, 6_900), + 2019: (3_500, 7_000), + 2020: (3_550, 7_100), + 2021: (3_600, 7_200), + 2022: (3_650, 7_300), + 2023: (3_850, 7_750), + 2024: (4_150, 8_300), + 2025: (4_300, 8_550), + 2026: (4_400, 8_750), + 2027: (4_500, 9_000), +} +LAST_PUBLISHED_YEAR = max(PUBLISHED) +# Years the C-CPI-U-based 223(g) (after Pub. L. 115-97) governs. +CHAINED_YEARS = range(2018, LAST_PUBLISHED_YEAR + 1) +REV_PROC_2018_18 = (3_450, 6_850) + + +def _limit(name, year): + return get_parameter(system.parameters, f"{LIMIT}.{name}")(f"{year}-01-01") + + +def _monthly(path, year, month): + return get_parameter(system.parameters, path)(f"{year}-{month:02d}-01") + + +def _window_average(path, year, last_month): + """Average of the 12 monthly values ending in ``last_month`` of ``year``. + + BLS did not publish October 2025; the parameter carries the September + value at that instant, which is what this reads. + """ + months = [ + (year - (month <= 0), (month - 1) % 12 + 1) + for month in range(last_month - 11, last_month + 1) + ] + return sum(_monthly(path, y, m) for y, m in months) / 12 + + +def _independent_cola(tax_year): + cpi_u, c_cpi_u = "gov.bls.cpi.cpi_u", "gov.bls.cpi.c_cpi_u" + ratio_2016 = _window_average(c_cpi_u, 2016, 8) / _window_average(cpi_u, 2016, 8) + denominator = _window_average(cpi_u, 1997, 3) * ratio_2016 + return _window_average(c_cpi_u, tax_year - 1, 3) / denominator - 1 + + +def _statutory_limit(base, cola): + return base + math.floor(base * cola / 50 + 0.5) * 50 + + +@pytest.mark.parametrize("year", sorted(PUBLISHED)) +def test_parameters_hold_the_published_limits(year): + assert (_limit("self_only", year), _limit("family", year)) == PUBLISHED[year] + + +@pytest.mark.parametrize("year", CHAINED_YEARS) +def test_independent_223g_reproduces_the_published_limits(year): + cola = _independent_cola(year) + computed = tuple(_statutory_limit(BASES[name], cola) for name in BASES) + expected = REV_PROC_2018_18 if year == 2018 else PUBLISHED[year] + assert computed == expected + + +@pytest.mark.parametrize("year", CHAINED_YEARS) +def test_model_cola_matches_the_independent_implementation(year): + assert get_hsa_cola(system.parameters, year) == pytest.approx( + _independent_cola(year), rel=1e-12 + ) + + +@given(st.integers(LAST_PUBLISHED_YEAR + 1, END_YEAR)) +def test_projected_limits_follow_the_statute(year): + cola = get_hsa_cola(system.parameters, year) + for name, base in BASES.items(): + assert _limit(name, year) == _statutory_limit(base, cola) + + +@given( + st.sampled_from(sorted(BASES)), + st.integers(min(PUBLISHED), END_YEAR), + st.integers(0, END_YEAR - min(PUBLISHED)), +) +def test_limits_are_positive_multiples_of_50_that_never_fall(name, year, gap): + later = min(year + gap, END_YEAR) + assert 0 < _limit(name, year) <= _limit(name, later) + assert _limit(name, year) % 50 == 0 + + +@given(st.integers(min(PUBLISHED), END_YEAR)) +def test_family_limit_is_within_50_of_twice_the_self_only_limit(year): + assert abs(_limit("family", year) - 2 * _limit("self_only", year)) <= 50 + + +@given( + # The C-CPI-U starts in December 1999, so its first full window ends in + # March 2001. + st.sampled_from([("cpi_u", 1997), ("c_cpi_u", 2001)]).flatmap( + lambda series: st.tuples(st.just(series[0]), st.integers(series[1], END_YEAR)) + ) +) +def test_march_window_index_is_positive(series_and_year): + series, year = series_and_year + cpi = getattr(system.parameters.gov.bls.cpi, series) + assert get_average_for_12_months_ending_march(cpi, year) > 0 + + +@given(st.integers(LAST_PUBLISHED_YEAR, END_YEAR - 1)) +def test_march_window_index_rises_in_every_projected_year(year): + c_cpi_u = system.parameters.gov.bls.cpi.c_cpi_u + assert get_average_for_12_months_ending_march( + c_cpi_u, year + ) < get_average_for_12_months_ending_march(c_cpi_u, year + 1) diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index fb7d4e7aec6..7f4cd810e55 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -1,7 +1,8 @@ """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 and CMS premiums -per enrollee for employer plan premiums.""" +expenses and debts, CMS per-capita spending for health costs, CMS premiums +per enrollee for employer plan premiums and the statutory limit for capped +contributions.""" from pathlib import Path @@ -22,6 +23,9 @@ ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" COLA = "gov.ssa.uprating" +HSA_FAMILY_LIMIT = ( + "gov.irs.gross_income.health_savings_account_contributions.limit.family" +) EXPECTED = { # Pay, and amounts set as a share of pay. @@ -66,6 +70,9 @@ # share. "employer_sponsored_insurance_premiums": ESI, "pre_tax_health_insurance_premiums": ESI, + # Contributions under a cap that 26 U.S.C. 223(g) indexes to chained + # CPI-U. + "health_savings_account_payroll_contributions": HSA_FAMILY_LIMIT, # 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/microsimulation/test_hsa_payroll_contribution_uprating.py b/policyengine_us/tests/microsimulation/test_hsa_payroll_contribution_uprating.py new file mode 100644 index 00000000000..8f48846293d --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_hsa_payroll_contribution_uprating.py @@ -0,0 +1,141 @@ +"""The engine ages HSA payroll contributions by the 26 U.S.C. 223(b)(2) +family contribution limit, in a simulation and in dataset extension. + +``tests/core/test_hsa_contribution_limits.py`` checks the limits themselves +and ``tests/core/test_uprating_series.py`` that the input names the family +limit. This file checks what a parameter read cannot: that holder uprating in +a ``Simulation`` and ``extend_single_year_dataset`` resolve the path +undivided by population (``tools/per_capita_uprating.py`` divides only +national totals) and apply it to this input. + +Invariants: + +1. Uprating: a contribution entered for 2024 equals that amount times the + ratio of the family limit in any later year, so uprating is linear in the + amount (Hypothesis, one vectorized simulation per example). The uprated + amount is what the payroll exclusion subtracts from wages. +2. Bound: a contribution at the 2024 family limit equals the family limit in + every later year, and one at the 2024 self-only limit stays within $25 of + the self-only limit, so uprating never carries a contribution at the cap + past it. +3. Dataset extension: each person's amount follows the limit ratio, and the + weighted total grows by the limit 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 + +HSA = "health_savings_account_payroll_contributions" +LIMIT = "gov.irs.gross_income.health_savings_account_contributions.limit" +FAMILY, SELF_ONLY = f"{LIMIT}.family", f"{LIMIT}.self_only" +BASE_YEAR, END_YEAR = 2024, 2100 + + +def _value(path, year): + return get_parameter(system.parameters, path)(f"{year}-01-01") + + +def _ratio(path, year): + return _value(path, year) / _value(path, BASE_YEAR) + + +def _simulation(amounts): + people = { + f"person_{i}": {"age": {BASE_YEAR: 40}, HSA: {BASE_YEAR: amount}} + for i, amount in enumerate(amounts) + } + return Simulation( + situation={ + "people": people, + "households": {"household": {"members": list(people)}}, + } + ) + + +# 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.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_contributions_by_the_family_limit(amounts, years): + simulation = _simulation(amounts) + for year in years: + values = simulation.calculate(HSA, year) + excluded = simulation.calculate("pre_tax_contributions", year) + for value, exclusion, amount in zip(values, excluded, amounts): + assert value == pytest.approx( + amount * _ratio(FAMILY, year), rel=1e-6, abs=0.01 + ) + # HSA contributions are the only pre-tax payroll deduction here. + assert exclusion == pytest.approx(value, rel=1e-6, abs=0.01) + + +@pytest.mark.parametrize("year", [2025, 2026, 2027, 2030, 2034, 2050, 2100]) +def test_contributions_at_the_limit_stay_at_the_limit(year): + family, self_only = _value(FAMILY, BASE_YEAR), _value(SELF_ONLY, BASE_YEAR) + values = _simulation([family, self_only]).calculate(HSA, year) + assert values[0] == pytest.approx(_value(FAMILY, year), abs=0.01) + assert abs(values[1] - _value(SELF_ONLY, year)) <= 25 + 0.01 + + +@lru_cache(maxsize=1) +def _extended(): + person = pd.DataFrame( + { + "person_id": [1, 2, 3, 4], + "person_household_id": [1, 1, 2, 3], + HSA: [8_300.0, 0.0, 4_150.0, 1_200.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, 2027, 2030, 2034, 2036]) +def test_dataset_extension_uprates_by_the_family_limit(year): + base, later = _extended().datasets[BASE_YEAR], _extended().datasets[year] + for before, after in zip(base.person[HSA], later.person[HSA]): + assert after == pytest.approx(before * _ratio(FAMILY, year), rel=1e-9) + assert _weighted_total(later, HSA) / _weighted_total(base, HSA) == pytest.approx( + _ratio(FAMILY, year) * _ratio(POPULATION_PATH, year), rel=1e-9 + ) diff --git a/policyengine_us/variables/household/expense/health/health_savings_account_contributions.py b/policyengine_us/variables/household/expense/health/health_savings_account_contributions.py index 2aa0cbd5d66..78f8d7eb2d2 100644 --- a/policyengine_us/variables/household/expense/health/health_savings_account_contributions.py +++ b/policyengine_us/variables/household/expense/health/health_savings_account_contributions.py @@ -9,4 +9,8 @@ class health_savings_account_payroll_contributions(Variable): label = "Health Savings Account payroll contributions" unit = USD definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + # Contributions are capped by 26 U.S.C. 223(b)(2), which 223(g) indexes + # to chained CPI-U. Following the family limit keeps an amount at the cap + # at the cap in later years; the family amount's $50 rounding is the + # smaller share, so its growth is closest to the 223(g) adjustment. + uprating = "gov.irs.gross_income.health_savings_account_contributions.limit.family"