diff --git a/changelog.d/ca-prop3-2030-expiry.added.md b/changelog.d/ca-prop3-2030-expiry.added.md new file mode 100644 index 00000000000..f6cf7129c2c --- /dev/null +++ b/changelog.d/ca-prop3-2030-expiry.added.md @@ -0,0 +1 @@ +Add California Proposition 3 (2026) as a contributed reform, toggled by gov.contrib.states.ca.prop3.in_effect, that keeps the rates above 9.3% from 2031 onward. diff --git a/changelog.d/ca-prop3-2030-expiry.fixed.md b/changelog.d/ca-prop3-2030-expiry.fixed.md new file mode 100644 index 00000000000..6a248088adf --- /dev/null +++ b/changelog.d/ca-prop3-2030-expiry.fixed.md @@ -0,0 +1 @@ +Encode the expiry of California's 10.3%, 11.3% and 12.3% income tax rates after tax year 2030 in all five filing-status schedules (Cal. Const. art. XIII, § 36(f)(2)-(3)). diff --git a/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml b/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml new file mode 100644 index 00000000000..2df610071a2 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml @@ -0,0 +1,16 @@ +description: California applies the Proposition 3 (2026) permanent extension of the income tax rates above 9.3% in years from 2031 onward in which this is true. +values: + 0000-01-01: false +metadata: + unit: bool + period: year + label: California Proposition 3 (2026) in effect + reference: + - title: California Proposition 3 (2026), Official Voter Information Guide, Legislative Analyst's analysis, Figure 1 + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3.pdf#page=2 + - title: California Proposition 3 (2026), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) (strikes "and before January 1, 2031,") + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3-text-proposed-laws.pdf#page=6 + - title: California Proposition 3 (2026), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2)(D), (f)(3) and (f)(3)(D) + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3-text-proposed-laws.pdf#page=7 + - title: Cal. Const. art. XIII, § 36(f)(2)-(3) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml index d30bf8988a7..e52f46c6afc 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml @@ -70,6 +70,9 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 510_303 @@ -81,6 +84,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 850_503 @@ -92,6 +96,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +115,9 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - - title: Cal. Rev. & Tax. Code § 17041 + - title: Cal. Const. art. XIII, § 36(f)(3) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(3) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(c)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml index db176716116..1717969d2a2 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml @@ -70,6 +70,9 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 750_442 @@ -81,6 +84,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 1_250_738 @@ -92,6 +96,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +115,11 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - - title: Cal. Rev. & Tax. Code § 17041 + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. + - title: Cal. Rev. & Tax. Code § 17045 (joint and surviving spouse tax is twice the tax on one-half of taxable income) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17045.&lawCode=RTC diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml index c5e1ae6154b..310316ae8af 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml @@ -70,6 +70,9 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 375_221 @@ -81,6 +84,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 625_369 @@ -92,6 +96,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +115,9 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - - title: Cal. Rev. & Tax. Code § 17041 + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml index 356ddee849b..38695aeff12 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml @@ -70,6 +70,9 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 375_221 @@ -81,6 +84,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 625_369 @@ -92,6 +96,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +115,9 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - - title: Cal. Rev. & Tax. Code § 17041 + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml index 1553538f47b..6c8255ac033 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml @@ -70,6 +70,9 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 750_442 @@ -81,6 +84,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 1_250_738 @@ -92,6 +96,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +115,11 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - - title: Cal. Rev. & Tax. Code § 17041 + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. + - title: Cal. Rev. & Tax. Code § 17045 (joint and surviving spouse tax is twice the tax on one-half of taxable income) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17045.&lawCode=RTC diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 46b0754e164..61fcd3050de 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -290,6 +290,9 @@ from .states.ca.ab2591 import ( create_ca_ab2591_reform, ) +from .states.ca.prop3 import ( + create_ca_prop3_reform, +) from .states.ga.sb520 import ( create_ga_sb520_reform, ) @@ -562,6 +565,7 @@ def create_structural_reforms_from_parameters(parameters, period): parameters, period ) ca_ab2591 = create_ca_ab2591_reform(parameters, period) + ca_prop3 = create_ca_prop3_reform(parameters, period) ga_sb520 = create_ga_sb520_reform(parameters, period) hi_hb2306_cdcc = create_hi_hb2306_cdcc_reform(parameters, period) nc_eitc = create_nc_eitc_reform(parameters, period) @@ -712,6 +716,7 @@ def create_structural_reforms_from_parameters(parameters, period): tx_rebate, al_hb527_overtime_deduction, ca_ab2591, + ca_prop3, ga_sb520, hi_hb2306_cdcc, nc_eitc, diff --git a/policyengine_us/reforms/states/ca/__init__.py b/policyengine_us/reforms/states/ca/__init__.py index 3eb47243a2e..3fa280d85cc 100644 --- a/policyengine_us/reforms/states/ca/__init__.py +++ b/policyengine_us/reforms/states/ca/__init__.py @@ -1 +1,2 @@ from .ab2591 import create_ca_ab2591_reform +from .prop3 import create_ca_prop3_reform diff --git a/policyengine_us/reforms/states/ca/prop3/__init__.py b/policyengine_us/reforms/states/ca/prop3/__init__.py new file mode 100644 index 00000000000..cd68b87c7ee --- /dev/null +++ b/policyengine_us/reforms/states/ca/prop3/__init__.py @@ -0,0 +1,5 @@ +from .ca_prop3_reform import ( + create_ca_prop3, + create_ca_prop3_reform, + ca_prop3, +) diff --git a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py new file mode 100644 index 00000000000..b5219999a66 --- /dev/null +++ b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py @@ -0,0 +1,134 @@ +import functools + +from policyengine_us.model_api import * +from policyengine_core.parameters import load_parameter_file +from policyengine_core.periods import instant + +# Cal. Const. art. XIII, § 36(f)(2) (single, separate, and through RTC § 17045 +# joint and surviving spouse) and § 36(f)(3) (head of household) apply the +# rates above 9.3% to taxable years before January 1, 2031. Proposition 3 +# (2026), SEC. 4, strikes that end date, so the rates in effect for 2030 keep +# applying from 2031 onward. +LAST_PRE_SUNSET_YEAR = instant("2030-01-01") +SUNSET = instant("2031-01-01") +HORIZON_END = instant("2100-12-31") +FILING_STATUSES = [ + "single", + "joint", + "separate", + "surviving_spouse", + "head_of_household", +] + + +def _active_windows(in_effect) -> list: + """Return the (start, stop) instants within SUNSET..HORIZON_END where the + dated values of ``in_effect`` are true. + + ``values_list`` holds the user's dated overrides in reverse chronological + order; each value applies from its instant until the next later one. + """ + windows = [] + later_start = None + for value_at_instant in in_effect.values_list: + start = instant(value_at_instant.instant_str) + if value_at_instant.value: + window_start = max(start, SUNSET) + window_stop = HORIZON_END + if later_start is not None: + window_stop = min(later_start.offset(-1, "day"), HORIZON_END) + if window_start <= window_stop: + windows.append((window_start, window_stop)) + later_start = start + return sorted(windows) + + +@functools.lru_cache(maxsize=None) +def _enacted_schedule(file_path: str): + # The schedule as enacted, read from the file the rates were loaded from, + # so the reform can tell the statutory sunset apart from a user's edit. + # The YAML must remain readable at this path, including after unpickling + # the system on another machine. + return load_parameter_file(file_path) + + +def _constant_intervals(start, stop, *parameters) -> list: + """Split start..stop into the (start, stop) intervals on which every + parameter keeps one value.""" + cuts = {start} + for parameter in parameters: + for value_at_instant in parameter.values_list: + cut = instant(value_at_instant.instant_str) + if start < cut <= stop: + cuts.add(cut) + cuts = sorted(cuts) + stops = [cut.offset(-1, "day") for cut in cuts[1:]] + [stop] + return list(zip(cuts, stops)) + + +def _restore_pre_sunset_rates(rates, enacted_rates: dict, windows: list) -> None: + """Within each window, give each bracket whose enacted rate the sunset + changes its 2030 rate, wherever the enacted post-sunset rate applies. + + ``enacted_rates`` maps each filing status to its enacted schedule. Brackets + the sunset leaves unchanged keep any edit, including one bounded to 2030, + and so do intervals whose rate differs from the enacted rate, which a user + has set explicitly. Restored intervals no longer match the enacted rate, so + applying the reform again changes nothing. + """ + updates = [] + for status in FILING_STATUSES: + brackets = getattr(rates, status).brackets + for bracket, enacted_bracket in zip(brackets, enacted_rates[status].brackets): + rate = bracket.rate + enacted_rate = enacted_bracket.rate + if enacted_rate(SUNSET) == enacted_rate(LAST_PRE_SUNSET_YEAR): + continue + # The 2030 rate, including any rate the user set for 2030. + pre_sunset_rate = rate(LAST_PRE_SUNSET_YEAR) + for window_start, window_stop in windows: + for start, stop in _constant_intervals( + window_start, window_stop, rate, enacted_rate + ): + if rate(start) == enacted_rate(start): + updates.append((rate, start, stop, pre_sunset_rate)) + # Read every rate before writing any. + for rate, start, stop, value in updates: + rate.update(start=start, stop=stop, value=value) + + +def create_ca_prop3(respect_in_effect: bool = False) -> Reform: + def modify_parameters(parameters): + if respect_in_effect: + windows = _active_windows(parameters.gov.contrib.states.ca.prop3.in_effect) + else: + windows = [(SUNSET, HORIZON_END)] + rates = parameters.gov.states.ca.tax.income.rates + enacted_rates = { + status: _enacted_schedule(getattr(rates, status).file_path) + for status in FILING_STATUSES + } + _restore_pre_sunset_rates(rates, enacted_rates, windows) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +def create_ca_prop3_reform(parameters, period, bypass: bool = False): + # The bypass instance, used by YAML `reforms:` tests, restores the rates + # for 2031-2100 regardless of in_effect. Otherwise the reform applies only + # in the years from 2031 onward in which in_effect is true. + if bypass: + return create_ca_prop3() + + if _active_windows(parameters.gov.contrib.states.ca.prop3.in_effect): + return create_ca_prop3(respect_in_effect=True) + else: + return None + + +ca_prop3 = create_ca_prop3_reform(None, None, bypass=True) 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 181d814a34b..7674c753f4c 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 @@ -53,6 +53,24 @@ output: ca_amt: 0 +- name: The 2031 rate expiry exposes AMT for a single filer with $1 million of taxable income + period: 2031 + absolute_error_margin: 1 + input: + state_code: CA + filing_status: SINGLE + ca_taxable_income: 1_000_000 + ca_amti: 1_400_000 + ca_amt_exemption: 0 + ca_non_refundable_credits: 0 + output: + # Regular tax falls to 88,942.90 once the rates above 9.3% expire (see + # ca_income_tax_before_credits.yaml). Tentative minimum tax is .07 x 1,400,000 = 98,000, + # so AMT is 98,000 - 88,942.90 = 9,057.10 and total tax is 98,000. + ca_income_tax_before_credits: 88_942.90 + ca_amt: 9_057.10 + ca_income_tax_before_refundable_credits: 98_000 + - 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. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml index 56b2bf12ede..c53e439957b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml @@ -11,7 +11,7 @@ ca_income_tax_before_credits: 464.61 -- name: Single filing, income in 3rd tax bracket +- name: Head of household with $1 million in 2021 reaches the 12.3% bracket period: 2021 absolute_error_margin: 1 input: @@ -21,3 +21,89 @@ output: # 82_046.3 + (12.30% of (1_000_000 - 850_503)) = 100_434.131 ca_income_tax_before_credits: 100_434.131 + +# 2030-2031 cases: thresholds are the FTB 2025 schedules uprated by gov.states.ca.cpi, +# a factor of f2030 = 193 / 172.797 for 2030 and f2031 = 196.85 / 172.797 for 2031. +# Single and separate tax at the 9.3% threshold is 3,201.97f (head of household 3,616.45f). +# Joint and surviving spouse tax is twice the single tax on half the income (RTC § 17045). +# The higher rates apply before January 1, 2031 (Cal. Const. art. XIII, § 36(f)(2)-(3)). +# These are also the reform-off controls for contrib/states/ca/prop3/ca_prop3.yaml. + +- name: Single filer with $1 million in 2030 pays the 10.3%, 11.3% and 12.3% rates + period: 2030 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # 3,201.97f + .093 x (1,000,000 - 72,724f) + .01 x (1,000,000 - 371,479f) + # + .01 x (1,000,000 - 445,771f) + .01 x (1,000,000 - 742,953f), f = f2030 + ca_income_tax_before_credits: 101_596.07 + +- name: Single filer with $1 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # 3,201.97f + .093 x (1,000,000 - 72,724f), f = f2031; the 9.3% threshold is 82,847.04. + ca_income_tax_before_credits: 88_942.90 + +- name: Joint filers with $2 million in 2031 pay 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 2_000_000 + filing_status: JOINT + state_code: CA + output: + # 2 x 88,942.90, the single tax on $1 million. + ca_income_tax_before_credits: 177_885.81 + +- name: Separate filer with $1 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_000_000 + filing_status: SEPARATE + state_code: CA + output: + # Same schedule as single: 3,201.97f + .093 x (1,000,000 - 72,724f), f = f2031. + ca_income_tax_before_credits: 88_942.90 + +- name: Surviving spouse with $2 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 2_000_000 + filing_status: SURVIVING_SPOUSE + state_code: CA + output: + # Treated as a joint return (RTC § 17045): 2 x 88,942.90. + ca_income_tax_before_credits: 177_885.81 + +- name: Head of household with $1.5 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_500_000 + filing_status: HEAD_OF_HOUSEHOLD + state_code: CA + output: + # 3,616.45f + .093 x (1,500,000 - 98,990f), f = f2031. + ca_income_tax_before_credits: 133_132.32 + +- name: Head of household with $1.5 million in 2030 pays the 10.3%, 11.3% and 12.3% rates + period: 2030 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_500_000 + filing_status: HEAD_OF_HOUSEHOLD + state_code: CA + output: + # 3,616.45f + .093 x (1,500,000 - 98,990f) + .01 x (1,500,000 - 505,208f) + # + .01 x (1,500,000 - 606,251f) + .01 x (1,500,000 - 1,010,417f), f = f2030 + ca_income_tax_before_credits: 154_557.25 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml index 09fac0645db..25e778aa531 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml @@ -33,6 +33,20 @@ # (50000 - 1000) + 2000 + 5000 = 56000 ca_income_tax_before_refundable_credits: 56_000 +- name: The 1% mental health services tax stacks on the 2031 regular tax + period: 2031 + absolute_error_margin: 1 + input: + state_code: CA + filing_status: SINGLE + ca_taxable_income: 2_000_000 + ca_non_refundable_credits: 0 + ca_amt: 0 + output: + # Regular tax 3,201.97f + .093 x (2,000,000 - 72,724f) = 181,942.90 (f = 196.85 / 172.797), + # plus mental health services tax .01 x (2,000,000 - 1,000,000) = 10,000. + ca_income_tax_before_refundable_credits: 191_942.90 + # PolicyBench scenario_099, snapshot 20260501, tax year 2026. Exact inputs # copied from scenario_json via the PolicyBench household converter. # FTB Schedule CA (540), Section C, line 11, restores the spouse's $337.50 diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml b/policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml new file mode 100644 index 00000000000..324fa8ecc43 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml @@ -0,0 +1,157 @@ +# California Proposition 3 (2026) keeps the 10.3%, 11.3% and 12.3% rates after 2030. +# +# The `reforms:` key applies the bypass instance of the reform (`ca_prop3`), which restores +# the rates for 2031-2100 whatever gov.contrib.states.ca.prop3.in_effect says, so these cases +# do not set the toggle. The app path, which reads the dated toggle, is tested in +# test_ca_prop3_reform.py. The reform-off controls are the 2030 and 2031 cases in +# baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml. +# +# Expected values apply the 2031 thresholds by hand: the FTB 2025 schedules uprated by +# f = 196.85 / 172.797. The baseline single and separate 2031 tax is +# 3,201.97f + .093 x (income - 72,724f); Prop 3 adds .01 x (income - t) above each of +# t = 371,479f, 445,771f and 742,953f. Head of household uses 3,616.45f, 98,990f and +# t = 505,208f, 606,251f and 1,010,417f. Joint and surviving spouse tax is twice the single +# tax on half the income (RTC § 17045). + +- name: Prop 3 keeps the higher rates for a single filer with $1 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 2031 is 88,942.90; Prop 3 adds 1% on 84,633.30, 2% on 338,549.15 and 3% on 153,629.41. + ca_income_tax_before_credits: 101_169.10 + +- name: Prop 3 keeps the higher rates for a separate filer with $1 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SEPARATE + state_code: CA + output: + # Same schedule as single: 88,942.90 + 12,226.20. + ca_income_tax_before_credits: 101_169.10 + +- name: Prop 3 keeps the higher rates for joint filers with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 2_000_000 + filing_status: JOINT + state_code: CA + output: + # 2 x 101,169.10, the single tax on $1 million. + ca_income_tax_before_credits: 202_338.20 + +- name: Prop 3 keeps the higher rates for a surviving spouse with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 2_000_000 + filing_status: SURVIVING_SPOUSE + state_code: CA + output: + # Treated as a joint return: 2 x 101,169.10. + ca_income_tax_before_credits: 202_338.20 + +- name: Prop 3 keeps the higher rates for a head of household with $1.5 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_500_000 + filing_status: HEAD_OF_HOUSEHOLD + state_code: CA + output: + # Baseline 133,132.32 + .01 x (1,500,000 - 575,531.95) + .01 x (1,500,000 - 690,639.94) + # + .01 x (1,500,000 - 1,151,065.04) = 153,959.95. + ca_income_tax_before_credits: 153_959.95 + +- name: Prop 3 applies only the 10.3% rate inside the first surcharge bracket in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 450_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 37,792.90 + .01 x (450,000 - 423,188.14) = 38,061.02. + ca_income_tax_before_credits: 38_061.02 + +- name: Prop 3 applies the 10.3% and 11.3% rates inside the second surcharge bracket in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 600_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 51,742.90 + .01 x (600,000 - 423,188.14) + .01 x (600,000 - 507,821.44) = 54,432.81. + ca_income_tax_before_credits: 54_432.81 + +- name: Prop 3 does not change tax below the 10.3% threshold in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 300_000 + filing_status: SINGLE + state_code: CA + output: + # 3,201.97f + .093 x (300,000 - 72,724f), the same as the baseline. + ca_income_tax_before_credits: 23_842.90 + +- name: Prop 3 does not change 2030 tax, when the higher rates already apply + period: 2030 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # Same as the 2030 baseline case. + ca_income_tax_before_credits: 101_596.07 + +- name: Prop 3 removes the AMT that the 2031 rate expiry exposes + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + ca_amti: 1_400_000 + ca_amt_exemption: 0 + ca_non_refundable_credits: 0 + output: + # Tentative minimum tax .07 x 1,400,000 = 98,000 is below the 101,169.10 regular tax, + # so AMT is 0 (the baseline AMT is 9,057.10; see alternative_minimum_tax/ca_amt.yaml). + ca_income_tax_before_credits: 101_169.10 + ca_amt: 0 + ca_income_tax_before_refundable_credits: 101_169.10 + +- name: Prop 3 regular tax and the 1% mental health services tax stack for a single filer with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 2_000_000 + filing_status: SINGLE + state_code: CA + ca_non_refundable_credits: 0 + ca_amt: 0 + output: + # Regular tax 181,942.90 + .01 x (2,000,000 - 423,188.14) + .01 x (2,000,000 - 507,821.44) + # + .01 x (2,000,000 - 846,370.59) = 224,169.10, plus MHST .01 x 1,000,000 = 10,000. + ca_income_tax_before_credits: 224_169.10 + ca_income_tax_before_refundable_credits: 234_169.10 diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py new file mode 100644 index 00000000000..690337e58af --- /dev/null +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py @@ -0,0 +1,479 @@ +"""Tests for the 2031 expiry of California's rates above 9.3% and for the +Proposition 3 (2026) contributed reform that would repeal it. + +Cal. Const. art. XIII, § 36(f)(2) (single and separate, and through RTC +§ 17045 joint and surviving spouse) and § 36(f)(3) (head of household) apply +the 10.3%, 11.3% and 12.3% rates to taxable years before January 1, 2031. +Proposition 3, SEC. 4, strikes that end date. The YAML cases check dollar +amounts, which depend on CPI projections; these tests check properties that +do not: + +- the rate ladder of every filing status in 2030, 2031, 2035 and 2100, under + the baseline and under the reform; +- for any year and income, the reform changes no threshold and no rate at or + below 9.3%, adds exactly 1% above each of the three surcharge thresholds + from 2031 and nothing before, and keeps separate tax equal to single tax and + joint and surviving spouse tax at twice the single tax on half the income; +- the app path (``Reform.from_dict`` on ``in_effect``) activates the reform + from a 2031 start and applies it only in the years ``in_effect`` is true; +- the dated windows the reform reads from ``in_effect`` cover exactly the + instants from 2031 through 2100 at which ``in_effect`` is true; +- the reform carries each bracket's 2030 rate forward, including a rate the + user set for 2030; and +- whatever the user edits, the reform changes only the brackets the enacted + sunset changes, only inside the active windows and only where the enacted + post-sunset rate still applies, so it keeps explicit future rates, does not + extend edits to other brackets, gives the same rates on both construction + paths and changes nothing when applied again. +""" + +import functools + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings, strategies as st +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us.reforms.states.ca.prop3 import ( + ca_prop3, + create_ca_prop3_reform, +) +from policyengine_us.reforms.states.ca.prop3.ca_prop3_reform import ( + LAST_PRE_SUNSET_YEAR, + SUNSET, + _active_windows, + _enacted_schedule, + _restore_pre_sunset_rates, +) +from policyengine_us.system import system as baseline_system + +IN_EFFECT = "gov.contrib.states.ca.prop3.in_effect" +STATUSES = ["single", "separate", "joint", "surviving_spouse", "head_of_household"] +# RTC § 17041(a)(1) and (c)(1) rates, then the § 36(f)(2)-(3) surcharge rates. +BASE_RATES = [0.01, 0.02, 0.04, 0.06, 0.08, 0.093] +PRE_SUNSET_RATES = BASE_RATES + [0.103, 0.113, 0.123] +POST_SUNSET_RATES = BASE_RATES + [0.093, 0.093, 0.093] +SURCHARGE_BRACKETS = [6, 7, 8] +INCOME = 1_000_000 + +# Input generation can be descheduled on shared hosts; keep every example and +# assertion without treating that wall-clock delay as a test failure. +# Deterministic examples keep CI failures reproducible. +PROPERTY_SETTINGS = dict( + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@functools.lru_cache(maxsize=None) +def _bypass_system(): + # The instance YAML `reforms:` tests use; it ignores in_effect. + return CountryTaxBenefitSystem(reform=ca_prop3) + + +def _schedule(system, status, year): + return getattr( + system.parameters(f"{year}-01-01").gov.states.ca.tax.income.rates, status + ) + + +def _rates(system, status, year): + return list(_schedule(system, status, year).rates) + + +def _tax(system, status, year, income): + return float(_schedule(system, status, year).calc(np.array([float(income)]))[0]) + + +def _situation(years): + years = [str(year) for year in years] + return { + "people": {"head": {"age": {year: 45 for year in years}}}, + "tax_units": { + "tax_unit": { + "members": ["head"], + "filing_status": {year: "SINGLE" for year in years}, + "ca_taxable_income": {year: INCOME for year in years}, + } + }, + "households": { + "household": { + "members": ["head"], + "state_code": {year: "CA" for year in years}, + } + }, + } + + +def _app_simulation(in_effect_values, years, other_values=None): + # Mirrors how the app applies the toggle: a dated Reform.from_dict, with the + # default start_instant. + reform = Reform.from_dict( + {IN_EFFECT: in_effect_values, **(other_values or {})}, + country_id="us", + ) + return Simulation(situation=_situation(years), reform=reform) + + +def _assert_active_years(simulation, years, active_years): + for year in years: + # Before 2031 the baseline already applies the higher rates. + keeps_higher_rates = year < 2031 or year in active_years + expected_rates = PRE_SUNSET_RATES if keeps_higher_rates else POST_SUNSET_RATES + expected_system = _bypass_system() if keeps_higher_rates else baseline_system + for status in STATUSES: + assert _rates(simulation.tax_benefit_system, status, year) == ( + pytest.approx(expected_rates) + ), (status, year) + tax = simulation.calculate("ca_income_tax_before_credits", year)[0] + if year >= 2031: + # The reform raises tax on $1 million in every year from 2031, so + # the two references differ and the comparison identifies the + # regime. + assert _tax(_bypass_system(), "single", year, INCOME) > _tax( + baseline_system, "single", year, INCOME + ) + assert tax == pytest.approx( + _tax(expected_system, "single", year, INCOME), abs=0.01 + ), year + + +@pytest.mark.parametrize("status", STATUSES) +def test_ca_prop3_rates_all_statuses_and_years(status): + for year in [2030, 2031, 2035, 2100]: + expected_baseline = PRE_SUNSET_RATES if year < 2031 else POST_SUNSET_RATES + assert _rates(baseline_system, status, year) == pytest.approx( + expected_baseline + ), year + # Proposition 3 keeps the 2030 rates in every year through 2100. + assert _rates(_bypass_system(), status, year) == pytest.approx( + PRE_SUNSET_RATES + ), year + + +@settings(max_examples=300, **PROPERTY_SETTINGS) +@given( + year=st.integers(min_value=2025, max_value=2040), + income=st.floats(min_value=0, max_value=50_000_000, allow_nan=False), +) +def test_ca_prop3_preserves_thresholds_lower_rates_and_joint_scaling(year, income): + reformed_system = _bypass_system() + for status in STATUSES: + baseline = _schedule(baseline_system, status, year) + reformed = _schedule(reformed_system, status, year) + # Proposition 3 changes no threshold and no rate at or below 9.3%. + assert list(reformed.thresholds) == list(baseline.thresholds) + assert list(reformed.rates)[:6] == list(baseline.rates)[:6] + # From 2031 it adds 1% above each surcharge threshold; before, nothing. + thresholds = list(baseline.thresholds) + expected_change = ( + sum(0.01 * max(income - thresholds[i], 0) for i in SURCHARGE_BRACKETS) + if year >= 2031 + else 0 + ) + change = _tax(reformed_system, status, year, income) - _tax( + baseline_system, status, year, income + ) + assert change >= 0 + assert change == pytest.approx(expected_change, rel=1e-9, abs=1e-6) + for system in [baseline_system, reformed_system]: + single = _tax(system, "single", year, income) + # RTC § 17041(a)(1) applies the single schedule to separate returns. + assert _tax(system, "separate", year, income) == pytest.approx( + single, rel=1e-9, abs=1e-6 + ) + # RTC § 17045: joint tax is twice the tax on half the income, and a + # surviving spouse return is treated as a joint return. + for status in ["joint", "surviving_spouse"]: + assert _tax(system, status, year, 2 * income) == pytest.approx( + 2 * single, rel=1e-9, abs=1e-6 + ) + + +def test_ca_prop3_app_activation_from_2031(): + years = [2030, 2031, 2035] + simulation = _app_simulation({"2031-01-01.2100-12-31": True}, years) + _assert_active_years(simulation, years, active_years={2031, 2035}) + assert simulation.calculate("ca_income_tax_before_credits", 2031)[ + 0 + ] == pytest.approx(101_169.10, abs=1) + # With the toggle off, 2031 tax is the baseline. + baseline = Simulation(situation=_situation(years)) + assert baseline.calculate("ca_income_tax_before_credits", 2031)[0] == pytest.approx( + 88_942.90, abs=1 + ) + + +@pytest.mark.parametrize( + "in_effect_values, active_years", + [ + # True only in 2026, before the sunset: nothing to restore. + ({"2026-01-01.2026-12-31": True}, set()), + # A delayed start leaves 2031-2032 at the baseline. + ({"2033-01-01.2100-12-31": True}, {2033, 2034, 2035}), + # True, then false in 2033-2034, then true again. + ( + {"2031-01-01.2032-12-31": True, "2035-01-01.2100-12-31": True}, + {2031, 2032, 2035}, + ), + ], +) +def test_ca_prop3_respects_delayed_and_bounded_activation( + in_effect_values, active_years +): + years = [2030, 2031, 2032, 2033, 2034, 2035] + simulation = _app_simulation(in_effect_values, years) + _assert_active_years(simulation, years, active_years) + reform = create_ca_prop3_reform( + simulation.tax_benefit_system.parameters, "2024-01-01" + ) + assert (reform is None) == (not active_years) + + +def test_ca_prop3_copies_2030_user_rates(): + simulation = _app_simulation( + {"2031-01-01.2100-12-31": True}, + [2030, 2031, 2035], + other_values={ + "gov.states.ca.tax.income.rates.single[6].rate": { + "2030-01-01.2030-12-31": 0.104 + } + }, + ) + rates = simulation.tax_benefit_system.parameters.gov.states.ca.tax.income.rates + for year in [2030, 2031, 2035]: + assert rates.single.brackets[6].rate(f"{year}-01-01") == pytest.approx(0.104) + # Other schedules keep the statutory 10.3%. + assert rates.separate.brackets[6].rate(f"{year}-01-01") == pytest.approx(0.103) + tax_2031 = simulation.calculate("ca_income_tax_before_credits", 2031)[0] + single_2031 = _schedule(_bypass_system(), "single", 2031) + bracket_6_income = single_2031.thresholds[7] - single_2031.thresholds[6] + assert tax_2031 == pytest.approx( + _tax(_bypass_system(), "single", 2031, INCOME) + 0.001 * bracket_6_income, + abs=0.01, + ) + + +def test_ca_prop3_enacted_schedules_match_baseline_rates(): + # The reform reads each enacted schedule from the file its rates were + # loaded from. The parameter pipeline does not change rates, so the file + # and the baseline agree in every year. + rates = baseline_system.parameters.gov.states.ca.tax.income.rates + for status in STATUSES: + schedule = getattr(rates, status) + enacted = _enacted_schedule(schedule.file_path) + assert len(enacted.brackets) == len(schedule.brackets) + for year in range(2021, 2102): + date = f"{year}-01-01" + assert [bracket.rate(date) for bracket in enacted.brackets] == [ + bracket.rate(date) for bracket in schedule.brackets + ], (status, year) + + +def test_ca_prop3_keeps_user_edits_on_both_construction_paths(): + rate = "gov.states.ca.tax.income.rates.{}.rate" + reform = Reform.from_dict( + { + IN_EFFECT: {"2033-01-01.2100-12-31": True}, + # A 2030 surcharge rate is carried into the active years. + rate.format("single[6]"): {"2030-01-01.2030-12-31": 0.104}, + # A 2031-only rate equal to the 2030 rate: the sunset still applies + # in 2032, and the reform removes it from 2033. + rate.format("separate[6]"): {"2031-01-01.2031-12-31": 0.103}, + # A 2030-only edit to a bracket the sunset leaves alone is not + # extended. + rate.format("joint[1]"): {"2030-01-01.2030-12-31": 0.021}, + # Explicit rates inside the active years are kept. + rate.format("head_of_household[7]"): {"2033-01-01.2033-12-31": 0.12}, + rate.format("head_of_household[8]"): {"2035-01-01.2100-12-31": 0.15}, + }, + country_id="us", + ) + expected = { + ("single", 6): { + 2030: 0.104, + 2031: 0.093, + 2032: 0.093, + 2033: 0.104, + 2036: 0.104, + }, + ("separate", 6): { + 2030: 0.103, + 2031: 0.103, + 2032: 0.093, + 2033: 0.103, + 2036: 0.103, + }, + ("joint", 1): {2030: 0.021, 2031: 0.02, 2033: 0.02, 2036: 0.02}, + ("head_of_household", 7): {2032: 0.093, 2033: 0.12, 2034: 0.113, 2036: 0.113}, + ("head_of_household", 8): { + 2032: 0.093, + 2033: 0.123, + 2034: 0.123, + 2035: 0.15, + 2036: 0.15, + }, + ("surviving_spouse", 8): {2030: 0.123, 2032: 0.093, 2033: 0.123, 2036: 0.123}, + } + simulation = Simulation(situation=_situation([2031, 2033]), reform=reform) + # A bare system applies the structural reform once; Simulation applies it + # again after replaying the user reform. + for system in [ + CountryTaxBenefitSystem(reform=reform), + simulation.tax_benefit_system, + ]: + rates = system.parameters.gov.states.ca.tax.income.rates + for (status, index), by_year in expected.items(): + bracket_rate = getattr(rates, status).brackets[index].rate + for year, value in by_year.items(): + assert bracket_rate(f"{year}-01-01") == pytest.approx(value), ( + status, + index, + year, + ) + # 2031 tax is the baseline; 2033 tax uses the carried 10.4% rate. + assert simulation.calculate("ca_income_tax_before_credits", 2031)[ + 0 + ] == pytest.approx(_tax(baseline_system, "single", 2031, INCOME), abs=0.01) + single_2033 = _schedule(_bypass_system(), "single", 2033) + bracket_6_income = single_2033.thresholds[7] - single_2033.thresholds[6] + assert simulation.calculate("ca_income_tax_before_credits", 2033)[ + 0 + ] == pytest.approx( + _tax(_bypass_system(), "single", 2033, INCOME) + 0.001 * bracket_6_income, + abs=0.01, + ) + + +def _around(date): + return {date.offset(-1, "day"), date, date.offset(1, "day")} + + +def _toggle(updates): + """Build an in_effect parameter from dated updates; also return the dates + at which its value can change.""" + node = ParameterNode("prop3", data={"in_effect": {"values": {"0000-01-01": False}}}) + in_effect = node.in_effect + boundaries = set() + for start_year, start_month, length, value in updates: + start = instant(f"{start_year}-{start_month:02d}-01") + stop = None if length is None else instant(f"{start_year + length}-12-31") + in_effect.update(start=start, stop=stop, value=value) + boundaries |= _around(start) + if stop is not None: + boundaries |= _around(stop) + return in_effect, boundaries + + +# Dated toggle schedules: (start year, start month, end year or None, value). +UPDATES = st.lists( + st.tuples( + st.integers(min_value=2020, max_value=2105), + st.sampled_from([1, 7]), + st.one_of(st.none(), st.integers(min_value=0, max_value=30)), + st.booleans(), + ), + max_size=5, +) + + +@settings(max_examples=300, **PROPERTY_SETTINGS) +@given(updates=UPDATES) +def test_ca_prop3_windows_match_dated_in_effect(updates): + in_effect, boundaries = _toggle(updates) + windows = _active_windows(in_effect) + # Windows are sorted, disjoint and inside 2031-01-01 through 2100-12-31. + for (start, stop), (next_start, _) in zip(windows, windows[1:]): + assert stop < next_start + for start, stop in windows: + assert instant("2031-01-01") <= start <= stop <= instant("2100-12-31") + # They cover exactly the dates from 2031 through 2100 at which the toggle + # is true, checked on the 1st of January and July of each year and on + # each side of every date at which the toggle or the horizon changes. + probes = { + instant(f"{year}-{month:02d}-01") + for year in range(2025, 2106) + for month in [1, 7] + } + probes |= boundaries | _around(SUNSET) | _around(instant("2101-01-01")) + for date in sorted(probes): + covered = any(start <= date <= stop for start, stop in windows) + expected = SUNSET <= date <= instant("2100-12-31") and bool(in_effect(date)) + assert covered == expected, (date, windows) + + +# User edits to bracket rates: (status, bracket, start year, length in years +# or None for open-ended, rate). +EDITS = st.lists( + st.tuples( + st.sampled_from(STATUSES), + st.integers(min_value=0, max_value=8), + st.integers(min_value=2028, max_value=2040), + st.one_of(st.none(), st.integers(min_value=0, max_value=5)), + st.sampled_from([0.02, 0.021, 0.093, 0.103, 0.104, 0.113, 0.15]), + ), + max_size=6, +) + + +@settings(max_examples=150, **PROPERTY_SETTINGS) +@given(edits=EDITS, updates=UPDATES) +def test_ca_prop3_restores_only_the_enacted_sunset(edits, updates): + baseline_rates = baseline_system.parameters.gov.states.ca.tax.income.rates + # A detached copy, so the edits stay out of the shared baseline tree. + rates = baseline_rates.clone() + rates.parent = None + probes = _around(LAST_PRE_SUNSET_YEAR) | _around(SUNSET) + probes |= _around(instant("2101-01-01")) + for status, index, start_year, length, value in edits: + start = instant(f"{start_year}-01-01") + stop = None if length is None else instant(f"{start_year + length}-12-31") + getattr(rates, status).brackets[index].rate.update( + start=start, stop=stop, value=value + ) + probes |= _around(start) + if stop is not None: + probes |= _around(stop) + edited = rates.clone() + in_effect, boundaries = _toggle(updates) + windows = _active_windows(in_effect) + probes |= boundaries + enacted = { + status: _enacted_schedule(getattr(rates, status).file_path) + for status in STATUSES + } + _restore_pre_sunset_rates(rates, enacted, windows) + restored = rates.clone() + _restore_pre_sunset_rates(rates, enacted, windows) + # Every interval on which the edits, windows and enacted rates are constant + # contains a probe, since the probes include both sides of every change. + probes = sorted(date for date in probes if date >= LAST_PRE_SUNSET_YEAR) + for status in STATUSES: + for index in range(9): + baseline_rate = getattr(baseline_rates, status).brackets[index].rate + edited_rate = getattr(edited, status).brackets[index].rate + changed_by_sunset = baseline_rate(SUNSET) != baseline_rate( + LAST_PRE_SUNSET_YEAR + ) + assert changed_by_sunset == (index in SURCHARGE_BRACKETS) + for date in probes: + in_window = any(start <= date <= stop for start, stop in windows) + if ( + changed_by_sunset + and in_window + and edited_rate(date) == baseline_rate(date) + ): + expected = edited_rate(LAST_PRE_SUNSET_YEAR) + else: + expected = edited_rate(date) + assert getattr(restored, status).brackets[index].rate(date) == ( + expected + ), (status, index, date) + # Applying the reform again changes nothing. + assert getattr(rates, status).brackets[index].rate(date) == ( + expected + ), (status, index, date)