From 4dc0451ae5cfff7648b64a46df4ba42da2ebc1e4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 02:29:35 -0400 Subject: [PATCH 1/2] Fix Scottish top-rate taxable income threshold --- changelog.d/2130.fixed.md | 1 + docs/engineering/gift-aid.md | 17 +- .../hmrc/income_tax/rates/scotland/rates.yaml | 19 ++- .../tax/income_tax/scottish_rates.yaml | 103 +++++++++++- policyengine_uk/tests/test_gift_aid.py | 21 +-- .../tests/test_scottish_income_tax_rates.py | 17 +- .../tests/test_scottish_top_rate.py | 156 ++++++++++++++++++ 7 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 changelog.d/2130.fixed.md create mode 100644 policyengine_uk/tests/test_scottish_top_rate.py diff --git a/changelog.d/2130.fixed.md b/changelog.d/2130.fixed.md new file mode 100644 index 0000000000..e6fc40c3c3 --- /dev/null +++ b/changelog.d/2130.fixed.md @@ -0,0 +1 @@ +- Corrected the Scottish 48% top-rate threshold to £125,140 of taxable income for 2024–25, 2025–26 and 2026–27, accounting for the fully withdrawn personal allowance. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 20ac92abe7..2437e5efa2 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -13,6 +13,15 @@ non-savings income, it also increases the Scottish basic-rate upper limit and every limit above it; the starter-rate upper limit remains unchanged. Savings and dividends use the extended UK limits for Scottish taxpayers too. +The Scottish top-rate limit is £125,140 of taxable income in each of +[2024-25](https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-12252), +[2025-26](https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531) +and [2026-27](https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844). +The personal allowance is fully withdrawn at that income, so £12,570 is not +subtracted from this limit, as the [Scottish Government's explanatory +note](https://www.gov.scot/publications/scottish-rate-resolution-2026-2027-draft-motion-explanatory-note/pages/3/) +explains. Gift Aid extends this taxable-income limit by the gross gift. + The model extends limits where band amounts are calculated. For variables that use a complete rate scale, `calc_income_tax_with_gift_aid` in `policyengine_uk/utils/income_tax.py` applies an equivalent correction to the @@ -53,14 +62,8 @@ including s. 12B(8) for Scottish taxpayers, with s. 414(2)(b)). ## Existing limitations -Zero-gift calculations retain their existing behavior. Several inherited -limitations remain relevant when interpreting mixed-income results: +Several limitations remain relevant when interpreting mixed-income results: -- The Scottish top-rate threshold parameter is £112,570 of income after - allowances for 2024 onwards. The [HMRC rates and allowances - table](https://www.gov.uk/government/publications/rates-and-allowances-income-tax/income-tax-rates-and-allowances-current-and-past) - gives £125,140 after allowances. The Gift Aid correction extends the model's - existing threshold and does not repair it. - Adjusted net income still omits other adjustments in s. 58, including the deduction for pension contributions relieved at source in Step 3. - Gifts are assumed to qualify. The model does not charge the donor for a diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/scotland/rates.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/scotland/rates.yaml index 97795115c3..c16c530ba1 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/scotland/rates.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/scotland/rates.yaml @@ -73,12 +73,11 @@ brackets: - threshold: values: 2017-04-06: null - # Top rate threshold: £125,140 total income = £112,570 above PA - # (£125,140 - £12,570 personal allowance) - 2024-04-06: 112_570 - 2025-04-06: 112_570 - # Scottish Budget 2025-26 announced freeze through 2026-27 - 2026-04-06: 112_570 + # The personal allowance is fully withdrawn at £125,140, so the + # taxable-income top limit equals total income: do not subtract PA. + 2024-04-06: 125_140 + 2025-04-06: 125_140 + 2026-04-06: 125_140 metadata: uprating: gov.economic_assumptions.indices.obr.consumer_price_index rate: @@ -89,6 +88,14 @@ metadata: rate_unit: /1 label: Scottish Income Tax rates reference: + - title: Scottish Rate Resolution 2024-25 (S6M-12252), paragraphs (a)-(f) + href: https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-12252 + - title: Scottish Rate Resolution 2025-26 (S6M-16531), paragraphs (a)-(f) + href: https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531 + - title: Scottish Rate Resolution 2026-27 (S6M-20844), paragraphs (a)-(f) + href: https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844 + - title: Scottish Rate Resolution 2026-27 explanatory note - personal allowance and top limit + href: https://www.gov.scot/publications/scottish-rate-resolution-2026-2027-draft-motion-explanatory-note/pages/3/ - title: GOV.UK href: https://www.gov.uk/government/publications/rates-and-allowances-income-tax/income-tax-rates-and-allowances-current-and-past - title: Scottish Budget 2025-26 - Higher rate threshold freeze diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index e462ddb27f..973b2c42b2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -11,9 +11,9 @@ period: 2025 absolute_error_margin: 0 input: - # Top rate (48%) starts at 125,140 total income (112,570 above PA) - # At this income, PA is fully tapered, so taxable income = 125,140 - # This exceeds the top rate threshold of 112,570 + # The personal allowance is fully withdrawn at £125,140; the taxable + # top-rate limit is £125,140 too. tax_band classification is separate + # from the amount of income charged at 48% (see #2350). adjusted_net_income: 125140 region: SCOTLAND output: @@ -25,10 +25,105 @@ absolute_error_margin: 0 input: # 100,000 income is above advanced rate threshold (62,430 above PA) - # but below top rate threshold (112,570 above PA) + # but below the £125,140 taxable top-rate threshold # PA at 100,000 is 12,570 (not yet tapered), taxable = 87,430 adjusted_net_income: 100000 region: SCOTLAND output: # TaxBand enum uses ADDITIONAL for both Advanced (45%) and Top (48%) rates tax_band: ADDITIONAL + +- name: Scottish 2024 pay of 120000 has no tax at the 48 percent rate + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 120000 + region: SCOTLAND + output: + # S6M-12252: 45% continues to £125,140 of taxable income. + # https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-12252 + earned_taxable_income: 117430 + earned_income_tax: 44278.31 + income_tax: 44278.31 + +- name: Scottish 2024 pay of 150000 has exactly 24860 taxed at 48 percent + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150000 + region: SCOTLAND + output: + # Lower bands: £47,747.81; top band: £24,860 * 48% = £11,932.80. + earned_taxable_income: 150000 + earned_income_tax: 59680.61 + income_tax: 59680.61 + +- name: Scottish 2025 pay of 120000 has no tax at the 48 percent rate + period: 2025 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 120000 + region: SCOTLAND + output: + # S6M-16531: 45% continues to £125,140 of taxable income. + # https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531 + earned_taxable_income: 117430 + earned_income_tax: 44263.80 + income_tax: 44263.80 + +- name: Scottish 2025 pay of 150000 has exactly 24860 taxed at 48 percent + period: 2025 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150000 + region: SCOTLAND + output: + # Lower bands: £47,733.30; top band: £24,860 * 48% = £11,932.80. + earned_taxable_income: 150000 + earned_income_tax: 59666.10 + income_tax: 59666.10 + +- name: Scottish 2026 pay of 120000 has no tax at the 48 percent rate + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 120000 + region: SCOTLAND + output: + # S6M-20844: 45% continues to £125,140 of taxable income. + # https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844 + earned_taxable_income: 117430 + earned_income_tax: 44232.05 + income_tax: 44232.05 + +- name: Scottish 2026 pay of 150000 has exactly 24860 taxed at 48 percent + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150000 + region: SCOTLAND + output: + # Lower bands: £47,701.55; top band: £24,860 * 48% = £11,932.80. + earned_taxable_income: 150000 + earned_income_tax: 59634.35 + income_tax: 59634.35 + +- name: Scottish 2026 pay of 130000 has exactly 4860 taxed at 48 percent + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 130000 + region: SCOTLAND + output: + # S6M-20844: lower bands £47,701.55 plus £4,860 * 48% = £2,332.80. + # Companion to Axiom scot_e130k_top_rate_pa_tapered. + earned_taxable_income: 130000 + earned_income_tax: 50034.35 + income_tax: 50034.35 diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 27d62c9d4c..2ee5dfa4b5 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -8,9 +8,8 @@ https://www.legislation.gov.uk/ukpga/2007/3/section/415 https://www.legislation.gov.uk/ukpga/2007/3/section/58 -The differential integrates the model's existing rate schedules independently. -It preserves their unrelated baseline policy choices, including the Scottish -top-rate threshold, to isolate the effect of extending limits for Gift Aid. +The differential integrates the model's rate schedules independently to +isolate the effect of extending limits for Gift Aid. """ import numpy as np @@ -267,15 +266,17 @@ def test_explicit_zero_gift_matches_omitted_input(sim, omitted_sim, drawn): assert before.tobytes() == after.tobytes(), variable -def test_zero_gift_preserves_main_outputs_bit_for_bit(): - """Freeze main's intermediate and final outputs, including mixed income. +def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): + """Freeze intermediate and final outputs, including mixed income. Frozen from main at 3381500f4 (after #2352 put savings and dividends above property income, and the Scottish personal savings allowance - moved to the UK bands). Re-freeze from main when main moves these. + moved to the UK bands). The Scottish £150,000 case uses the corrected + £125,140 top-rate limit from S6M-20844 (#2130), giving £59,634.35: + https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844 Gift Aid's zero-input compatibility also applies to inherited baseline - choices about property stacking, the Scottish schedule and taxpayer bands. + choices about property stacking and taxpayer bands. Exact float32 expectations catch changes hidden by a tax-level tolerance. """ rows = [ @@ -319,7 +320,7 @@ def test_zero_gift_preserves_main_outputs_bit_for_bit(): 28_032, 3451.070068359375, 13182.0498046875, - 60011.44921875, + 59634.3515625, 3451.070068359375, 0, 31407.05078125, @@ -336,7 +337,7 @@ def test_zero_gift_preserves_main_outputs_bit_for_bit(): 28_032, 3451.070068359375, 13182.0498046875, - 60011.44921875, + 59634.3515625, 14043.3203125, 3307.25, 31407.05078125, @@ -408,7 +409,7 @@ def test_explicit_adjusted_net_income_preserves_tax_base(ani, net_gift): "scottish", ), "Scotland, six rates": ( - [0, 3_967, 16_956, 31_092, 62_430, 112_570], + [0, 3_967, 16_956, 31_092, 62_430, 125_140], [0.19, 0.20, 0.21, 0.42, 0.45, 0.48], "scottish", ), diff --git a/policyengine_uk/tests/test_scottish_income_tax_rates.py b/policyengine_uk/tests/test_scottish_income_tax_rates.py index e38571466a..9404c2c2b9 100644 --- a/policyengine_uk/tests/test_scottish_income_tax_rates.py +++ b/policyengine_uk/tests/test_scottish_income_tax_rates.py @@ -1,7 +1,18 @@ +import pytest + from policyengine_uk.system import system -def test_scottish_income_tax_thresholds_updated_for_2026(): - rates = system.parameters.gov.hmrc.income_tax.rates.scotland.rates("2026-04-06") +@pytest.mark.parametrize( + "year, thresholds", + [ + # Scottish Rate Resolutions S6M-12252, S6M-16531 and S6M-20844. + (2024, [0, 2306, 13991, 31092, 62430, 125140]), + (2025, [0, 2827, 14921, 31092, 62430, 125140]), + (2026, [0, 3967, 16956, 31092, 62430, 125140]), + ], +) +def test_scottish_income_tax_thresholds_match_the_resolution(year, thresholds): + rates = system.parameters.gov.hmrc.income_tax.rates.scotland.rates(f"{year}-04-06") - assert rates.thresholds == [0.0, 3967, 16956, 31092, 62430, 112570] + assert rates.thresholds == thresholds diff --git a/policyengine_uk/tests/test_scottish_top_rate.py b/policyengine_uk/tests/test_scottish_top_rate.py new file mode 100644 index 0000000000..bedf7041e7 --- /dev/null +++ b/policyengine_uk/tests/test_scottish_top_rate.py @@ -0,0 +1,156 @@ +"""Scottish top-rate income starts at £125,140 of taxable income (#2130). + +Each year's taxable-income bands come independently from its Scottish Rate +Resolution, rather than the model's parameter tree: + +* 2024-25: https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-12252 +* 2025-26: https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531 +* 2026-27: https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844 + +The £12,570 personal allowance is withdrawn by £1 for every £2 of adjusted +net income over £100,000 (ITA 2007 s.35), reaching zero at £125,140. It +therefore cannot be subtracted from the £125,140 top-rate limit. The lower +limits, including £62,430 at the advanced rate, are reached with an intact +allowance. These MOCK situations contain employment income only: no survey +data, pension relief, Gift Aid or tax-band classification is involved. +""" + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +THRESHOLDS = { + 2024: (0, 2_306, 13_991, 31_092, 62_430, 125_140), + 2025: (0, 2_827, 14_921, 31_092, 62_430, 125_140), + 2026: (0, 3_967, 16_956, 31_092, 62_430, 125_140), +} +RATES = (0.19, 0.20, 0.21, 0.42, 0.45, 0.48) +TOP_LIMIT = 125_140 + + +@pytest.fixture(scope="module") +def template(): + # The examples never modify the tax-benefit system. Reuse it while each + # clone rebuilds its own small population and calculation caches. + return Simulation(situation={"people": {"template": {"age": {2026: 45}}}}) + + +def simulate(template, year, wages): + people = { + f"p{i}": { + "age": {year: 45}, + "employment_income": {year: float(wage)}, + "employee_pension_contributions": {year: 0}, + "employer_pension_contributions": {year: 0}, + } + for i, wage in enumerate(wages) + } + simulation = template.clone(clone_tax_benefit_system=False) + simulation.build_from_situation( + { + "people": people, + "benunits": {f"b_{p}": {"members": [p]} for p in people}, + "households": { + f"h_{p}": {"members": [p], "region": {year: "SCOTLAND"}} for p in people + }, + } + ) + simulation.input_variables = simulation.get_known_variables() + simulation.calculated_periods = [] + return { + name: np.asarray(simulation.calculate(name, year), dtype=float) + for name in ("earned_taxable_income", "earned_income_tax", "income_tax") + } + + +def taxable_pay(wage): + # s.35 rounds a non-whole-pound allowance up to the next whole pound. + allowance = max(0, np.ceil(12_570 - max(0, wage - 100_000) / 2)) + return max(0, wage - allowance) + + +def statutory_tax(year, taxable): + limits = THRESHOLDS[year] + return sum( + rate * max(0, min(taxable, upper) - lower) + for lower, upper, rate in zip(limits, (*limits[1:], float("inf")), RATES) + ) + + +def assert_statutory_tax(year, wages, values): + taxable = [taxable_pay(wage) for wage in wages] + np.testing.assert_allclose(values["earned_taxable_income"], taxable, atol=0.01) + expected = [statutory_tax(year, amount) for amount in taxable] + for name in ("earned_income_tax", "income_tax"): + np.testing.assert_allclose(values[name], expected, rtol=0, atol=0.02) + + +@pytest.mark.parametrize("year", THRESHOLDS) +def test_scottish_120000_of_pay_has_no_top_rate_tax(template, year): + values = simulate(template, year, [120_000]) + assert_statutory_tax(year, [120_000], values) + # All taxable pay is below the top limit; subtracting its statutory tax + # leaves no tax charge attributable to the 48% band. + lower_band_tax = statutory_tax(year, taxable_pay(120_000)) + assert values["earned_income_tax"][0] - lower_band_tax == pytest.approx(0, abs=0.01) + + +@pytest.mark.parametrize("year", THRESHOLDS) +def test_scottish_150000_of_pay_has_24860_taxed_at_48_percent(template, year): + values = simulate(template, year, [150_000]) + assert_statutory_tax(year, [150_000], values) + # Once all lower bands are full, the remaining tax is exactly the + # liability on £150,000 - £125,140 at 48% (allowing float32 rounding). + lower_band_tax = statutory_tax(year, TOP_LIMIT) + top_rate_income = (values["earned_income_tax"][0] - lower_band_tax) / 0.48 + assert top_rate_income == pytest.approx(24_860, abs=0.02) + + +@pytest.mark.parametrize("year", THRESHOLDS) +def test_scottish_band_boundaries_are_continuous(template, year): + # Lower bands retain the full PA. At the top limit it is fully withdrawn. + boundaries = [limit + 12_570 for limit in THRESHOLDS[year][:-1]] + [TOP_LIMIT] + wages = [wage + offset for wage in boundaries for offset in (-1, 0, 1)] + values = simulate(template, year, wages) + assert_statutory_tax(year, wages, values) + tax = values["income_tax"].reshape(-1, 3) + taxable = values["earned_taxable_income"].reshape(-1, 3) + for band, (tax_row, taxable_row) in enumerate(zip(tax, taxable)): + left_rate = RATES[band - 1] if band else 0 + right_rate = RATES[band] + assert tax_row[1] - tax_row[0] == pytest.approx( + left_rate * (taxable_row[1] - taxable_row[0]), abs=0.01 + ) + assert tax_row[2] - tax_row[1] == pytest.approx( + right_rate * (taxable_row[2] - taxable_row[1]), abs=0.01 + ) + + +@settings(max_examples=25, deadline=None, derandomize=True, database=None) +@given( + year=st.sampled_from(tuple(THRESHOLDS)), + high_pay=st.integers(TOP_LIMIT, 500_000), + increase=st.integers(1, 100_000), + below_top_pay=st.integers(116_761, TOP_LIMIT - 1), +) +@example(year=2024, high_pay=125_140, increase=1, below_top_pay=120_000) +@example(year=2025, high_pay=150_000, increase=1_000, below_top_pay=125_139) +@example(year=2026, high_pay=125_140, increase=1, below_top_pay=120_000) +def test_scottish_tax_is_monotone_and_48_percent_only_above_top_limit( + template, year, high_pay, increase, below_top_pay +): + wages = [high_pay, high_pay + increase, below_top_pay, below_top_pay + 1] + values = simulate(template, year, wages) + assert_statutory_tax(year, wages, values) + tax = values["income_tax"] + taxable = values["earned_taxable_income"] + assert tax[1] >= tax[0] + assert tax[1] - tax[0] == pytest.approx(0.48 * increase, abs=0.05) + assert taxable[0] >= TOP_LIMIT + assert taxable[3] <= TOP_LIMIT + # Below £125,140 of taxable income, this interval remains at 45%, + # including pay of £120,000 that main incorrectly charged at 48%. + assert tax[3] - tax[2] == pytest.approx(0.45 * (taxable[3] - taxable[2]), abs=0.01) From 54901fd6475cfe40781ef95f536d13b7c48e506a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 02:35:07 -0400 Subject: [PATCH 2/2] Update Scottish Gift Aid liability regression --- .../tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index 39897c8930..e6384e97ee 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -201,8 +201,11 @@ employment_income: [150_000, 150_000] gift_aid: [0, 800] output: + # S6M-20844: £47,701.55 in the lower bands, plus £24,860 at 48%. + # https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844 + # s.414 raises the £125,140 top limit to £126,140 for the donor. # Rate steps 1%, 21%, 3% and 3% on £1,000 gross give £280 relief. - income_tax: [60_011.45, 59_731.45] + income_tax: [59_634.35, 59_354.35] - name: A large Gift Aid gift can cross several Scottish bands period: 2026