From 411dc0243dbbadefd56d46389b4b2b6270591eda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:53:14 +0000 Subject: [PATCH 01/19] Deduct only the tax the Council Tax Reduction schemes allow Scotland's working-age scheme deducts income tax and National Insurance on earnings only; the pensioner schemes of England, Wales and Scotland deduct no tax on income they do not take into account (savings, dividends, the Scottish Carer Supplement). A new council_tax_reduction_tax_deduction variable holds the deduction. Fixes #2090 for those schemes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FLTn86KDyF7Gco35i --- changelog.d/2090.fixed.md | 1 + ...l_tax_reduction_carer_support_payment.yaml | 22 +- .../council_tax_reduction_tax_deduction.yaml | 148 +++++++++++++ ...l_tax_reduction_carer_income_properties.py | 80 ++++--- ..._tax_reduction_tax_deduction_properties.py | 197 ++++++++++++++++++ ...council_tax_reduction_applicable_income.py | 16 +- .../council_tax_reduction_tax_deduction.py | 187 +++++++++++++++++ 7 files changed, 594 insertions(+), 57 deletions(-) create mode 100644 changelog.d/2090.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md new file mode 100644 index 0000000000..ed9a67373c --- /dev/null +++ b/changelog.d/2090.fixed.md @@ -0,0 +1 @@ +Council Tax Reduction income now deducts only the income tax and National Insurance each scheme allows: tax on earnings in Scotland's working-age scheme, and no tax on savings, dividends or the Scottish Carer Supplement in the pensioner schemes of England, Wales and Scotland. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index f18404306e..637e7844ad 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -12,9 +12,10 @@ # the supplement's tax does not reach Council Tax Reduction either: at working # age SSI 2021/249 deducts tax only from earnings, and at pension age SSI # 2012/319 reg 31(12) disregards the tax payable on income taken into -# account, which the supplement is not. The model deducts all income tax, so -# the taxable supplement reaches the model's Council Tax Reduction income -# through income tax and in no other way. +# account, which the supplement is not. The model deducts only the tax each +# scheme allows (council_tax_reduction_tax_deduction), with the supplement's +# tax taken as the highest part of non-savings income, so the supplement does +# not reach the model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, @@ -248,7 +249,7 @@ # jumps to 2,000 when the carer moves to Carer Support Payment in 2025. council_tax_reduction: 2_000 - 0.2 * (7_000 + 81.90 * 52 - (90.50 + 45.60) * 52) -- name: Pension-age Council Tax Reduction counts Carer Support Payment, and the supplement only through tax +- name: Pension-age Council Tax Reduction counts Carer Support Payment and deducts no tax on the supplement period: 2026 absolute_error_margin: 0.01 input: @@ -275,13 +276,18 @@ # first 3,967 at the Scottish starter rate of 19% and 2,566.80 at 20%. # The supplement adds 608.40 x 20% = 121.68 of that tax. income_tax: 3_967 * 0.19 + 2_566.80 * 0.20 - # 14,000 + 4,495.40 - 1,267.09: the supplement is not added. - council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_267.09 + # SSI 2012/319 reg 31(12) disregards only the tax on income taken into + # account. The supplement is not, so its tax, as the highest part of + # income, is not deducted: 14,000 + 4,495.40 - 12,570 = 5,925.40 taxable, + # 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so 1,145.41. + council_tax_reduction_tax_deduction: 3_967 * 0.19 + 1_958.40 * 0.20 + # 14,000 + 4,495.40 - 1,145.41: neither the supplement nor its tax counts. + council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_145.41 council_tax_reduction_applicable_amount: (256 + 48.15) * 52 # Income is above the minimum guarantee of (238 + 48.15) x 52 = 14,879.80. guarantee_credit: 0 - # 2,000 - 0.2 x (17,228.31 - 15,815.80) - council_tax_reduction: 1_717.50 + # 2,000 - 0.2 x (17,349.99 - 15,815.80) + council_tax_reduction: 1_693.16 - name: A pension-age couple's Council Tax Reduction counts the partner's Carer Support Payment period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml new file mode 100644 index 0000000000..17c68f8f51 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -0,0 +1,148 @@ +# Income tax and National Insurance deducted from Council Tax Reduction income. +# - Scotland, working age: the Council Tax Reduction (Scotland) Regulations +# 2021 (SSI 2021/249) reg 50(3)(a) reads "Earnings do not include— (a) any +# amount deducted from them by way of income tax or primary Class 1 +# contributions under the 1992 Act"; regs 49(6)(b) and 51(2) do the same, +# and the unearned income rules (regs 57 to 65) deduct no tax. So only tax +# and National Insurance on earnings come off. +# - Pension age: SSI 2012/319 reg 31(12), "In the case of any income taken +# into account for the purpose of calculating a person's income, any amount +# payable by way of tax must be disregarded"; SI 2012/2885 Sch 1 para 17(13) +# and WSI 2013/3029 Sch 1 para 11(13) read the same. So tax on income not +# taken into account (savings interest, dividends, the Scottish Carer +# Supplement) does not come off. +# +# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable income: +# 19% to 3,967, 20% to 16,956, 21% to 31,092. UK basic rate 20% to 37,700. +# Class 1 primary contributions 8% above 241.73 a week (12,569.96 a year). +# Each family is a single adult with no Pension Credit or Universal Credit. + +- name: Scotland's working-age scheme deducts tax and National Insurance on earnings only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Tax on earnings, the earnings as the lowest part of income: + # 20,000 - 12,570 = 7,430 taxable; 3,967 x 19% = 753.73 and + # 3,463 x 20% = 692.60, so 1,446.33. Class 1: (20,000 - 12,569.96) x 8% = + # 594.40. The tax on the private pension is not deducted. + council_tax_reduction_tax_deduction: 2_040.73 + # 20,000 + 10,000 - 2,040.73. + council_tax_reduction_applicable_income: 27_959.27 + +- name: England's pensioner scheme deducts no tax on savings interest + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_308 + savings_interest_income: 6_559 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The scheme counts the pension, not the interest. Tax on the pension: + # (20,308 - 12,570) x 20% = 1,547.60. + council_tax_reduction_tax_deduction: 1_547.60 + # 20,308 - 1,547.60. + council_tax_reduction_applicable_income: 18_760.40 + +- name: Wales's pensioner scheme deducts no tax on dividends + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + dividend_income: 10_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + region: WALES + council_tax: 2_000 + output: + # The scheme counts the pension, not the dividends. Tax on the pension: + # (20,000 - 12,570) x 20% = 1,486. + council_tax_reduction_tax_deduction: 1_486 + # 20,000 - 1,486. + council_tax_reduction_applicable_income: 18_514 + +- name: Scotland's pensioner scheme deducts no tax on the Scottish Carer Supplement + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 15_000 + # 11.70 a week for 52 weeks. + scottish_carer_supplement: 608.40 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/319 reg 27(1)(j)(xxib) excepts the supplement, so the scheme + # counts the pension alone. Tax on the pension with the supplement above + # it: (15,000 - 12,570) x 19% = 461.70. + council_tax_reduction_tax_deduction: 461.70 + # 15,000 - 461.70. + council_tax_reduction_applicable_income: 14_538.30 + +- name: Wales's working-age scheme still deducts the whole income tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: WALES + region: WALES + council_tax: 2_000 + output: + # The model keeps the whole liability here, which the cases above do not + # cover: (30,000 - 12,570) x 20% = 3,486 and Class 1 594.40. + council_tax_reduction_tax_deduction: 4_080.40 + council_tax_reduction_applicable_income: 25_919.60 diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 031f5b855e..2eac04743a 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -16,41 +16,39 @@ working age SSI 2021/249 deducts income tax only from earnings (Part 6 Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax payable on income taken into account, which the supplement is not. So in law -the supplement does not touch Council Tax Reduction at all. The model deducts -all of a family's income tax from its Council Tax Reduction income, an -existing approximation, so there the supplement, which is taxable (SI -2026/93), reaches that income through the tax charged on it and in no other -way. The tax deducted is the liability before the winter fuel payment charge -(ITEPA 2003 s. 681I), which recovers a winter payment rather than taxing the -income assessed; "income tax" below means that liability. +the supplement, which is taxable (SI 2026/93), does not touch Council Tax +Reduction at all. The model deducts the tax and National Insurance each +scheme allows (council_tax_reduction_tax_deduction), with the supplement's +tax taken as the highest part of non-savings income. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all over State Pension age: 1. Counts the component: claiming Carer Support Payment raises Council Tax - Reduction income, before income tax and National Insurance and apart from - the other benefits it counts, by exactly the Carer Support Payment + Reduction income, before the income tax and National Insurance it deducts + and apart from the other benefits it counts, by exactly the Carer Support Payment component (86.45 a week), not by the component plus the supplement, and by that amount before any overlapping-benefit reduction (see below). The reduction itself never rises, and where both awards are partial it falls by 20% of the rise in income less the rise in the applicable amount (the carer premium comes with the claim). 2. Invariant to the supplement: setting the Scottish Carer Supplement to zero - leaves Carer Support Payment and the other benefits counted in Council - Tax Reduction income (Child Benefit, the income-related benefits, - Universal Credit and tax credits) unchanged, and changes that income only - through income tax: income plus income tax is unchanged. The supplement - never lowers the reduction, and where both awards are partial it raises - it by 20% of the tax on the supplement. + leaves Carer Support Payment, the other benefits counted in Council Tax + Reduction income (Child Benefit, the income-related benefits, Universal + Credit and tax credits) and Council Tax Reduction income itself unchanged + under the general rules. The supplement never lowers the reduction, and + under the general rules never changes it. 3. Differential: Council Tax Reduction and Housing Benefit assess the same carer income. Where Guarantee Credit does not passport Housing Benefit, Housing Benefit applicable income, before its disregard, childcare element and tariff income, equals Council Tax Reduction income less the part of Carer Support Payment that an overlapping benefit removes, which Housing - Benefit counts after the reduction (HB (SPC) Regs reg 29(4)(g)). This holds in - Scotland, where the carer's benefit is Carer Support Payment, and in - England and Wales, where it is Carer's Allowance. + Benefit counts after the reduction (HB (SPC) Regs reg 29(4)(g)), and less + the income tax and National Insurance that Housing Benefit deducts and + Council Tax Reduction does not. This holds in Scotland, where the carer's + benefit is Carer Support Payment, and in England and Wales, where it is + Carer's Allowance. Invariants 1 to 3 concern Council Tax Reduction income under the general rules. A pension-age family in receipt of a guarantee credit has its whole income @@ -84,9 +82,11 @@ CARER_SUPPORT_PAYMENT = 86.45 * WEEKS SCOTTISH_CARER_SUPPLEMENT = 11.70 * WEEKS WITHDRAWAL_RATE = 0.2 -# The income tax Council Tax Reduction income deducts: the liability before -# the winter fuel payment charge. +# The income tax Housing Benefit deducts: the liability before the winter fuel +# payment charge. INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +# The income tax and National Insurance Council Tax Reduction income deducts. +CTR_TAX = "council_tax_reduction_tax_deduction" PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -114,6 +114,7 @@ ] BENUNIT_VARIABLES = [ "council_tax_reduction_applicable_income", + CTR_TAX, "council_tax_reduction_applicable_amount", "council_tax_benefit", "guarantee_credit", @@ -246,8 +247,7 @@ def grid(families, reform=None): # from the other benefits it counts. g["income_before_tax"] = ( g["council_tax_reduction_applicable_income"] - + g[INCOME_TAX] - + g["national_insurance"] + + g[CTR_TAX] - g["other_counted_benefits"] ) # Cells whose Council Tax Reduction income follows the general rules, not @@ -340,29 +340,20 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( ) income = g["council_tax_reduction_applicable_income"][i] income_without = without["council_tax_reduction_applicable_income"][i] - # Only where the zero floor on income does not bind in either run, and - # both runs follow the general rules. + # Where both runs follow the general rules. general = g["general_rules"][i] & without["general_rules"][i] - compared = (income > 0) & (income_without > 0) & general - assert compared.any(), fam - assert np.allclose( - (income + g[INCOME_TAX][i])[compared], - (income_without + without[INCOME_TAX][i])[compared], - atol=0.05, - ), fam + assert general.any(), fam + assert np.allclose(income[general], income_without[general], atol=0.01), ( + fam, + income[general], + income_without[general], + ) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] - # The supplement can only add tax, which lowers income, so it never - # lowers the award, even on the Pension Credit routes (whose income - # follows invariant 4). + # The supplement never lowers the award, even on the Pension Credit + # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam - both_partial = partial(g, i) & partial(without, i) & general - tax_on_supplement = g[INCOME_TAX][i] - without[INCOME_TAX][i] - assert np.allclose( - (award - award_without)[both_partial], - WITHDRAWAL_RATE * tax_on_supplement[both_partial], - atol=0.05, - ), fam + assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam @@ -400,7 +391,12 @@ def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( + g["housing_benefit_applicable_income_childcare_element"][i] - g["housing_benefit_tariff_income"][i] ) - ctr_less_overlap = ctr_income - removed_by_overlap + # Housing Benefit deducts the whole liability; Council Tax Reduction + # the tax and National Insurance its scheme allows. + tax_only_hb_deducts = ( + g[INCOME_TAX][i] + g["national_insurance"][i] - g[CTR_TAX][i] + ) + ctr_less_overlap = ctr_income - removed_by_overlap - tax_only_hb_deducts assert np.allclose( hb_before_adjustments[compared], ctr_less_overlap[compared], atol=0.05 ), (fam, hb_before_adjustments[compared], ctr_less_overlap[compared]) diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py new file mode 100644 index 0000000000..4ccf6dcd27 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -0,0 +1,197 @@ +"""Income tax and National Insurance deducted from Council Tax Reduction income. + +Scotland's working-age scheme (the Council Tax Reduction (Scotland) Regulations +2021, SSI 2021/249) takes income tax and primary Class 1 contributions off +earnings only (regs 49(6)(b), 50(3)(a) and 51(2)); its unearned income rules +(regs 57 to 65) deduct no tax. The pensioner schemes disregard tax only on +income taken into account: SSI 2012/319 reg 31(12) in Scotland, SI 2012/2885 +Sch 1 para 17(13) in England and WSI 2013/3029 Sch 1 para 11(13) in Wales. +council_tax_reduction_tax_deduction applies these rules, and +council_tax_reduction_applicable_income deducts it. + +Each test builds single-adult households by hand in 2026, with no Universal +Credit claim, and states its invariant in its docstring. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +REGIONS = {"ENGLAND": "NORTH_WEST", "WALES": "WALES", "SCOTLAND": "SCOTLAND"} +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +WHOLE_TAX = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] +# Taxed income that is not earnings. A working-age adult has no State Pension. +UNEARNED = [ + "private_pension_income", + "property_income", + "savings_interest_income", + "dividend_income", +] +# Taxed income the pensioner schemes do not take into account. +UNCOUNTED = [ + "savings_interest_income", + "dividend_income", + "scottish_carer_supplement", +] + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +def simulate(people, country): + """One single-adult household per entry of people, all in one country.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, inputs in enumerate(people): + name = f"p{i}" + situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} + situation["benunits"][f"b{i}"] = { + "members": [name], + "would_claim_uc": {YEAR: False}, + } + situation["households"][f"h{i}"] = { + "members": [name], + "country": {YEAR: country}, + "region": {YEAR: REGIONS[country]}, + "council_tax": {YEAR: 2_000.0}, + "savings": {YEAR: 0.0}, + } + return Simulation(situation=situation) + + +def benunit_values(simulation, variable): + return np.asarray(simulation.calculate(variable, YEAR)) + + +def person_values(simulation, variables): + return sum( + np.asarray(simulation.calculate(v, YEAR, map_to="benunit")) for v in variables + ) + + +@st.composite +def working_age_adult(draw): + return dict( + age=draw(st.integers(25, 60)), + employment_income=draw(st.one_of(st.just(0.0), money(80_000))), + self_employment_income=draw(st.one_of(st.just(0.0), money(40_000))), + **{v: draw(st.one_of(st.just(0.0), money(40_000))) for v in UNEARNED}, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(working_age_adult(), min_size=1, max_size=4), + st.sampled_from(UNEARNED), + money(30_000), +) +def test_scottish_working_age_income_never_falls_as_taxed_unearned_income_rises( + adults, unearned, rise +): + """In Scotland's working-age scheme, Council Tax Reduction applicable income + never falls when a taxed unearned income rises and earnings stay fixed.""" + raised = [{**a, unearned: a[unearned] + rise} for a in adults] + base = simulate(adults, "SCOTLAND") + more = simulate(raised, "SCOTLAND") + assert not benunit_values(base, "council_tax_reduction_pensioner").any() + before = benunit_values(base, "council_tax_reduction_applicable_income") + after = benunit_values(more, "council_tax_reduction_applicable_income") + assert np.all(after >= before - 0.01), (adults, unearned, rise, before, after) + + +@PROPERTY_SETTINGS +@given(st.lists(working_age_adult(), min_size=1, max_size=4)) +def test_scottish_working_age_deducts_no_tax_without_earnings(adults): + """In Scotland's working-age scheme, with no earnings the deduction for + income tax and National Insurance is nil, however much other taxed income + there is.""" + no_earnings = [ + {**a, "employment_income": 0.0, "self_employment_income": 0.0} for a in adults + ] + simulation = simulate(no_earnings, "SCOTLAND") + deduction = benunit_values(simulation, "council_tax_reduction_tax_deduction") + assert np.all(deduction == 0), (no_earnings, deduction) + + +@st.composite +def pensioner(draw): + # Pensions reach far enough, with the uncounted income, to cross the + # personal allowance taper. + return dict( + age=draw(st.integers(67, 90)), + state_pension=draw(money(12_000)), + private_pension_income=draw(money(140_000)), + property_income=draw(st.one_of(st.just(0.0), money(15_000))), + savings_interest_income=draw(st.one_of(st.just(0.0), money(20_000))), + dividend_income=draw(st.one_of(st.just(0.0), money(20_000))), + scottish_carer_supplement=draw(st.sampled_from([0.0, 608.40])), + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(pensioner(), min_size=1, max_size=4), + st.sampled_from(sorted(REGIONS)), +) +def test_pensioner_schemes_deduct_no_more_than_the_tax_on_counted_income( + pensioners, country +): + """In the pensioner schemes of England, Wales and Scotland, the income tax + and National Insurance deducted never exceed what the same person would + pay with only the income the scheme counts, nor the whole liability.""" + counted_only = [{**p, **{v: 0.0 for v in UNCOUNTED}} for p in pensioners] + full = simulate(pensioners, country) + alone = simulate(counted_only, country) + assert benunit_values(full, "council_tax_reduction_pensioner").all() + deduction = benunit_values(full, "council_tax_reduction_tax_deduction") + tax_on_counted_income = person_values(alone, WHOLE_TAX) + whole_tax = person_values(full, WHOLE_TAX) + assert np.all(deduction <= tax_on_counted_income + 0.01), ( + pensioners, + deduction, + tax_on_counted_income, + ) + assert np.all(deduction <= whole_tax + 0.01), (pensioners, deduction, whole_tax) + # Taking away the uncounted income leaves the deduction where it was. + deduction_alone = benunit_values(alone, "council_tax_reduction_tax_deduction") + assert np.allclose(deduction, deduction_alone, atol=0.01), ( + pensioners, + deduction, + deduction_alone, + ) + + +CASES = [ + ("SCOTLAND", dict(age=40, employment_income=20_000.0, dividend_income=9_000.0)), + ("SCOTLAND", dict(age=70, private_pension_income=15_000.0)), + ("ENGLAND", dict(age=70, private_pension_income=20_308.0)), + ("WALES", dict(age=70, private_pension_income=20_000.0)), +] + + +def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_first(): + """Council Tax Reduction and its tax deduction come out the same whether + the deduction or the reduction is calculated first, so the deduction reads + no benefit that depends on the reduction.""" + variables = [ + "council_tax_reduction_tax_deduction", + "council_tax_reduction_applicable_income", + "council_tax_reduction", + ] + for country, person in CASES: + inputs = {**person, "savings_interest_income": 6_559.0} + results = [] + for order in (variables, variables[::-1]): + simulation = simulate([inputs], country) + results.append( + {v: float(simulation.calculate(v, YEAR).sum()) for v in order} + ) + assert results[0] == results[1], (country, results) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index e1494e88cb..073ea7f684 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -38,7 +38,11 @@ class council_tax_reduction_applicable_income(Variable): "children or young persons. War pensions and Armed Forces " "Compensation Scheme payments are not counted: Scotland disregards or " "omits them, and English and Welsh councils may relieve them beyond " - "the prescribed £10 a week, so the model takes the full disregard." + "the prescribed £10 a week, so the model takes the full disregard. " + "The income tax and National Insurance deducted are those the scheme " + "allows (council_tax_reduction_tax_deduction): on earnings only in " + "Scotland's working-age scheme, on income taken into account in the " + "pensioner schemes, and the whole liability in the other schemes." ) definition_period = YEAR unit = GBP @@ -285,12 +289,10 @@ def formula(benunit, period, parameters): pension_contributions = add_for_members( benunit, period, ["pension_contributions"], members ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) - tax = add_for_members( - benunit, - period, - ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], - members, - ) + # The income tax and National Insurance each scheme allows: on earnings + # only in Scotland's working-age scheme, on income taken into account + # in the pensioner schemes (council_tax_reduction_tax_deduction). + tax = benunit("council_tax_reduction_tax_deduction", period) # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 1 para # 11(9) and Sch 3; SSI 2012/319 reg 31(8) and Sch 2: the pensioner # earnings disregards, capped at net earnings. Zero for working-age diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py new file mode 100644 index 0000000000..147a35ddc5 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -0,0 +1,187 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, +) +from policyengine_uk.variables.household.demographic.country import Country + +# Taxable non-savings income other than earnings. Scotland's working-age scheme +# deducts no tax from it. +NON_EARNINGS_NON_SAVINGS_INCOME = [ + "taxable_pension_income", + "taxable_social_security_income", + "taxable_property_income", + "taxable_miscellaneous_income", +] +# Taxable non-savings income that the pensioner schemes do not take into +# account: the Scottish Carer Supplement (SSI 2012/319 reg 27(1)(j)(xxib)), +# miscellaneous income and benefits in kind, which the model's Council Tax +# Reduction income does not count. +PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME = [ + "scottish_carer_supplement", + "taxable_miscellaneous_income", + "benefits_in_kind", +] +# National Insurance charged on earnings and profits. Class 3 contributions are +# voluntary and not charged on earnings. +NATIONAL_INSURANCE_ON_EARNINGS = ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + + +def tax_below_top_slice(person, period, parameters, top_slice): + """Step 5 tax on a person's non-savings income without its top slice. + + Savings and dividend income are taxed as the highest part of income, so + the Step 5 tax on the rest is earned_income_tax plus property_income_tax. + The top_slice of non-savings income is taken as the highest part of the + non-savings income, and its tax at the person's main rates is taken off. + Allowances and reliefs therefore fall on the income below it, and that + income keeps the personal allowance it would have on its own: the part of + the allowance withdrawn only because the top slice, savings and dividends + lift adjusted net income above the taper threshold is restored to it. + """ + allowance = parameters(period).gov.hmrc.income_tax.allowances.personal_allowance + rates = parameters(period).gov.hmrc.income_tax.rates + non_savings_tax = add(person, period, ["earned_income_tax", "property_income_tax"]) + taxable = person("non_savings_taxable_income", period) + income_above = top_slice + add( + person, period, ["taxable_savings_interest_income", "taxable_dividend_income"] + ) + income_below = max_(0, person("adjusted_net_income", period) - income_above) + allowance_alone = max_( + 0, + np.ceil( + allowance.amount + - max_(0, income_below - allowance.maximum_ANI) * allowance.reduction_rate + ), + ) + restored_allowance = max_(0, allowance_alone - person("personal_allowance", period)) + below = max_(0, taxable - top_slice - restored_allowance) + gross_gift = person("gift_aid_grossed_up", period) + scottish = rates.scotland.rates + first_extended = first_extended_scottish_threshold(scottish) + scottish_bands = len(scottish.thresholds) + + def tax_at_main_rates(income): + return where( + person("pays_scottish_income_tax", period), + calc_income_tax_with_gift_aid( + scottish, income, gross_gift, first_extended, scottish_bands + ), + calc_income_tax_with_gift_aid(rates.uk, income, gross_gift), + ) + + tax_on_top_slice = max_(0, tax_at_main_rates(taxable) - tax_at_main_rates(below)) + # With no taxable income below the top slice there is no tax on it; this + # also clears the rounding left by the subtraction. + return where(below > 0, max_(0, non_savings_tax - tax_on_top_slice), 0) + + +def tax_after_reductions_and_charges(person, period, step_5_part): + """Income Tax attributed to a part of the Step 5 tax. + + The Step 6 reductions come off that part first, as they would if it were + the person's only income. The Step 7 charges other than the winter fuel + payment charge are shared in proportion to the part's share of the Step 5 + tax. The result never exceeds income_tax_before_winter_fuel_payment_charge. + """ + step_5_tax = max_(0, person("income_tax_pre_charges", period)) + reductions = person("income_tax_reductions", period) + charges = person("income_tax_charges", period) + share = np.divide( + step_5_part, + step_5_tax, + out=np.zeros_like(step_5_tax), + where=step_5_tax > 0, + ) + return max_(0, step_5_part - reductions) + charges * min_(share, 1) + + +class council_tax_reduction_tax_deduction(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction deduction for income tax and National Insurance" + documentation = ( + "Income tax and National Insurance that Council Tax Reduction income " + "deducts, for the applicant and partner " + "(is_council_tax_reduction_applicant_or_partner). Scotland's " + "working-age scheme (SSI 2021/249) deducts them from earnings only, " + "so the formula deducts National Insurance on earnings and profits " + "(Classes 1, 2 and 4) and the Income Tax on earnings, taken as the " + "Step 5 tax that employment and self-employment income bear as the " + "lowest part of non-savings income, with the other non-savings " + "income above them and savings and dividends taxed separately. The " + "pensioner schemes of England (SI 2012/2885), Wales (WSI 2013/3029) " + "and Scotland (SSI 2012/319) disregard tax only on income taken into " + "account, so the formula deducts the same National Insurance and the " + "Step 5 tax on non-savings income less the tax on the Scottish Carer " + "Supplement, miscellaneous income and benefits in kind, taken as the " + "highest part of non-savings income. The income below keeps the " + "personal allowance it would have on its own. Tax on savings and dividend " + "income is never deducted in these schemes. In each, the Step 6 tax " + "reductions come off the tax deducted first, and the Step 7 charges " + "other than the winter fuel payment charge are shared in proportion " + "to the Step 5 tax. The ordering of earnings below other non-savings " + "income is a modelling choice: the regulations name the tax deducted " + "from earnings, not a way to apportion it. The English and Welsh " + "working-age schemes, and families outside Great Britain, deduct the " + "whole Income Tax liability before the winter fuel payment charge " + "and all National Insurance." + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ssi/2021/249/regulation/49", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + ] + + def formula(benunit, period, parameters): + person = benunit.members + members = person("is_council_tax_reduction_applicant_or_partner", period) + country = benunit.household("country", period) + in_great_britain = ( + (country == Country.ENGLAND) + | (country == Country.SCOTLAND) + | (country == Country.WALES) + ) + pensioner = benunit("council_tax_reduction_pensioner", period) + scottish_working_age = (country == Country.SCOTLAND) & ~pensioner + pensioner_scheme = in_great_britain & pensioner + + whole_tax = add( + person, + period, + ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], + ) + ni_on_earnings = add(person, period, NATIONAL_INSURANCE_ON_EARNINGS) + earnings_step_5_tax = tax_below_top_slice( + person, + period, + parameters, + add(person, period, NON_EARNINGS_NON_SAVINGS_INCOME), + ) + counted_step_5_tax = tax_below_top_slice( + person, + period, + parameters, + add(person, period, PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME), + ) + tax_on_earnings = ( + tax_after_reductions_and_charges(person, period, earnings_step_5_tax) + + ni_on_earnings + ) + tax_on_counted_income = ( + tax_after_reductions_and_charges(person, period, counted_step_5_tax) + + ni_on_earnings + ) + person_scottish_working_age = benunit.project(scottish_working_age) + person_pensioner_scheme = benunit.project(pensioner_scheme) + deduction = select( + [person_scottish_working_age, person_pensioner_scheme], + [tax_on_earnings, tax_on_counted_income], + default=whole_tax, + ) + return benunit.sum(deduction * members) From 4574ee1620081ebaa08f64cb9925362d7a6debca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:37:15 +0000 Subject: [PATCH 02/19] Split Council Tax Reduction's tax deduction as Pension Credit does, and price self-employed tax notionally Every scheme that deducts tax from earnings now takes the tax on earnings as the extra tax they cause, with the earnings as the top slice of income, as main's Pension Credit code does (option (a) of #2019). The Scottish working-age scheme without Universal Credit and the pensioner schemes of England, Wales and Scotland price the tax on self-employed profit at the basic rate less the personal reliefs, with Class 4 between the limits and Class 2 before each omission. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- changelog.d/2090.fixed.md | 2 +- ...l_tax_reduction_carer_support_payment.yaml | 7 +- .../council_tax_reduction_tax_deduction.yaml | 392 +++++++++++++- ...l_tax_reduction_carer_income_properties.py | 45 +- ..._tax_reduction_tax_deduction_properties.py | 505 ++++++++++++++---- ...tion_income_tax_on_other_counted_income.py | 54 ++ ...ion_scotland_self_employment_deductions.py | 87 +++ .../council_tax_reduction_tax_deduction.py | 264 ++++----- 8 files changed, 1048 insertions(+), 308 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md index ed9a67373c..fe34b9d788 100644 --- a/changelog.d/2090.fixed.md +++ b/changelog.d/2090.fixed.md @@ -1 +1 @@ -Council Tax Reduction income now deducts only the income tax and National Insurance each scheme allows: tax on earnings in Scotland's working-age scheme, and no tax on savings, dividends or the Scottish Carer Supplement in the pensioner schemes of England, Wales and Scotland. +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement where earnings do not raise it. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index 637e7844ad..c4c5f44b96 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -13,9 +13,10 @@ # age SSI 2021/249 deducts tax only from earnings, and at pension age SSI # 2012/319 reg 31(12) disregards the tax payable on income taken into # account, which the supplement is not. The model deducts only the tax each -# scheme allows (council_tax_reduction_tax_deduction), with the supplement's -# tax taken as the highest part of non-savings income, so the supplement does -# not reach the model's Council Tax Reduction income. +# scheme allows (council_tax_reduction_tax_deduction). At working age that is +# the tax earnings add, and at pension age the tax on the counted income with +# the supplement above it, so without earnings the supplement does not reach +# the model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 17c68f8f51..b681ab26b6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -1,23 +1,37 @@ # Income tax and National Insurance deducted from Council Tax Reduction income. -# - Scotland, working age: the Council Tax Reduction (Scotland) Regulations -# 2021 (SSI 2021/249) reg 50(3)(a) reads "Earnings do not include— (a) any -# amount deducted from them by way of income tax or primary Class 1 -# contributions under the 1992 Act"; regs 49(6)(b) and 51(2) do the same, -# and the unearned income rules (regs 57 to 65) deduct no tax. So only tax -# and National Insurance on earnings come off. +# - Scotland, working age (SSI 2021/249): reg 50(3)(a) reads "Earnings do not +# include— (a) any amount deducted from them by way of income tax or +# primary Class 1 contributions under the 1992 Act". The Scottish Ministers +# set the same rule with Universal Credit (regs 49(6)(b) and 51(2) step 3) +# and gave no tax deduction for unearned income (regs 57 to 65). Without +# Universal Credit, reg 51(9) applies SSI 2012/303 regs 37 and 38, which +# price the tax and contributions on self-employed profit notionally: the +# basic rate on chargeable income less only the personal allowance, and +# Class 4 at the main rate between the lower and upper limits. # - Pension age: SSI 2012/319 reg 31(12), "In the case of any income taken # into account for the purpose of calculating a person's income, any amount # payable by way of tax must be disregarded"; SI 2012/2885 Sch 1 para 17(13) -# and WSI 2013/3029 Sch 1 para 11(13) read the same. So tax on income not -# taken into account (savings interest, dividends, the Scottish Carer -# Supplement) does not come off. +# and WSI 2013/3029 Sch 1 para 11(13) read the same. Reg 33(2)(a), para +# 19(2)(a) and para 13(2)(a) deduct the income tax and Class 1 deducted from +# earnings, and reg 37, para 30 and para 24 price self-employed profit +# notionally, as at working age. +# No provision says how to split one income tax liability. The model follows +# the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 +# (option (a)): the tax on earnings is the extra tax the earnings cause, with +# the earnings as the top slice of income and self-employed profit beneath +# them. In the pensioner schemes the tax on the other counted income is the +# tax it bears with the uncounted income above it. # -# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable income: -# 19% to 3,967, 20% to 16,956, 21% to 31,092. UK basic rate 20% to 37,700. -# Class 1 primary contributions 8% above 241.73 a week (12,569.96 a year). -# Each family is a single adult with no Pension Credit or Universal Credit. +# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable non- +# savings income: 19% to 3,967, 20% to 16,956, 21% to 31,092, 42% to 62,430. +# UK basic rate 20% to 37,700, higher rate 40%. Savings: starting rate band +# 5,000 less taxable non-savings income; personal savings allowance 1,000 for +# a basic-rate taxpayer and 500 for a higher-rate one. Class 1 primary +# contributions 8% above 241.73 a week (12,569.96 a year). Class 4 6% from +# 12,570 to 50,270. Each family is a single adult with no Pension Credit or +# Universal Credit unless stated. -- name: Scotland's working-age scheme deducts tax and National Insurance on earnings only +- name: Scotland's working-age scheme deducts the tax that pay adds to a pension period: 2026 absolute_error_margin: 0.01 input: @@ -37,13 +51,240 @@ region: SCOTLAND council_tax: 2_000 output: - # Tax on earnings, the earnings as the lowest part of income: - # 20,000 - 12,570 = 7,430 taxable; 3,967 x 19% = 753.73 and - # 3,463 x 20% = 692.60, so 1,446.33. Class 1: (20,000 - 12,569.96) x 8% = - # 594.40. The tax on the private pension is not deducted. + # Tax with the pay: 30,000 - 12,570 = 17,430 taxable; 3,967 x 19% = 753.73, + # 12,989 x 20% = 2,597.80 and 474 x 21% = 99.54, so 3,451.07. Tax without + # the pay: 10,000 is below the personal allowance, so nil. The pay adds + # 3,451.07. Class 1: (20,000 - 12,569.96) x 8% = 594.40. + council_tax_reduction_tax_deduction: 4_045.47 + # 20,000 + 10,000 - 4,045.47. + council_tax_reduction_applicable_income: 25_954.53 + +- name: Scotland's working-age scheme deducts the savings tax that pay adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + savings_interest_income: 60_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # With the pay: non-savings 17,430 taxable, 3,451.07 as above. Savings + # 60,000 sit above 17,430 in the UK bands: no starting rate; 500 personal + # savings allowance; 37,700 - 17,430 - 500 = 19,770 x 20% = 3,954; and + # 60,000 - 20,270 = 39,730 x 40% = 15,892. Total 23,297.07. + # Without the pay: 60,000 - 12,570 = 47,430 taxable savings; 5,000 at the + # starting rate and 500 allowance at nil, 32,200 x 20% = 6,440 and + # 9,730 x 40% = 3,892, so 10,332. The pay adds 12,965.07. + # Class 1: (30,000 - 12,569.96) x 8% = 1,394.40. + council_tax_reduction_tax_deduction: 14_359.47 + # The scheme counts the pay alone: 30,000 - 14,359.47. + council_tax_reduction_applicable_income: 15_640.53 + +- name: Scotland's working-age scheme deducts the tax that pay adds to property income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + property_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # With the pay: 50,000 - 12,570 = 37,430 taxable; 753.73 + 2,597.80 + + # 14,136 x 21% = 2,968.56 + 6,338 x 42% = 2,661.96, so 8,982.05. Without + # the pay: 7,430 taxable; 753.73 + 3,463 x 20% = 692.60, so 1,446.33. The + # pay adds 7,535.72. Class 1 1,394.40. + council_tax_reduction_tax_deduction: 8_930.12 + # 30,000 + 20,000 - 8,930.12. + council_tax_reduction_applicable_income: 41_069.88 + +- name: Scotland's working-age scheme keeps benefits in kind beneath the pay + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + company_car_benefit: 15_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Reg 50(3)(e): a payment in kind is not earnings, so the car benefit stays + # in both terms. With the pay: 45,000 - 12,570 = 32,430 taxable; 753.73 + + # 2,597.80 + 2,968.56 + 1,338 x 42% = 561.96, so 6,882.05. Without the pay: + # 15,000 - 12,570 = 2,430 x 19% = 461.70. The pay adds 6,420.35. Class 1 + # 1,394.40. + council_tax_reduction_tax_deduction: 7_814.75 + # 30,000 - 7,814.75: the car benefit is not counted. + council_tax_reduction_applicable_income: 22_185.25 + +- name: Scotland's working-age scheme prices tax on a 15,000 profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 15_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/303 reg 38: (15,000 - 12,570) x 20% = 486 and Class 4 + # (15,000 - 12,570) x 6% = 145.80. That exceeds the 461.70 + 145.80 the + # person pays, as the rule intends. + council_tax_reduction_tax_deduction: 631.80 + # 15,000 - 631.80. + council_tax_reduction_applicable_income: 14_368.20 + +- name: Scotland's working-age scheme prices tax on a 60,000 profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 60_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # (60,000 - 12,570) x 20% = 9,486, and Class 4 only between the limits: + # (50,270 - 12,570) x 6% = 2,262. + council_tax_reduction_tax_deduction: 11_748 + # 60,000 - 11,748. + council_tax_reduction_applicable_income: 48_252 + +- name: Scotland's working-age scheme with Universal Credit deducts the tax that profit adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + self_employment_income: 15_000 + savings_interest_income: 20_000 + child_1: + age: 5 + child_2: + age: 3 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + universal_credit: 7_190.20 + # Reg 51(2) step 3 deducts the tax and contributions paid. With the + # profit: non-savings 2,430 x 19% = 461.70; savings 20,000 over a 2,570 + # starting rate band and a 1,000 allowance, 16,430 x 20% = 3,286; total + # 3,747.70. Without it: 7,430 taxable savings; 5,000 starting rate, 1,000 + # allowance, 1,430 x 20% = 286. The profit adds 3,461.70. Class 4 + # charged: 145.80. + council_tax_reduction_tax_deduction: 3_607.50 + +- name: Scotland's working-age scheme counted Class 2 in the notional deduction before 2024-25 + period: 2023 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # 2023-24: SSI 2012/303 reg 38 as it stood on 5 April 2024. Income tax + # (20,000 - 12,570) x 20% = 1,486; Class 4 (20,000 - 12,570) x 9% = + # 668.70; Class 2, as the profit exceeds the 12,570 lower profits + # threshold, 3.45 x 52 weeks = 179.40. + council_tax_reduction_tax_deduction: 2_334.10 + # 20,000 - 2,334.10. + council_tax_reduction_applicable_income: 17_665.90 + +- name: Scotland's working-age scheme deducts no voluntary Class 3 contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + ni_class_3: 900 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Tax on the pay: 7,430 taxable; 753.73 + 692.60 = 1,446.33. Class 1: + # 594.40. Class 3 is voluntary, not a primary Class 1 contribution + # deducted from earnings, so the 900 does not come off. council_tax_reduction_tax_deduction: 2_040.73 - # 20,000 + 10,000 - 2,040.73. - council_tax_reduction_applicable_income: 27_959.27 + # 20,000 - 2,040.73. + council_tax_reduction_applicable_income: 17_959.27 - name: England's pensioner scheme deducts no tax on savings interest period: 2026 @@ -64,12 +305,64 @@ region: NORTH_WEST council_tax: 2_000 output: - # The scheme counts the pension, not the interest. Tax on the pension: - # (20,308 - 12,570) x 20% = 1,547.60. + # The scheme counts the pension, not the interest. Tax on the pension + # with the interest above it: (20,308 - 12,570) x 20% = 1,547.60. council_tax_reduction_tax_deduction: 1_547.60 # 20,308 - 1,547.60. council_tax_reduction_applicable_income: 18_760.40 +- name: England's pensioner scheme deducts the savings tax that pay adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 20_000 + savings_interest_income: 20_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # With the pay: 7,430 x 20% = 1,486 on the pay; savings 20,000 with no + # starting rate, a 1,000 allowance and 19,000 x 20% = 3,800; total 5,286. + # Without the pay: 7,430 taxable savings; 5,000 starting rate, 1,000 + # allowance, 1,430 x 20% = 286. The pay adds 5,000. The scheme counts no + # other taxed income, and the person pays no Class 1 above pension age. + council_tax_reduction_tax_deduction: 5_000 + # 20,000 - 5,000 - the 5 a week earnings disregard (260). + council_tax_reduction_applicable_income: 14_740 + +- name: England's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # SI 2012/2885 Sch 1 para 30: (60,000 - 12,570) x 20% = 9,486, not the + # 11,432 the profit bears. No Class 4 is payable above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + - name: Wales's pensioner scheme deducts no tax on dividends period: 2026 absolute_error_margin: 0.01 @@ -95,6 +388,30 @@ # 20,000 - 1,486. council_tax_reduction_applicable_income: 18_514 +- name: Wales's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + region: WALES + council_tax: 2_000 + output: + # WSI 2013/3029 Sch 1 para 24: (60,000 - 12,570) x 20% = 9,486, not the + # 11,432 the profit bears. No Class 4 is payable above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + - name: Scotland's pensioner scheme deducts no tax on the Scottish Carer Supplement period: 2026 absolute_error_margin: 0.01 @@ -115,13 +432,38 @@ region: SCOTLAND council_tax: 2_000 output: - # SSI 2012/319 reg 27(1)(j)(xxib) excepts the supplement, so the scheme - # counts the pension alone. Tax on the pension with the supplement above - # it: (15,000 - 12,570) x 19% = 461.70. + # The Scottish pensioner scheme does not count the Scottish Carer + # Supplement as income (SSI 2012/319 reg 27(1)(j)(xxib)), so its tax, as + # the highest part of income, does not come off. Tax on the pension: + # (15,000 - 12,570) x 19% = 461.70. council_tax_reduction_tax_deduction: 461.70 # 15,000 - 461.70. council_tax_reduction_applicable_income: 14_538.30 +- name: Scotland's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/319 reg 37: (60,000 - 12,570) x 20% = 9,486, not the 13,182.05 + # in Scottish bands the profit bears. No Class 4 above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + - name: Wales's working-age scheme still deducts the whole income tax period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 2eac04743a..6ec86bd612 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -15,11 +15,14 @@ 27(1)(j)(xxib) excepts it. Nor does the tax on the supplement count: at working age SSI 2021/249 deducts income tax only from earnings (Part 6 Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax -payable on income taken into account, which the supplement is not. So in law -the supplement, which is taxable (SI 2026/93), does not touch Council Tax -Reduction at all. The model deducts the tax and National Insurance each -scheme allows (council_tax_reduction_tax_deduction), with the supplement's -tax taken as the highest part of non-savings income. +payable on income taken into account, which the supplement is not. The +supplement is taxable (SI 2026/93). The model deducts the tax and National +Insurance each scheme allows (council_tax_reduction_tax_deduction). At working +age that includes the extra tax earnings cause, with the earnings as the top +slice of income (the choice settled for Pension Credit on +PolicyEngine/policyengine-uk#2019, option (a)), so the supplement beneath the +earnings can raise the tax the earnings add. At pension age the formula +deducts the tax on the counted income with the supplement above it. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all @@ -33,12 +36,14 @@ reduction itself never rises, and where both awards are partial it falls by 20% of the rise in income less the rise in the applicable amount (the carer premium comes with the claim). -2. Invariant to the supplement: setting the Scottish Carer Supplement to zero - leaves Carer Support Payment, the other benefits counted in Council Tax - Reduction income (Child Benefit, the income-related benefits, Universal - Credit and tax credits) and Council Tax Reduction income itself unchanged - under the general rules. The supplement never lowers the reduction, and - under the general rules never changes it. +2. Invariant to the supplement, apart from the tax earnings add: setting the + Scottish Carer Supplement to zero leaves Carer Support Payment and the + other benefits counted in Council Tax Reduction income (Child Benefit, the + income-related benefits, Universal Credit and tax credits) unchanged. + Under the general rules it changes Council Tax Reduction income only by + the change in the deduction for income tax and National Insurance, and in + a family with no earnings it changes neither that income nor the + reduction. The supplement never lowers the reduction. 3. Differential: Council Tax Reduction and Housing Benefit assess the same carer income. Where Guarantee Credit does not passport Housing Benefit, Housing Benefit applicable income, before its disregard, childcare element @@ -343,17 +348,25 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( # Where both runs follow the general rules. general = g["general_rules"][i] & without["general_rules"][i] assert general.any(), fam - assert np.allclose(income[general], income_without[general], atol=0.01), ( - fam, - income[general], - income_without[general], + # The supplement reaches the income only through the deduction. + assert np.allclose( + (income + g[CTR_TAX][i])[general], + (income_without + without[CTR_TAX][i])[general], + atol=0.01, + ), (fam, income[general], income_without[general]) + has_earnings = any( + a.get("employment_income", 0.0) > 0 for a in fam["adults"] ) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] # The supplement never lowers the award, even on the Pension Credit # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam - assert np.allclose(award[general], award_without[general], atol=0.01), fam + if not has_earnings: + assert np.allclose( + income[general], income_without[general], atol=0.01 + ), (fam, income[general], income_without[general]) + assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 4ccf6dcd27..71ddc95699 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -3,18 +3,26 @@ Scotland's working-age scheme (the Council Tax Reduction (Scotland) Regulations 2021, SSI 2021/249) takes income tax and primary Class 1 contributions off earnings only (regs 49(6)(b), 50(3)(a) and 51(2)); its unearned income rules -(regs 57 to 65) deduct no tax. The pensioner schemes disregard tax only on -income taken into account: SSI 2012/319 reg 31(12) in Scotland, SI 2012/2885 -Sch 1 para 17(13) in England and WSI 2013/3029 Sch 1 para 11(13) in Wales. -council_tax_reduction_tax_deduction applies these rules, and -council_tax_reduction_applicable_income deducts it. - -Each test builds single-adult households by hand in 2026, with no Universal -Credit claim, and states its invariant in its docstring. +(regs 57 to 65) deduct no tax. Without Universal Credit, reg 51(9) applies the +notional tax and contributions of SSI 2012/303 reg 38 to self-employed profit. +The pensioner schemes disregard tax only on income taken into account and on +earnings: SSI 2012/319 regs 31(12), 33(2)(a) and 37 in Scotland, SI 2012/2885 +Sch 1 paras 17(13), 19(2)(a) and 30 in England, and WSI 2013/3029 Sch 1 paras +11(13), 13(2)(a) and 24 in Wales. + +No provision says how to split one income tax liability. The model follows +the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 +(option (a)): the tax on earnings is the extra tax the earnings cause, with +the earnings as the top slice of income. council_tax_reduction_tax_deduction +applies these rules, and council_tax_reduction_applicable_income deducts it. + +Each test builds households by hand in 2026, states its invariant in its +docstring, and records the scheme of each household it draws as a Hypothesis +event, so that `--hypothesis-show-statistics` counts the examples per scheme. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, event, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -27,19 +35,29 @@ derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -WHOLE_TAX = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] -# Taxed income that is not earnings. A working-age adult has no State Pension. -UNEARNED = [ - "private_pension_income", - "property_income", +DEDUCTION = "council_tax_reduction_tax_deduction" +INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +NO_SCOTTISH_CARER_SUPPLEMENT = { + "gov.social_security_scotland.carer_support_payment.supplement": { + "2026-01-01.2100-12-31": 0 + } +} +# Taxed income the pensioner schemes do not count, as inputs. +UNCOUNTED = [ "savings_interest_income", + "other_investment_income", "dividend_income", + "miscellaneous_income", + "company_car_benefit", ] -# Taxed income the pensioner schemes do not take into account. -UNCOUNTED = [ +# Taxed income other than earnings that Scotland's working-age scheme counts +# or treats as capital. +UNEARNED = [ + "private_pension_income", + "property_income", "savings_interest_income", "dividend_income", - "scottish_carer_supplement", + "miscellaneous_income", ] @@ -47,151 +65,412 @@ def money(high): return st.floats(0, high, allow_nan=False, allow_infinity=False) -def simulate(people, country): - """One single-adult household per entry of people, all in one country.""" +def maybe(high): + return st.one_of(st.just(0.0), money(high)) + + +def simulate(households, reform=None): + """One simulation of households. Each household is a dict with country, + benunits (a list of lists of person input dicts; the first benunit is + the Council Tax Reduction applicant's) and, optionally, uc.""" situation = {"people": {}, "benunits": {}, "households": {}} - for i, inputs in enumerate(people): - name = f"p{i}" - situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} - situation["benunits"][f"b{i}"] = { - "members": [name], - "would_claim_uc": {YEAR: False}, - } - situation["households"][f"h{i}"] = { - "members": [name], + for h, household in enumerate(households): + names = [] + for b, adults in enumerate(household["benunits"]): + members = [] + for p, inputs in enumerate(adults): + name = f"p{h}_{b}_{p}" + situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} + members.append(name) + situation["benunits"][f"b{h}_{b}"] = { + "members": members, + "would_claim_uc": {YEAR: household.get("uc", False)}, + } + names += members + country = household["country"] + situation["households"][f"h{h}"] = { + "members": names, "country": {YEAR: country}, "region": {YEAR: REGIONS[country]}, "council_tax": {YEAR: 2_000.0}, "savings": {YEAR: 0.0}, } - return Simulation(situation=situation) + return Simulation(situation=situation, reform=reform) def benunit_values(simulation, variable): return np.asarray(simulation.calculate(variable, YEAR)) -def person_values(simulation, variables): - return sum( - np.asarray(simulation.calculate(v, YEAR, map_to="benunit")) for v in variables +def applicant_total(simulation, variable): + """The Council Tax Reduction applicant's and partner's total, by benunit.""" + values = np.asarray(simulation.calculate(variable, YEAR)) + members = np.asarray( + simulation.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + ) + return np.asarray( + simulation.map_result(values * members, "person", "benunit", how="sum") ) +def scheme_of(country, pensioner, uc=False): + if pensioner: + return f"{country.lower()} pensioner" + if country == "SCOTLAND": + return "scotland working age " + ("with UC" if uc else "without UC") + return f"{country.lower()} working age" + + @st.composite -def working_age_adult(draw): - return dict( - age=draw(st.integers(25, 60)), - employment_income=draw(st.one_of(st.just(0.0), money(80_000))), - self_employment_income=draw(st.one_of(st.just(0.0), money(40_000))), - **{v: draw(st.one_of(st.just(0.0), money(40_000))) for v in UNEARNED}, +def adult(draw, pensioner, earnings=True, self_employment=True): + person = dict( + age=draw(st.integers(67, 90)) if pensioner else draw(st.integers(25, 60)), + employment_income=draw(maybe(90_000)) if earnings else 0.0, + self_employment_income=draw(maybe(90_000)) if self_employment else 0.0, + private_pension_income=draw(maybe(60_000)), + property_income=draw(maybe(30_000)), + **{v: draw(maybe(40_000)) for v in UNCOUNTED}, ) + if pensioner: + person["state_pension"] = draw(money(12_000)) + return person + + +@st.composite +def household(draw, countries=tuple(REGIONS), pensioner=None, **kwargs): + country = draw(st.sampled_from(countries)) + is_pensioner = draw(st.booleans()) if pensioner is None else pensioner + adults = draw(st.lists(adult(is_pensioner, **kwargs), min_size=1, max_size=2)) + return dict(country=country, benunits=[adults], pensioner=is_pensioner) + + +def schemes(simulation, households): + pensioner = benunit_values(simulation, "council_tax_reduction_pensioner") + universal_credit = benunit_values(simulation, "universal_credit") > 0 + labels = [] + for i, h in enumerate(households): + assert pensioner[i] == h["pensioner"], h + labels.append(scheme_of(h["country"], pensioner[i], universal_credit[i])) + event(labels[-1]) + return labels @PROPERTY_SETTINGS @given( - st.lists(working_age_adult(), min_size=1, max_size=4), - st.sampled_from(UNEARNED), - money(30_000), + st.lists( + household(countries=("SCOTLAND",), pensioner=False, self_employment=False), + min_size=1, + max_size=4, + ) ) -def test_scottish_working_age_income_never_falls_as_taxed_unearned_income_rises( - adults, unearned, rise +def test_scottish_working_age_deduction_matches_pension_credit_earnings_tax( + households, ): - """In Scotland's working-age scheme, Council Tax Reduction applicable income - never falls when a taxed unearned income rises and earnings stay fixed.""" - raised = [{**a, unearned: a[unearned] + rise} for a in adults] - base = simulate(adults, "SCOTLAND") - more = simulate(raised, "SCOTLAND") - assert not benunit_values(base, "council_tax_reduction_pensioner").any() - before = benunit_values(base, "council_tax_reduction_applicable_income") - after = benunit_values(more, "council_tax_reduction_applicable_income") - assert np.all(after >= before - 0.01), (adults, unearned, rise, before, after) + """Invariant 1. At working age in Scotland without self-employment income, + the deduction less primary Class 1 contributions equals the income tax on + employed earnings that Pension Credit computes + (pension_credit_employment_income_tax), wherever the Council Tax + Reduction applicant and partner are the Pension Credit claimant and + partner.""" + simulation = simulate(households) + schemes(simulation, households) + same_people = np.asarray( + simulation.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + ) == np.asarray(simulation.calculate("is_claimant_or_partner", YEAR)) + assert same_people.all() + deduction = benunit_values(simulation, DEDUCTION) + class_1 = applicant_total(simulation, "ni_class_1_employee") + pension_credit = benunit_values(simulation, "pension_credit_employment_income_tax") + assert np.allclose(deduction - class_1, pension_credit, atol=0.01), ( + households, + deduction, + class_1, + pension_credit, + ) @PROPERTY_SETTINGS -@given(st.lists(working_age_adult(), min_size=1, max_size=4)) -def test_scottish_working_age_deducts_no_tax_without_earnings(adults): - """In Scotland's working-age scheme, with no earnings the deduction for - income tax and National Insurance is nil, however much other taxed income - there is.""" - no_earnings = [ - {**a, "employment_income": 0.0, "self_employment_income": 0.0} for a in adults - ] - simulation = simulate(no_earnings, "SCOTLAND") - deduction = benunit_values(simulation, "council_tax_reduction_tax_deduction") - assert np.all(deduction == 0), (no_earnings, deduction) +@given( + st.lists(household(pensioner=True, earnings=False), min_size=1, max_size=4), + st.sampled_from(UNCOUNTED), + money(60_000), + st.booleans(), +) +def test_pensioner_deduction_ignores_uncounted_income_without_earnings( + households, uncounted, change, partner +): + """Invariant 2. In every pensioner scheme, with no employed earnings, the + deduction does not change when any uncounted income changes, for either + member of a couple. The notional amount on self-employed profit reads the + personal reliefs to which the person is entitled, which the uncounted + income can taper, so the test compares the deduction less that amount.""" + changed = [] + for h in households: + adults = [dict(a) for a in h["benunits"][0]] + who = len(adults) - 1 if partner else 0 + adults[who][uncounted] = adults[who][uncounted] + change + changed.append({**h, "benunits": [adults]}) + before = simulate(households) + after = simulate(changed) + schemes(before, households) + def without_notional(simulation): + notional = np.where( + np.asarray( + [h["country"] == "SCOTLAND" for h in households], dtype=bool + ), + applicant_total( + simulation, "council_tax_reduction_scotland_self_employment_deductions" + ), + applicant_total(simulation, "pension_credit_self_employment_deductions"), + ) + return benunit_values(simulation, DEDUCTION) - notional -@st.composite -def pensioner(draw): - # Pensions reach far enough, with the uncounted income, to cross the - # personal allowance taper. - return dict( - age=draw(st.integers(67, 90)), - state_pension=draw(money(12_000)), - private_pension_income=draw(money(140_000)), - property_income=draw(st.one_of(st.just(0.0), money(15_000))), - savings_interest_income=draw(st.one_of(st.just(0.0), money(20_000))), - dividend_income=draw(st.one_of(st.just(0.0), money(20_000))), - scottish_carer_supplement=draw(st.sampled_from([0.0, 608.40])), + assert np.allclose(without_notional(before), without_notional(after), atol=0.01), ( + households, + uncounted, + change, + without_notional(before), + without_notional(after), ) +@st.composite +def any_household(draw): + h = draw(household()) + if h["country"] == "SCOTLAND" and not h["pensioner"]: + # Children make a Universal Credit award likely. + h["uc"] = draw(st.booleans()) + if h["uc"]: + h["benunits"][0] += [dict(age=4), dict(age=7)] + return h + + @PROPERTY_SETTINGS -@given( - st.lists(pensioner(), min_size=1, max_size=4), - st.sampled_from(sorted(REGIONS)), -) -def test_pensioner_schemes_deduct_no_more_than_the_tax_on_counted_income( - pensioners, country -): - """In the pensioner schemes of England, Wales and Scotland, the income tax - and National Insurance deducted never exceed what the same person would - pay with only the income the scheme counts, nor the whole liability.""" - counted_only = [{**p, **{v: 0.0 for v in UNCOUNTED}} for p in pensioners] - full = simulate(pensioners, country) - alone = simulate(counted_only, country) - assert benunit_values(full, "council_tax_reduction_pensioner").all() - deduction = benunit_values(full, "council_tax_reduction_tax_deduction") - tax_on_counted_income = person_values(alone, WHOLE_TAX) - whole_tax = person_values(full, WHOLE_TAX) - assert np.all(deduction <= tax_on_counted_income + 0.01), ( - pensioners, +@given(st.lists(any_household(), min_size=1, max_size=4)) +def test_deduction_bounds(households): + """Invariant 3. The deduction is never negative. Without self-employment + income it never exceeds the applicant's and partner's income tax before + the winter fuel payment charge plus National Insurance. With it, the + notional amount the schemes set on profit can take the deduction above + that liability, as the law intends, but never above the liability plus + the notional amount.""" + simulation = simulate(households) + schemes(simulation, households) + deduction = benunit_values(simulation, DEDUCTION) + whole = applicant_total(simulation, INCOME_TAX) + applicant_total( + simulation, "national_insurance" + ) + profit = applicant_total(simulation, "self_employment_income") + notional = np.maximum( + applicant_total( + simulation, "council_tax_reduction_scotland_self_employment_deductions" + ), + applicant_total(simulation, "pension_credit_self_employment_deductions"), + ) + assert np.all(deduction >= 0), (households, deduction) + assert np.all((deduction <= whole + 0.01)[profit == 0]), ( + households, deduction, - tax_on_counted_income, + whole, ) - assert np.all(deduction <= whole_tax + 0.01), (pensioners, deduction, whole_tax) - # Taking away the uncounted income leaves the deduction where it was. - deduction_alone = benunit_values(alone, "council_tax_reduction_tax_deduction") - assert np.allclose(deduction, deduction_alone, atol=0.01), ( - pensioners, + assert np.all(deduction <= whole + notional + 0.01), ( + households, deduction, - deduction_alone, + whole, + notional, ) +@PROPERTY_SETTINGS +@given( + st.lists( + household( + countries=("SCOTLAND",), + pensioner=False, + earnings=False, + self_employment=False, + ), + min_size=1, + max_size=4, + ) +) +def test_scottish_working_age_deducts_nothing_without_earnings(households): + """Invariant 4. At working age in Scotland, with no earnings and no + profit, the deduction is nil, however much other taxed income there is.""" + simulation = simulate(households) + schemes(simulation, households) + deduction = benunit_values(simulation, DEDUCTION) + assert np.all(deduction == 0), (households, deduction) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household(countries=("SCOTLAND",), pensioner=False), min_size=1, max_size=4 + ), + money(20_000), + st.booleans(), +) +def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): + """Invariant 5. At working age in Scotland without Universal Credit, + raising either member's employment income never raises Council Tax + Reduction.""" + raised = [] + for h in households: + adults = [dict(a) for a in h["benunits"][0]] + who = len(adults) - 1 if partner else 0 + adults[who]["employment_income"] += rise + raised.append({**h, "benunits": [adults]}) + before = simulate(households) + after = simulate(raised) + schemes(before, households) + reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) + reduction_after = np.asarray(after.calculate("council_tax_reduction", YEAR)) + assert np.all(reduction_after <= reduction_before + 0.01), ( + households, + rise, + partner, + reduction_before, + reduction_after, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(any_household(), min_size=1, max_size=3), + adult(pensioner=False), + adult(pensioner=False), +) +def test_non_dependant_income_never_changes_the_deduction( + households, non_dependant, other_income +): + """Invariant 6. A non-dependant's income never changes the deduction: + adding a working-age adult with their own benefit unit to the household, + and changing that adult's income, leaves the applicant's deduction where + it was.""" + with_non_dependant = [ + {**h, "benunits": h["benunits"] + [[non_dependant]]} for h in households + ] + changed = [ + {**h, "benunits": h["benunits"] + [[other_income]]} for h in households + ] + alone = simulate(households) + schemes(alone, households) + first = simulate(with_non_dependant) + second = simulate(changed) + # The applicant's benunit is the first in each household. + applicant = np.arange(len(households)) * 2 + deduction = benunit_values(alone, DEDUCTION) + for simulation in (first, second): + assert np.allclose( + benunit_values(simulation, DEDUCTION)[applicant], deduction, atol=0.01 + ), (households, non_dependant, other_income) + + CASES = [ - ("SCOTLAND", dict(age=40, employment_income=20_000.0, dividend_income=9_000.0)), - ("SCOTLAND", dict(age=70, private_pension_income=15_000.0)), - ("ENGLAND", dict(age=70, private_pension_income=20_308.0)), - ("WALES", dict(age=70, private_pension_income=20_000.0)), + dict( + country="SCOTLAND", + benunits=[[dict(age=40, employment_income=30_000.0, dividend_income=9_000.0)]], + ), + dict(country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]]), + dict( + country="SCOTLAND", + uc=True, + benunits=[ + [ + dict(age=35, self_employment_income=15_000.0), + dict(age=5), + dict(age=3), + ] + ], + ), + dict(country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]]), + dict( + country="ENGLAND", + benunits=[[dict(age=70, employment_income=20_000.0)]], + ), + dict( + country="WALES", + benunits=[[dict(age=70, self_employment_income=60_000.0)]], + ), ] def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_first(): - """Council Tax Reduction and its tax deduction come out the same whether - the deduction or the reduction is calculated first, so the deduction reads - no benefit that depends on the reduction.""" + """Invariant 7. Council Tax Reduction and its tax deduction come out the + same whether the deduction or the reduction is calculated first.""" variables = [ - "council_tax_reduction_tax_deduction", + DEDUCTION, "council_tax_reduction_applicable_income", "council_tax_reduction", ] - for country, person in CASES: - inputs = {**person, "savings_interest_income": 6_559.0} + for case in CASES: + applicant = case["benunits"][0][0] + inputs = { + **case, + "benunits": [ + [{**applicant, "savings_interest_income": 6_559.0}] + + case["benunits"][0][1:] + ], + } results = [] for order in (variables, variables[::-1]): - simulation = simulate([inputs], country) + simulation = simulate([inputs]) results.append( {v: float(simulation.calculate(v, YEAR).sum()) for v in order} ) - assert results[0] == results[1], (country, results) + assert results[0] == results[1], (case, results) + + +UNCOUNTED_CASES = [ + ("savings_interest_income", 10_000.0), + ("other_investment_income", 10_000.0), + ("dividend_income", 10_000.0), + ("miscellaneous_income", 10_000.0), + ("company_car_benefit", 10_000.0), +] + + +def test_branch_takes_each_uncounted_income_out_of_income_tax(): + """The pensioner schemes' tax on other counted income zeroes each income + they do not count, so it equals the income tax of the same person without + that income. This covers the Scottish Carer Supplement, which the model + computes from the carer's award rather than reading as an input.""" + base = dict(age=70, private_pension_income=30_000.0) + with_income = [ + dict(country="ENGLAND", benunits=[[{**base, variable: amount}]]) + for variable, amount in UNCOUNTED_CASES + ] + without_income = [dict(country="ENGLAND", benunits=[[base]])] + branch = np.asarray( + simulate(with_income).calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + ) + ) + expected = float(simulate(without_income).calculate(INCOME_TAX, YEAR)[0]) + assert np.allclose(branch, expected, atol=0.01), (branch, expected) + tax_with_income = np.asarray(simulate(with_income).calculate(INCOME_TAX, YEAR)) + assert np.all(tax_with_income > expected + 1), tax_with_income + + carer = dict( + age=70, + private_pension_income=14_000.0, + care_hours=35, + would_claim_carers_allowance=True, + ) + carer_household = [dict(country="SCOTLAND", benunits=[[carer]])] + with_supplement = simulate(carer_household) + without_supplement = simulate(carer_household, reform=NO_SCOTTISH_CARER_SUPPLEMENT) + assert with_supplement.calculate("scottish_carer_supplement", YEAR)[0] > 0 + assert np.isclose( + with_supplement.calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + )[0], + without_supplement.calculate(INCOME_TAX, YEAR)[0], + atol=0.01, + ) + assert ( + with_supplement.calculate(INCOME_TAX, YEAR)[0] + > without_supplement.calculate(INCOME_TAX, YEAR)[0] + 1 + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py new file mode 100644 index 0000000000..a50e726e04 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -0,0 +1,54 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pension_credit import employed_earnings_sources +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_earnings_income_tax import ( + income_tax_with_inputs_zeroed, +) + +# Taxed income that council_tax_reduction_applicable_income leaves out on the +# pensioner schemes' general route: the Scottish Carer Supplement (SSI +# 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no +# scheme counts a payment in kind as earnings), savings interest and other +# investment income, and dividends. +PENSIONER_UNCOUNTED_INCOME = [ + "scottish_carer_supplement", + "miscellaneous_income", + "benefits_in_kind", + "savings_interest_income", + "other_investment_income", + "dividend_income", +] + + +class council_tax_reduction_income_tax_on_other_counted_income(Variable): + value_type = float + entity = Person + label = "Income tax on the other income a pensioner Council Tax Reduction counts" + documentation = ( + "The person's income tax before the winter fuel payment charge if " + "nobody in the simulation had employed earnings (employment income " + "and statutory sick, maternity and paternity pay), self-employment " + "income, or the taxed income the pensioner schemes do not count: the " + "Scottish Carer Supplement, miscellaneous income, benefits in kind, " + "savings interest, other investment income and dividends. This is the " + "tax on the rest of the income the pensioner schemes count, with the " + "uncounted income above it. The formula zeroes those inputs on a " + "branch that it drops afterwards, and builds the branch only when " + "someone has uncounted income." + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + if not any( + (person(variable, period) != 0).any() + for variable in PENSIONER_UNCOUNTED_INCOME + ): + return person("pension_credit_income_tax_without_earnings", period) + return income_tax_with_inputs_zeroed( + person.simulation, + period, + employed_earnings_sources(parameters, period) + + ["self_employment_income"] + + PENSIONER_UNCOUNTED_INCOME, + ) + diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py new file mode 100644 index 0000000000..8d2d262dc5 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -0,0 +1,87 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable + + +class council_tax_reduction_scotland_self_employment_deductions(Variable): + value_type = float + entity = Person + label = ( + "Notional tax and contributions on self-employed profit, for Scottish " + "Council Tax Reduction" + ) + documentation = ( + "The income tax and social security contributions that Scotland's " + "Council Tax Reduction schemes deduct from self-employed earnings in " + "place of those actually payable: SSI 2012/303 regs 37 and 38, which " + "SSI 2021/249 reg 51(9) applies at working age where nobody in the " + "family has Universal Credit, and SSI 2012/319 regs 36 and 37 at " + "pension age. The formula charges notional income tax at the basic " + "rate (the Scottish basic rate for a Scottish taxpayer) on the profit " + "less only the personal allowance; Class 4 contributions at the main " + "rate on the profit between the lower and upper profits limits, where " + "Class 4 is payable at all; and, for years before 2024-25, the Class " + "2 contributions payable, which SI 2024/377 omitted from reg 38(2) " + "and reg 37(2) on 6 April 2024. The working-age rule names the " + "personal relief under ITA 2007 s. 35(1); the formula reads it as the " + "allowance s. 35(2) leaves after the taper, because s. 35(2) reduces " + "'the allowance under subsection (1)'. The pension-age rule names the " + "relief under ss. 36 or 37; Finance Act 2014 omitted s. 36, and s. 37 " + "gives the section 35 amount to a person born before 6 April 1938, " + "so the formula reads both as the same tapered s. 35 allowance. A " + "child minder's chargeable income is one-third of the earnings; the " + "model does not identify child minders, so it treats every profit as " + "chargeable income in full." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/37", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/38", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/36", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", + "https://www.legislation.gov.uk/ukpga/2007/3/section/35", + "https://www.legislation.gov.uk/ukpga/2007/3/section/37", + ) + + def formula(person, period, parameters): + profit = max_(0, person("self_employment_income", period)) + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate is the band after the starter rate. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + basic_rate = where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + rates.uk.rates[0], + ) + # "less only the personal relief": no blind person's or marriage + # allowance, and no married couple's allowance. + notional_tax = basic_rate * max_( + 0, profit - person("personal_allowance", period) + ) + class_4 = parameters(period).gov.hmrc.national_insurance.class_4 + lower = class_4.contribution_scale.components[1].threshold + upper = class_4.contribution_scale.components[2].threshold + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.contribution_scale.components[1].rate + * min_(max_(0, profit - lower), upper - lower), + 0, + ) + # Reg 38(2)(a) and reg 37(2)(a) as they stood on 5 April 2024: the + # Class 2 contributions payable, unless chargeable income does not + # exceed the threshold of SSCBA 1992 s. 11. + class_2 = parameters(period).gov.hmrc.national_insurance.class_2 + notional_class_2 = ( + (period.start.year < 2024) + * person("ni_liable", period) + * class_2_liable(profit, class_2) + * class_2.flat_rate + * class_2_contribution_weeks(period.start.year) + ) + return where( + profit > 0, + notional_tax + notional_class_4 + notional_class_2, + 0, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index 147a35ddc5..ee51b04d54 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -1,100 +1,13 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.income_tax import ( - calc_income_tax_with_gift_aid, - first_extended_scottish_threshold, +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_earnings_income_tax import ( + DECISION, + PRE_CHARGE_TAX, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, ) from policyengine_uk.variables.household.demographic.country import Country -# Taxable non-savings income other than earnings. Scotland's working-age scheme -# deducts no tax from it. -NON_EARNINGS_NON_SAVINGS_INCOME = [ - "taxable_pension_income", - "taxable_social_security_income", - "taxable_property_income", - "taxable_miscellaneous_income", -] -# Taxable non-savings income that the pensioner schemes do not take into -# account: the Scottish Carer Supplement (SSI 2012/319 reg 27(1)(j)(xxib)), -# miscellaneous income and benefits in kind, which the model's Council Tax -# Reduction income does not count. -PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME = [ - "scottish_carer_supplement", - "taxable_miscellaneous_income", - "benefits_in_kind", -] -# National Insurance charged on earnings and profits. Class 3 contributions are -# voluntary and not charged on earnings. -NATIONAL_INSURANCE_ON_EARNINGS = ["ni_class_1_employee", "ni_class_2", "ni_class_4"] - - -def tax_below_top_slice(person, period, parameters, top_slice): - """Step 5 tax on a person's non-savings income without its top slice. - - Savings and dividend income are taxed as the highest part of income, so - the Step 5 tax on the rest is earned_income_tax plus property_income_tax. - The top_slice of non-savings income is taken as the highest part of the - non-savings income, and its tax at the person's main rates is taken off. - Allowances and reliefs therefore fall on the income below it, and that - income keeps the personal allowance it would have on its own: the part of - the allowance withdrawn only because the top slice, savings and dividends - lift adjusted net income above the taper threshold is restored to it. - """ - allowance = parameters(period).gov.hmrc.income_tax.allowances.personal_allowance - rates = parameters(period).gov.hmrc.income_tax.rates - non_savings_tax = add(person, period, ["earned_income_tax", "property_income_tax"]) - taxable = person("non_savings_taxable_income", period) - income_above = top_slice + add( - person, period, ["taxable_savings_interest_income", "taxable_dividend_income"] - ) - income_below = max_(0, person("adjusted_net_income", period) - income_above) - allowance_alone = max_( - 0, - np.ceil( - allowance.amount - - max_(0, income_below - allowance.maximum_ANI) * allowance.reduction_rate - ), - ) - restored_allowance = max_(0, allowance_alone - person("personal_allowance", period)) - below = max_(0, taxable - top_slice - restored_allowance) - gross_gift = person("gift_aid_grossed_up", period) - scottish = rates.scotland.rates - first_extended = first_extended_scottish_threshold(scottish) - scottish_bands = len(scottish.thresholds) - - def tax_at_main_rates(income): - return where( - person("pays_scottish_income_tax", period), - calc_income_tax_with_gift_aid( - scottish, income, gross_gift, first_extended, scottish_bands - ), - calc_income_tax_with_gift_aid(rates.uk, income, gross_gift), - ) - - tax_on_top_slice = max_(0, tax_at_main_rates(taxable) - tax_at_main_rates(below)) - # With no taxable income below the top slice there is no tax on it; this - # also clears the rounding left by the subtraction. - return where(below > 0, max_(0, non_savings_tax - tax_on_top_slice), 0) - - -def tax_after_reductions_and_charges(person, period, step_5_part): - """Income Tax attributed to a part of the Step 5 tax. - - The Step 6 reductions come off that part first, as they would if it were - the person's only income. The Step 7 charges other than the winter fuel - payment charge are shared in proportion to the part's share of the Step 5 - tax. The result never exceeds income_tax_before_winter_fuel_payment_charge. - """ - step_5_tax = max_(0, person("income_tax_pre_charges", period)) - reductions = person("income_tax_reductions", period) - charges = person("income_tax_charges", period) - share = np.divide( - step_5_part, - step_5_tax, - out=np.zeros_like(step_5_tax), - where=step_5_tax > 0, - ) - return max_(0, step_5_part - reductions) + charges * min_(share, 1) - class council_tax_reduction_tax_deduction(Variable): value_type = float @@ -103,29 +16,37 @@ class council_tax_reduction_tax_deduction(Variable): documentation = ( "Income tax and National Insurance that Council Tax Reduction income " "deducts, for the applicant and partner " - "(is_council_tax_reduction_applicant_or_partner). Scotland's " - "working-age scheme (SSI 2021/249) deducts them from earnings only, " - "so the formula deducts National Insurance on earnings and profits " - "(Classes 1, 2 and 4) and the Income Tax on earnings, taken as the " - "Step 5 tax that employment and self-employment income bear as the " - "lowest part of non-savings income, with the other non-savings " - "income above them and savings and dividends taxed separately. The " - "pensioner schemes of England (SI 2012/2885), Wales (WSI 2013/3029) " - "and Scotland (SSI 2012/319) disregard tax only on income taken into " - "account, so the formula deducts the same National Insurance and the " - "Step 5 tax on non-savings income less the tax on the Scottish Carer " - "Supplement, miscellaneous income and benefits in kind, taken as the " - "highest part of non-savings income. The income below keeps the " - "personal allowance it would have on its own. Tax on savings and dividend " - "income is never deducted in these schemes. In each, the Step 6 tax " - "reductions come off the tax deducted first, and the Step 7 charges " - "other than the winter fuel payment charge are shared in proportion " - "to the Step 5 tax. The ordering of earnings below other non-savings " - "income is a modelling choice: the regulations name the tax deducted " - "from earnings, not a way to apportion it. The English and Welsh " - "working-age schemes, and families outside Great Britain, deduct the " - "whole Income Tax liability before the winter fuel payment charge " - "and all National Insurance." + "(is_council_tax_reduction_applicant_or_partner). T is their income " + "tax before the winter fuel payment charge, and T(X) the same with " + "every person's X set to nil (pension_credit_income_tax_without_" + "employment_income, pension_credit_income_tax_without_earnings and " + "council_tax_reduction_income_tax_on_other_counted_income). E is " + "employed earnings: employment income and statutory sick, maternity " + "and paternity pay. S is self-employment income. U is the taxed " + "income the pensioner schemes do not count. " + "Scotland's working-age scheme (SSI 2021/249) deducts tax from " + "earnings only. Where nobody in the family has Universal Credit, the " + "formula deducts T less T(E), never negative, primary Class 1 " + "contributions, and the notional tax and contributions on profit " + "(council_tax_reduction_scotland_self_employment_deductions; reg " + "51(9)). With Universal Credit it deducts T less T(E, S), never " + "negative, and the Class 1, 2 and 4 contributions charged (regs " + "49(6)(b) and 51(2) step 3). The pensioner schemes of England (SI " + "2012/2885), Wales (WSI 2013/3029) and Scotland (SSI 2012/319) " + "disregard the tax deducted from earnings and the tax payable on " + "income taken into account, so the formula deducts T less T(E), " + "never negative; T(E, S, U), the tax on the other counted income " + "with the uncounted income above it; the notional tax and " + "contributions on profit (pension_credit_self_employment_deductions " + "in England and Wales, the Scottish variable in Scotland); and " + "primary Class 1 contributions. Earnings are the top slice of income " + "and self-employed profit sits beneath them, so the tax on earnings " + "includes any tax they add on savings and dividends and through the " + "marriage allowance. Benefits in kind stay in both terms of each " + "difference, since no scheme counts a payment in kind as earnings. " + "The English and Welsh working-age schemes, and families outside " + "Great Britain, deduct the whole income tax before the winter fuel " + "payment charge and all National Insurance. " + DECISION ) definition_period = YEAR unit = GBP @@ -134,54 +55,97 @@ class council_tax_reduction_tax_deduction(Variable): "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/30", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/24", ] def formula(benunit, period, parameters): person = benunit.members members = person("is_council_tax_reduction_applicant_or_partner", period) + + def total(values): + return benunit.sum(values * members) + country = benunit.household("country", period) + scotland = country == Country.SCOTLAND in_great_britain = ( - (country == Country.ENGLAND) - | (country == Country.SCOTLAND) - | (country == Country.WALES) + (country == Country.ENGLAND) | scotland | (country == Country.WALES) ) pensioner = benunit("council_tax_reduction_pensioner", period) - scottish_working_age = (country == Country.SCOTLAND) & ~pensioner pensioner_scheme = in_great_britain & pensioner - - whole_tax = add( - person, - period, - ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], - ) - ni_on_earnings = add(person, period, NATIONAL_INSURANCE_ON_EARNINGS) - earnings_step_5_tax = tax_below_top_slice( - person, - period, - parameters, - add(person, period, NON_EARNINGS_NON_SAVINGS_INCOME), + scottish_working_age = scotland & ~pensioner + with_universal_credit = scottish_working_age & applicant_has_universal_credit( + benunit, period ) - counted_step_5_tax = tax_below_top_slice( - person, - period, - parameters, - add(person, period, PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME), + without_universal_credit = scottish_working_age & ~with_universal_credit + + income_tax = person(PRE_CHARGE_TAX, period) + tax = total(income_tax) + whole_liability = tax + total(person("national_insurance", period)) + class_1 = total(person("ni_class_1_employee", period)) + zeros = np.zeros_like(tax) + + # Each branch of income tax is read only where a scheme uses it. + if (without_universal_credit | pensioner_scheme).any(): + tax_without_employment = total( + person("pension_credit_income_tax_without_employment_income", period) + ) + else: + tax_without_employment = tax + tax_on_employed_earnings = max_(0, tax - tax_without_employment) + + if with_universal_credit.any(): + tax_without_earnings = total( + person("pension_credit_income_tax_without_earnings", period) + ) + else: + tax_without_earnings = tax + uc_deduction = ( + max_(0, tax - tax_without_earnings) + + class_1 + + total(add(person, period, ["ni_class_2", "ni_class_4"])) ) - tax_on_earnings = ( - tax_after_reductions_and_charges(person, period, earnings_step_5_tax) - + ni_on_earnings + + scottish_notional = ( + total( + person( + "council_tax_reduction_scotland_self_employment_deductions", + period, + ) + ) + if (scotland & (without_universal_credit | pensioner_scheme)).any() + else zeros ) - tax_on_counted_income = ( - tax_after_reductions_and_charges(person, period, counted_step_5_tax) - + ni_on_earnings + no_uc_deduction = tax_on_employed_earnings + class_1 + scottish_notional + + if pensioner_scheme.any(): + tax_on_other_counted_income = total( + person( + "council_tax_reduction_income_tax_on_other_counted_income", + period, + ) + ) + # SI 2012/2885 Sch 1 para 30 and WSI 2013/3029 Sch 1 para 24 price + # the notional tax as Pension Credit does. + england_and_wales_notional = total( + person("pension_credit_self_employment_deductions", period) + ) + else: + tax_on_other_counted_income = zeros + england_and_wales_notional = zeros + notional = where(scotland, scottish_notional, england_and_wales_notional) + pensioner_deduction = ( + tax_on_employed_earnings + tax_on_other_counted_income + notional + class_1 ) - person_scottish_working_age = benunit.project(scottish_working_age) - person_pensioner_scheme = benunit.project(pensioner_scheme) - deduction = select( - [person_scottish_working_age, person_pensioner_scheme], - [tax_on_earnings, tax_on_counted_income], - default=whole_tax, + + return select( + [with_universal_credit, without_universal_credit, pensioner_scheme], + [uc_deduction, no_uc_deduction, pensioner_deduction], + default=whole_liability, ) - return benunit.sum(deduction * members) From 3d0bf96731a6bd8eee47af0481d067a63131388e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:56:09 +0000 Subject: [PATCH 03/19] Test the Council Tax Reduction tax split against Pension Credit, and its cliffs Restate the property tests for option (a): a differential test against pension_credit_employment_income_tax, the pensioner deduction's independence from uncounted income, bounds with the notional amount, and the Marriage Allowance and savings allowance cliffs. Add YAML cases for savings, property, benefits in kind, notional self-employed tax, Universal Credit, Class 2 before 2024-25 and Class 3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- .../council_tax_reduction_tax_deduction.yaml | 3 ++ ...l_tax_reduction_carer_income_properties.py | 12 +++---- ..._tax_reduction_tax_deduction_properties.py | 32 +++++++++++++------ ...tion_income_tax_on_other_counted_income.py | 1 - 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index b681ab26b6..b9d4d84e67 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -231,6 +231,9 @@ # allowance, 1,430 x 20% = 286. The profit adds 3,461.70. Class 4 # charged: 145.80. council_tax_reduction_tax_deduction: 3_607.50 + # 15,000 - 3,607.50, plus Universal Credit 7,190.20 and Child Benefit + # (27.05 + 17.90) x 52 = 2,337.40. The interest is not counted. + council_tax_reduction_applicable_income: 20_920.10 - name: Scotland's working-age scheme counted Class 2 in the notional deduction before 2024-25 period: 2023 diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 6ec86bd612..e8b5775481 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -354,18 +354,18 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( (income_without + without[CTR_TAX][i])[general], atol=0.01, ), (fam, income[general], income_without[general]) - has_earnings = any( - a.get("employment_income", 0.0) > 0 for a in fam["adults"] - ) + has_earnings = any(a.get("employment_income", 0.0) > 0 for a in fam["adults"]) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] # The supplement never lowers the award, even on the Pension Credit # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam if not has_earnings: - assert np.allclose( - income[general], income_without[general], atol=0.01 - ), (fam, income[general], income_without[general]) + assert np.allclose(income[general], income_without[general], atol=0.01), ( + fam, + income[general], + income_without[general], + ) assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 71ddc95699..f9d30f6427 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -216,9 +216,7 @@ def test_pensioner_deduction_ignores_uncounted_income_without_earnings( def without_notional(simulation): notional = np.where( - np.asarray( - [h["country"] == "SCOTLAND" for h in households], dtype=bool - ), + np.asarray([h["country"] == "SCOTLAND" for h in households], dtype=bool), applicant_total( simulation, "council_tax_reduction_scotland_self_employment_deductions" ), @@ -315,7 +313,12 @@ def test_scottish_working_age_deducts_nothing_without_earnings(households): def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): """Invariant 5. At working age in Scotland without Universal Credit, raising either member's employment income never raises Council Tax - Reduction.""" + Reduction, except where the rise crosses a cliff in the income tax + system: losing Marriage Allowance, or the personal savings allowance + falling as the person enters a higher band. Those cliffs raise the tax + the earnings add by more than the rise in pay, as the ruling intends; for + example a Scottish couple whose pay rises from 43,661 to 43,662 loses + Marriage Allowance and its Council Tax Reduction rises by 52.78.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -327,7 +330,14 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p schemes(before, households) reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) reduction_after = np.asarray(after.calculate("council_tax_reduction", YEAR)) - assert np.all(reduction_after <= reduction_before + 0.01), ( + cliff = np.zeros(len(households), dtype=bool) + for variable in ["marriage_allowance", "savings_allowance"]: + cliff |= ~np.isclose( + applicant_total(before, variable), applicant_total(after, variable) + ) + for crossed in cliff: + event("crosses a tax cliff" if crossed else "no tax cliff") + assert np.all((reduction_after <= reduction_before + 0.01) | cliff), ( households, rise, partner, @@ -352,9 +362,7 @@ def test_non_dependant_income_never_changes_the_deduction( with_non_dependant = [ {**h, "benunits": h["benunits"] + [[non_dependant]]} for h in households ] - changed = [ - {**h, "benunits": h["benunits"] + [[other_income]]} for h in households - ] + changed = [{**h, "benunits": h["benunits"] + [[other_income]]} for h in households] alone = simulate(households) schemes(alone, households) first = simulate(with_non_dependant) @@ -373,7 +381,9 @@ def test_non_dependant_income_never_changes_the_deduction( country="SCOTLAND", benunits=[[dict(age=40, employment_income=30_000.0, dividend_income=9_000.0)]], ), - dict(country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]]), + dict( + country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]] + ), dict( country="SCOTLAND", uc=True, @@ -385,7 +395,9 @@ def test_non_dependant_income_never_changes_the_deduction( ] ], ), - dict(country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]]), + dict( + country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]] + ), dict( country="ENGLAND", benunits=[[dict(age=70, employment_income=20_000.0)]], diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index a50e726e04..1375bb3312 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -51,4 +51,3 @@ def formula(person, period, parameters): + ["self_employment_income"] + PENSIONER_UNCOUNTED_INCOME, ) - From f539a569df623827dbae20816d0303ea552d40a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:23:18 +0000 Subject: [PATCH 04/19] Draw Universal Credit families in the Council Tax Reduction tax properties Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- ..._tax_reduction_tax_deduction_properties.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index f9d30f6427..6aeb80bf30 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -141,7 +141,25 @@ def household(draw, countries=tuple(REGIONS), pensioner=None, **kwargs): country = draw(st.sampled_from(countries)) is_pensioner = draw(st.booleans()) if pensioner is None else pensioner adults = draw(st.lists(adult(is_pensioner, **kwargs), min_size=1, max_size=2)) - return dict(country=country, benunits=[adults], pensioner=is_pensioner) + h = dict(country=country, benunits=[adults], pensioner=is_pensioner) + if country == "SCOTLAND" and not is_pensioner and draw(st.booleans()): + # A Universal Credit claim with two children, and earnings, profit, + # pension and property income low enough for an award. Savings + # interest, dividends and the other uncounted income stay as drawn: + # Universal Credit does not count them. + h["uc"] = True + for a in adults: + for variable in ["employment_income", "self_employment_income"]: + a[variable] = min(a[variable], 10_000.0) + a["private_pension_income"] = 0.0 + a["property_income"] = 0.0 + adults += [dict(age=4), dict(age=7)] + return h + + +def grown_ups(people): + """Indices of the adults among a benefit unit's members.""" + return [i for i, p in enumerate(people) if p["age"] >= 18] def schemes(simulation, households): @@ -207,7 +225,7 @@ def test_pensioner_deduction_ignores_uncounted_income_without_earnings( changed = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] - who = len(adults) - 1 if partner else 0 + who = grown_ups(adults)[-1] if partner else 0 adults[who][uncounted] = adults[who][uncounted] + change changed.append({**h, "benunits": [adults]}) before = simulate(households) @@ -235,13 +253,7 @@ def without_notional(simulation): @st.composite def any_household(draw): - h = draw(household()) - if h["country"] == "SCOTLAND" and not h["pensioner"]: - # Children make a Universal Credit award likely. - h["uc"] = draw(st.booleans()) - if h["uc"]: - h["benunits"][0] += [dict(age=4), dict(age=7)] - return h + return draw(household()) @PROPERTY_SETTINGS @@ -322,9 +334,11 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] - who = len(adults) - 1 if partner else 0 + who = grown_ups(adults)[-1] if partner else 0 adults[who]["employment_income"] += rise - raised.append({**h, "benunits": [adults]}) + raised.append({**h, "uc": False, "benunits": [adults]}) + # The invariant is for families without Universal Credit. + households = [{**h, "uc": False} for h in households] before = simulate(households) after = simulate(raised) schemes(before, households) From 7778076a4b611e99978fd44256d7d04a0f6c15c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:24:00 +0000 Subject: [PATCH 05/19] Read the scheme's tax deduction in the CTR route and trading-loss tests The Pension Credit route test computed CTR income with the whole tax liability, and the trading-loss test held income tax, which the CTR branches then read unchanged. Both now use council_tax_reduction_tax_deduction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- ...est_council_tax_reduction_pension_credit_properties.py | 8 ++++++-- policyengine_uk/tests/test_trading_loss_properties.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index 8f1c6c793b..c09d489ca0 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -287,7 +287,11 @@ def situation( # The means tests deduct income tax before the winter fuel payment charge # (ITEPA 2003 s.681I), which recovers a payment rather than taxing the income # they assess. A pensioner over £35,000 of total income is charged it. -MAIN_DEDUCTIONS = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] +# The income tax and National Insurance each scheme allows. Its own tests +# (test_council_tax_reduction_tax_deduction_properties.py and +# council_tax_reduction_tax_deduction.yaml) check it against the law; this file +# checks the routes around it. +MAIN_DEDUCTIONS = ["council_tax_reduction_tax_deduction"] # The pensioner earnings disregards in 2026, written out from the law, not # read from the parameters: £25 a week for a lone parent, £10 with a partner, # £5 otherwise, plus £17.10 (SI 2012/2885 Sch 4 paras 2, 8 and 10; WSI @@ -369,7 +373,7 @@ def benunit_total(names): benunit_total(["child_benefit"]), 0, ) - - benunit_total(MAIN_DEDUCTIONS) + - sum(np.asarray(sim.calculate(v, YEAR))[claimants] for v in MAIN_DEDUCTIONS) - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), ) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 1209ab80a9..b6ce60e6ab 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -945,11 +945,15 @@ def values(sim, variable): # is 5,239.09433 x 6% = 314.345660. Holding both tax allocations gives # PC income 17,809.09433 - 1,047.818866 - 314.345660 - 1,040 = 15,406.93 # on both sides; the child's loss never enters the claimant's earnings. + # Council Tax Reduction reads income tax on branches that zero earnings, + # so holding the person's tax would also hold it on those branches. Hold + # the scheme's deduction from the with-loss side instead. pc_tax = { v: values(with_loss, v) for v in [ "pension_credit_employment_income_tax", "pension_credit_self_employment_income_tax", + "council_tax_reduction_tax_deduction", ] } loss = values(with_loss, "trading_loss") From 4ebd4dfe894e532bd8bf41295c015a61805b2b7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:20:27 +0000 Subject: [PATCH 06/19] Leave the Step 7 charges out of the pensioner Council Tax Reduction deduction T(E, S, U) now zeroes income_tax_charges beside the uncounted income: the High Income Child Benefit Charge recovers Child Benefit, which the pensioner schemes do not count, and the pension annual allowance charge falls on contributions. Add YAML cases for the charge, a working-age partner's Class 1 in a pensioner scheme, reg 37(1)'s personal relief with Marriage Allowance, and the Scottish Marriage Allowance cliff; a property test that a Child Benefit claim leaves the pensioner deduction unchanged; and assert invariant 5 on a simulation with the tax cliffs held, in place of the exemption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HngezwZWa7uwAqaXr --- changelog.d/2090.fixed.md | 2 +- .../council_tax_reduction_tax_deduction.yaml | 265 ++++++++++++++++++ ..._tax_reduction_tax_deduction_properties.py | 84 +++++- ...tion_income_tax_on_other_counted_income.py | 32 ++- ...ion_scotland_self_employment_deductions.py | 17 +- .../council_tax_reduction_tax_deduction.py | 12 +- 6 files changed, 380 insertions(+), 32 deletions(-) diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md index fe34b9d788..d83a5b2418 100644 --- a/changelog.d/2090.fixed.md +++ b/changelog.d/2090.fixed.md @@ -1 +1 @@ -The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement where earnings do not raise it. +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index b9d4d84e67..f0f503e326 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -467,6 +467,204 @@ # 60,000 - 9,486 - 260. council_tax_reduction_applicable_income: 50_254 +- name: England's pensioner scheme deducts no High Income Child Benefit Charge + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Tax on the pension: 70,000 - 12,570 = 57,430 taxable; 37,700 x 20% = + # 7,540 and 19,730 x 40% = 7,892, so 15,432. Child Benefit 27.05 x 52 = + # 1,406.60, 1,406 in whole pounds; the charge takes (70,000 - 60,000) / + # 200 = 50% of it, 703, so income tax before the winter fuel payment + # charge comes to 16,135. The scheme does not count Child Benefit, and the + # charge recovers it, so the charge is not tax payable on income taken + # into account. + income_tax_before_winter_fuel_payment_charge: [16_135, 0] + council_tax_reduction_tax_deduction: 15_432 + # 70,000 - 15,432; the scheme does not count Child Benefit. + council_tax_reduction_applicable_income: 54_568 + +- name: England's pensioner scheme deducts the same tax without a Child Benefit claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: false + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # No Child Benefit, so no charge: (70,000 - 12,570) taxed as above, 15,432. + income_tax_before_winter_fuel_payment_charge: [15_432, 0] + council_tax_reduction_tax_deduction: 15_432 + # 70,000 - 15,432. + council_tax_reduction_applicable_income: 54_568 + +- name: England's pensioner scheme deducts the rise in the Child Benefit charge that pay causes + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + employment_income: 5_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # With the pay: 75,000 - 12,570 = 62,430 taxable; 7,540 + 24,730 x 40% = + # 9,892, so 17,432; the charge takes (75,000 - 60,000) / 200 = 75% of + # 1,406, 1,054 in whole pounds; T = 18,486. Without the pay: 15,432 and + # a charge of 703, T(E) = 16,135. T - T(E) = 2,351: the 2,000 of tax on + # the pay and the 351 rise in the charge. T(E, S, U) leaves out the charge + # the pensioner would pay without the pay: 15,432. No Class 1 above + # pension age. 2,351 + 15,432 = 17,783. + income_tax_before_winter_fuel_payment_charge: [18_486, 0] + council_tax_reduction_tax_deduction: 17_783 + # 75,000 - 17,783 - the 25 a week lone parent's earnings disregard (1,300). + council_tax_reduction_applicable_income: 55_917 + +- name: England's pensioner scheme deducts a Child Benefit charge that pay causes in full + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 50_000 + employment_income: 30_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Without the pay: (50,000 - 12,570) x 20% = 7,486, and adjusted net + # income 50,000 brings no charge. With it: 80,000 - 12,570 = 67,430 + # taxable; the pay adds 270 x 20% = 54 and 29,730 x 40% = 11,892, so + # 11,946; the charge takes (80,000 - 60,000) / 200 = 100% of 1,406. T = + # 7,486 + 11,946 + 1,406 = 20,838; T(E) = 7,486; T(E, S, U) = 7,486. The + # deduction comes to the whole liability. + income_tax_before_winter_fuel_payment_charge: [20_838, 0] + council_tax_reduction_tax_deduction: 20_838 + # 80,000 - 20,838 - 1,300. + council_tax_reduction_applicable_income: 57_862 + +- name: England's pensioner scheme deducts a working-age partner's Class 1 contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + partner: + age: 60 + employment_income: 20_000 + benunits: + benunit: + members: [pensioner, partner] + would_claim_uc: false + households: + household: + members: [pensioner, partner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Para 19(2)(a) deducts the income tax and primary Class 1 deducted from + # the partner's pay. T: the pension bears (20,000 - 12,570) x 20% = 1,486 + # and the pay the same 1,486, so 2,972. Without the pay the partner has + # no income, and the model transfers 1,260 of Marriage Allowance to the + # pensioner, whose tax falls to (20,000 - 12,570 - 1,260) x 20% = 1,234: + # T(E) = 1,234, so the pay adds 2,972 - 1,234 = 1,738. T(E, S, U) = + # 1,234. Class 1 (20,000 - 12,569.96) x 8% = 594.40. 1,738 + 1,234 + + # 594.40 = 3,566.40. + council_tax_reduction_tax_deduction: 3_566.40 + # 40,000 - 3,566.40 - the 10 a week couple's earnings disregard (520). + council_tax_reduction_applicable_income: 35_913.60 + +- name: Scotland's pensioner scheme allows only the personal relief against a profit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + self_employment_income: 30_000 + younger: + age: 68 + private_pension_income: 10_000 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # The younger partner's pension falls within the personal allowance, so + # the model transfers 1,260 of Marriage Allowance to the older partner. + marriage_allowance: [1_260, 0] + # SSI 2012/319 reg 37(1) allows "only the personal relief", so the + # Marriage Allowance does not come off: (30,000 - 12,570) x 20% = 3,486. + # No Class 4 above pension age. T(E, S, U): without the profit the older + # partner has no income and the pension bears no tax, so nil. No + # employed earnings. + council_tax_reduction_tax_deduction: 3_486 + # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). + council_tax_reduction_applicable_income: 35_994 + - name: Wales's working-age scheme still deducts the whole income tax period: 2026 absolute_error_margin: 0.01 @@ -491,3 +689,70 @@ # cover: (30,000 - 12,570) x 20% = 3,486 and Class 1 594.40. council_tax_reduction_tax_deduction: 4_080.40 council_tax_reduction_applicable_income: 25_919.60 + +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,661 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 43_661 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable non-savings income 43,661 - 12,570 = 31,091, within the + # intermediate band, so the earner takes the partner's 1,260 of Marriage + # Allowance, which the model takes off taxable income: 29,831 taxable; + # 3,967 x 19% = 753.73, 12,989 x 20% = 2,597.80 and 12,875 x 21% = + # 2,703.75, so 6,055.28. Without the pay, nil. Class 1 (43,661 - + # 12,569.96) x 8% = 2,487.28. 6,055.28 + 2,487.28 = 8,542.56. + marriage_allowance: [1_260, 0] + council_tax_reduction_tax_deduction: 8_542.56 + # 43,661 - 8,542.56. + council_tax_reduction_applicable_income: 35_118.44 + # 15,000 - 20% x (35,118.44 - the couple's allowance 150.15 x 52 = + # 7,807.80). + council_tax_reduction: 9_537.87 + +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,662 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 43_662 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 31,092 reaches the Scottish higher band, so no Marriage + # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56, so 6,320.09. + # Class 1 (43,662 - 12,569.96) x 8% = 2,487.36. 6,320.09 + 2,487.36 = + # 8,807.45: one pound of pay raises the deduction by 264.89. + marriage_allowance: [0, 0] + council_tax_reduction_tax_deduction: 8_807.45 + # 43,662 - 8,807.45. + council_tax_reduction_applicable_income: 34_854.55 + # 15,000 - 20% x (34,854.55 - 7,807.80): the reduction rises by 52.78. + council_tax_reduction: 9_590.65 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 6aeb80bf30..ed6eada144 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -72,7 +72,9 @@ def maybe(high): def simulate(households, reform=None): """One simulation of households. Each household is a dict with country, benunits (a list of lists of person input dicts; the first benunit is - the Council Tax Reduction applicant's) and, optionally, uc.""" + the Council Tax Reduction applicant's) and, optionally, uc and + child_benefit (whether the applicant's benefit unit claims Child + Benefit).""" situation = {"people": {}, "benunits": {}, "households": {}} for h, household in enumerate(households): names = [] @@ -86,6 +88,10 @@ def simulate(households, reform=None): "members": members, "would_claim_uc": {YEAR: household.get("uc", False)}, } + if b == 0 and "child_benefit" in household: + situation["benunits"][f"b{h}_{b}"]["would_claim_child_benefit"] = { + YEAR: household["child_benefit"] + } names += members country = household["country"] situation["households"][f"h{h}"] = { @@ -251,6 +257,53 @@ def without_notional(simulation): ) +@st.composite +def pensioner_parent_household(draw): + """A pensioner family with one or two children and no employed earnings, + each adult drawing a private pension of up to 120,000, so that the + applicant's adjusted net income often exceeds the 60,000 at which the High + Income Child Benefit Charge starts.""" + h = draw(household(pensioner=True, earnings=False)) + adults = h["benunits"][0] + for a in adults: + a["private_pension_income"] = draw(money(120_000)) + children = [dict(age=draw(st.integers(0, 15)))] + if draw(st.booleans()): + children.append(dict(age=draw(st.integers(0, 15)))) + return {**h, "benunits": [adults + children]} + + +@PROPERTY_SETTINGS +@given(st.lists(pensioner_parent_household(), min_size=1, max_size=4)) +def test_pensioner_deduction_ignores_a_child_benefit_claim(households): + """Invariant 2, for Child Benefit. In every pensioner scheme, with no + employed earnings, the deduction does not change with a claim for Child + Benefit. The pensioner schemes do not count Child Benefit, and the High + Income Child Benefit Charge recovers it, so the charge is not tax payable + on income taken into account. The test compares two simulations of the + same families, one claiming Child Benefit and one not.""" + claiming = [{**h, "child_benefit": True} for h in households] + not_claiming = [{**h, "child_benefit": False} for h in households] + with_claim = simulate(claiming) + without_claim = simulate(not_claiming) + schemes(with_claim, households) + charge = applicant_total(with_claim, "CB_HITC") + for charged in charge > 0: + event("High Income Child Benefit Charge" if charged else "no charge") + assert np.all(benunit_values(with_claim, "child_benefit") > 0) + assert np.all(benunit_values(without_claim, "child_benefit") == 0) + assert np.allclose( + benunit_values(with_claim, DEDUCTION), + benunit_values(without_claim, DEDUCTION), + atol=0.01, + ), ( + households, + charge, + benunit_values(with_claim, DEDUCTION), + benunit_values(without_claim, DEDUCTION), + ) + + @st.composite def any_household(draw): return draw(household()) @@ -325,12 +378,17 @@ def test_scottish_working_age_deducts_nothing_without_earnings(households): def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): """Invariant 5. At working age in Scotland without Universal Credit, raising either member's employment income never raises Council Tax - Reduction, except where the rise crosses a cliff in the income tax - system: losing Marriage Allowance, or the personal savings allowance - falling as the person enters a higher band. Those cliffs raise the tax - the earnings add by more than the rise in pay, as the ruling intends; for - example a Scottish couple whose pay rises from 43,661 to 43,662 loses - Marriage Allowance and its Council Tax Reduction rises by 52.78.""" + Reduction once the income tax system's cliffs are held still. Two cliffs + raise the tax the earnings add by more than the rise in pay, and option + (a) passes both through to the deduction: losing Marriage Allowance, and + the personal savings allowance falling as the person enters a higher + band. A Scottish couple, one partner with no income and council tax of + 15,000, whose pay rises from 43,661 to 43,662 loses Marriage Allowance: + the deduction rises by 264.89 and the reduction by 52.78 (the YAML cases + pin both). The test therefore builds a third simulation of the raised + families with marriage_allowance and savings_allowance held at their + values before the rise, and asserts there, for every family, that the + reduction does not rise.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -341,22 +399,26 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p households = [{**h, "uc": False} for h in households] before = simulate(households) after = simulate(raised) + held = simulate(raised) + cliffs = ["marriage_allowance", "savings_allowance"] + for variable in cliffs: + held.set_input(variable, YEAR, np.asarray(before.calculate(variable, YEAR))) schemes(before, households) reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) - reduction_after = np.asarray(after.calculate("council_tax_reduction", YEAR)) + reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) cliff = np.zeros(len(households), dtype=bool) - for variable in ["marriage_allowance", "savings_allowance"]: + for variable in cliffs: cliff |= ~np.isclose( applicant_total(before, variable), applicant_total(after, variable) ) for crossed in cliff: event("crosses a tax cliff" if crossed else "no tax cliff") - assert np.all((reduction_after <= reduction_before + 0.01) | cliff), ( + assert np.all(reduction_held <= reduction_before + 0.01), ( households, rise, partner, reduction_before, - reduction_after, + reduction_held, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index 1375bb3312..186457263d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -18,6 +18,14 @@ "dividend_income", ] +# The Step 7 charges of ITA 2007 s. 23 other than the winter fuel payment +# charge (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child +# Benefit Charge and the pension annual allowance charge). Neither falls on +# income the pensioner schemes count: the first recovers Child Benefit, which +# council_tax_reduction_applicable_income leaves out on the pensioner route, +# and the second falls on pension contributions. +STEP_7_CHARGES = ["income_tax_charges"] + class council_tax_reduction_income_tax_on_other_counted_income(Variable): value_type = float @@ -29,25 +37,29 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "and statutory sick, maternity and paternity pay), self-employment " "income, or the taxed income the pensioner schemes do not count: the " "Scottish Carer Supplement, miscellaneous income, benefits in kind, " - "savings interest, other investment income and dividends. This is the " - "tax on the rest of the income the pensioner schemes count, with the " - "uncounted income above it. The formula zeroes those inputs on a " - "branch that it drops afterwards, and builds the branch only when " - "someone has uncounted income." + "savings interest, other investment income and dividends. It also " + "leaves out the Step 7 charges of ITA 2007 s. 23 that " + "income_tax_charges holds, the High Income Child Benefit Charge and " + "the pension annual allowance charge: the first recovers Child " + "Benefit, which the pensioner schemes do not count, and the second " + "falls on pension contributions, so neither is tax payable on income " + "taken into account. This is the tax on the rest of the income the " + "pensioner schemes count, with the uncounted income above it. The " + "formula zeroes those inputs and the charges on a branch that it " + "drops afterwards, and builds the branch only when someone has " + "uncounted income or a charge." ) definition_period = YEAR unit = GBP def formula(person, period, parameters): - if not any( - (person(variable, period) != 0).any() - for variable in PENSIONER_UNCOUNTED_INCOME - ): + zeroed = PENSIONER_UNCOUNTED_INCOME + STEP_7_CHARGES + if not any((person(variable, period) != 0).any() for variable in zeroed): return person("pension_credit_income_tax_without_earnings", period) return income_tax_with_inputs_zeroed( person.simulation, period, employed_earnings_sources(parameters, period) + ["self_employment_income"] - + PENSIONER_UNCOUNTED_INCOME, + + zeroed, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index 8d2d262dc5..d5e754de84 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -16,18 +16,21 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "SSI 2021/249 reg 51(9) applies at working age where nobody in the " "family has Universal Credit, and SSI 2012/319 regs 36 and 37 at " "pension age. The formula charges notional income tax at the basic " - "rate (the Scottish basic rate for a Scottish taxpayer) on the profit " + "rate (the formula reads the Scottish basic rate for a Scottish " + "taxpayer; both rates are 20%) on the profit " "less only the personal allowance; Class 4 contributions at the main " "rate on the profit between the lower and upper profits limits, where " "Class 4 is payable at all; and, for years before 2024-25, the Class " "2 contributions payable, which SI 2024/377 omitted from reg 38(2) " - "and reg 37(2) on 6 April 2024. The working-age rule names the " + "and reg 37(2) on 6 April 2024. SSI 2012/303 reg 38(1) allows the " "personal relief under ITA 2007 s. 35(1); the formula reads it as the " - "allowance s. 35(2) leaves after the taper, because s. 35(2) reduces " - "'the allowance under subsection (1)'. The pension-age rule names the " - "relief under ss. 36 or 37; Finance Act 2014 omitted s. 36, and s. 37 " - "gives the section 35 amount to a person born before 6 April 1938, " - "so the formula reads both as the same tapered s. 35 allowance. A " + "allowance left after the s. 35(2) taper, because s. 35(2) reduces " + "'the allowance under subsection (1)'. SSI 2012/319 reg 37(1) allows " + "the relief under ss. 36 or 37. Parliament omitted s. 36 (Finance Act " + "2014), and s. 37 gives the section 35 amount only to a person born " + "before 6 April 1938; read literally, the reference gives nobody " + "younger any relief. The formula reads it as the same tapered s. 35 " + "allowance for every applicant. A " "child minder's chargeable income is one-third of the earnings; the " "model does not identify child minders, so it treats every profit as " "chargeable income in full." diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index ee51b04d54..62a83eacb7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -36,7 +36,11 @@ class council_tax_reduction_tax_deduction(Variable): "disregard the tax deducted from earnings and the tax payable on " "income taken into account, so the formula deducts T less T(E), " "never negative; T(E, S, U), the tax on the other counted income " - "with the uncounted income above it; the notional tax and " + "with the uncounted income above it and without the Step 7 charges in " + "income_tax_charges (the High Income Child Benefit Charge recovers " + "Child Benefit, which these schemes do not count, and the pension " + "annual allowance charge falls on contributions, so neither is tax " + "payable on income taken into account); the notional tax and " "contributions on profit (pension_credit_self_employment_deductions " "in England and Wales, the Scottish variable in Scotland); and " "primary Class 1 contributions. Earnings are the top slice of income " @@ -131,8 +135,10 @@ def total(values): period, ) ) - # SI 2012/2885 Sch 1 para 30 and WSI 2013/3029 Sch 1 para 24 price - # the notional tax as Pension Credit does. + # The formula prices the English and Welsh notional tax with + # Pension Credit's variable, because SI 2012/2885 Sch 1 para 30 and + # WSI 2013/3029 Sch 1 para 24 allow the same personal reliefs (ITA + # 2007 Part 3 Chapters 2, 3 and 3A). england_and_wales_notional = total( person("pension_credit_self_employment_deductions", period) ) From 3c16aea5ae07c928b222408aeef1a58d62f8ab64 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:42:20 +0000 Subject: [PATCH 07/19] Test the Marriage Allowance cliff at pay of 43,663 and an annual allowance charge Move the second Marriage Allowance cliff case from pay of 43,662 to 43,663. At 43,662 the earner's taxable income of 31,092 reaches the Scottish intermediate limit without passing it. The model drops the allowance there only because tax_band tests income < threshold and so counts that income as HIGHER. At 43,663 the earner pays 1 pound at the higher rate and loses the allowance either way. The invariant 5 test now holds marriage_allowance and savings_allowance on both sides of the pay rise. income_tax_with_inputs_zeroed keeps stored inputs on its branches, so when the test held them on the raised side alone, it also held them on that side's branch without the pay. Add a pensioner with a pension annual allowance charge: the formula leaves the charge out of the tax on the other counted income, as it does the High Income Child Benefit Charge. Name the actor in comments and docstrings this pull request adds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHf6NVfdP4sQfNWoZ5U --- .../council_tax_reduction_tax_deduction.yaml | 73 ++++++++++++++----- ..._tax_reduction_tax_deduction_properties.py | 39 ++++++---- ...tion_income_tax_on_other_counted_income.py | 25 ++++--- ...ion_scotland_self_employment_deductions.py | 14 ++-- .../council_tax_reduction_tax_deduction.py | 16 ++-- 5 files changed, 111 insertions(+), 56 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index f0f503e326..c7a0013e36 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -493,9 +493,9 @@ # 7,540 and 19,730 x 40% = 7,892, so 15,432. Child Benefit 27.05 x 52 = # 1,406.60, 1,406 in whole pounds; the charge takes (70,000 - 60,000) / # 200 = 50% of it, 703, so income tax before the winter fuel payment - # charge comes to 16,135. The scheme does not count Child Benefit, and the - # charge recovers it, so the charge is not tax payable on income taken - # into account. + # charge comes to 16,135. The scheme does not count Child Benefit, and + # through the charge HMRC recovers it, so the formula treats the charge as + # not tax payable on income taken into account. income_tax_before_winter_fuel_payment_charge: [16_135, 0] council_tax_reduction_tax_deduction: 15_432 # 70,000 - 15,432; the scheme does not count Child Benefit. @@ -598,6 +598,39 @@ # 80,000 - 20,838 - 1,300. council_tax_reduction_applicable_income: 57_862 +- name: England's pensioner scheme deducts no pension annual allowance charge + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + personal_pension_contributions: 70_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The pensioner has no earnings, so the model gives relief on 3,600 of + # the contributions: 70,000 - 3,600 - 12,570 = 53,830 taxable; 37,700 x + # 20% = 7,540 and 16,130 x 40% = 6,452, so 13,992. The contributions + # exceed the 60,000 annual allowance by 10,000, and the model charges that + # amount at the higher rate, 40%, so the charge comes to 4,000 and income + # tax before the winter fuel payment charge to 17,992. + personal_pension_contributions_tax: 4_000 + income_tax_before_winter_fuel_payment_charge: 17_992 + # The charge falls on the contributions, not on income the scheme + # counts, so the formula leaves it out of T(E, S, U): the deduction comes + # to the 13,992 of tax on the pension. + council_tax_reduction_tax_deduction: 13_992 + - name: England's pensioner scheme deducts a working-age partner's Class 1 contributions period: 2026 absolute_error_margin: 0.01 @@ -620,8 +653,9 @@ region: NORTH_WEST council_tax: 2_000 output: - # Para 19(2)(a) deducts the income tax and primary Class 1 deducted from - # the partner's pay. T: the pension bears (20,000 - 12,570) x 20% = 1,486 + # Under para 19(2)(a), the council deducts the income tax and primary + # Class 1 deducted from the partner's pay. T: the pension bears (20,000 - + # 12,570) x 20% = 1,486 # and the pay the same 1,486, so 2,972. Without the pay the partner has # no income, and the model transfers 1,260 of Marriage Allowance to the # pensioner, whose tax falls to (20,000 - 12,570 - 1,260) x 20% = 1,234: @@ -656,8 +690,9 @@ # The younger partner's pension falls within the personal allowance, so # the model transfers 1,260 of Marriage Allowance to the older partner. marriage_allowance: [1_260, 0] - # SSI 2012/319 reg 37(1) allows "only the personal relief", so the - # Marriage Allowance does not come off: (30,000 - 12,570) x 20% = 3,486. + # The Scottish Ministers allow "only the personal relief" (SSI 2012/319 + # reg 37(1)), so the Marriage Allowance does not come off: (30,000 - + # 12,570) x 20% = 3,486. # No Class 4 above pension age. T(E, S, U): without the profit the older # partner has no income and the pension bears no tax, so nil. No # employed earnings. @@ -725,14 +760,14 @@ # 7,807.80). council_tax_reduction: 9_537.87 -- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,662 +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,663 period: 2026 absolute_error_margin: 0.01 input: people: earner: age: 40 - employment_income: 43_662 + employment_income: 43_663 partner: age: 40 benunits: @@ -746,13 +781,15 @@ region: SCOTLAND council_tax: 15_000 output: - # Taxable 31,092 reaches the Scottish higher band, so no Marriage - # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56, so 6,320.09. - # Class 1 (43,662 - 12,569.96) x 8% = 2,487.36. 6,320.09 + 2,487.36 = - # 8,807.45: one pound of pay raises the deduction by 264.89. + # Taxable 31,093 exceeds the Scottish intermediate limit of 31,092, so + # the earner pays 1 pound at the higher rate and gets no Marriage + # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56 + 1 x 42% = + # 0.42, so 6,320.51. Class 1 (43,663 - 12,569.96) x 8% = 2,487.44. + # 6,320.51 + 2,487.44 = 8,807.95: two pounds of pay raise the deduction + # by 265.39. marriage_allowance: [0, 0] - council_tax_reduction_tax_deduction: 8_807.45 - # 43,662 - 8,807.45. - council_tax_reduction_applicable_income: 34_854.55 - # 15,000 - 20% x (34,854.55 - 7,807.80): the reduction rises by 52.78. - council_tax_reduction: 9_590.65 + council_tax_reduction_tax_deduction: 8_807.95 + # 43,663 - 8,807.95. + council_tax_reduction_applicable_income: 34_855.05 + # 15,000 - 20% x (34,855.05 - 7,807.80): the reduction rises by 52.68. + council_tax_reduction: 9_590.55 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index ed6eada144..514cf1d7bd 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -278,10 +278,11 @@ def pensioner_parent_household(draw): def test_pensioner_deduction_ignores_a_child_benefit_claim(households): """Invariant 2, for Child Benefit. In every pensioner scheme, with no employed earnings, the deduction does not change with a claim for Child - Benefit. The pensioner schemes do not count Child Benefit, and the High - Income Child Benefit Charge recovers it, so the charge is not tax payable - on income taken into account. The test compares two simulations of the - same families, one claiming Child Benefit and one not.""" + Benefit. The pensioner schemes do not count Child Benefit, and through the + High Income Child Benefit Charge HMRC recovers it, so the formula treats + the charge as not tax payable on income taken into account. The test + compares two simulations of the same families, one claiming Child Benefit + and one not.""" claiming = [{**h, "child_benefit": True} for h in households] not_claiming = [{**h, "child_benefit": False} for h in households] with_claim = simulate(claiming) @@ -383,12 +384,15 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p (a) passes both through to the deduction: losing Marriage Allowance, and the personal savings allowance falling as the person enters a higher band. A Scottish couple, one partner with no income and council tax of - 15,000, whose pay rises from 43,661 to 43,662 loses Marriage Allowance: - the deduction rises by 264.89 and the reduction by 52.78 (the YAML cases - pin both). The test therefore builds a third simulation of the raised - families with marriage_allowance and savings_allowance held at their - values before the rise, and asserts there, for every family, that the - reduction does not rise.""" + 15,000, whose pay rises from 43,661 to 43,663 loses Marriage Allowance: + the deduction rises by 265.39 and the reduction by 52.68 (the YAML cases + pin both). The test therefore holds marriage_allowance and + savings_allowance at their values before the rise in two more + simulations, one of the families before the rise and one of the raised + families, and asserts, for every family, that the reduction in the second + does not exceed the reduction in the first. Because + income_tax_with_inputs_zeroed keeps stored inputs on its branches, the + branch without the pay then reads the same held values on both sides.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -399,12 +403,19 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p households = [{**h, "uc": False} for h in households] before = simulate(households) after = simulate(raised) + # income_tax_with_inputs_zeroed keeps stored inputs on its branches, so + # hold the cliffs on both sides. + held_before = simulate(households) held = simulate(raised) cliffs = ["marriage_allowance", "savings_allowance"] for variable in cliffs: - held.set_input(variable, YEAR, np.asarray(before.calculate(variable, YEAR))) + values = np.asarray(before.calculate(variable, YEAR)) + held_before.set_input(variable, YEAR, values) + held.set_input(variable, YEAR, values) schemes(before, households) - reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) + reduction_held_before = np.asarray( + held_before.calculate("council_tax_reduction", YEAR) + ) reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) cliff = np.zeros(len(households), dtype=bool) for variable in cliffs: @@ -413,11 +424,11 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p ) for crossed in cliff: event("crosses a tax cliff" if crossed else "no tax cliff") - assert np.all(reduction_held <= reduction_before + 0.01), ( + assert np.all(reduction_held <= reduction_held_before + 0.01), ( households, rise, partner, - reduction_before, + reduction_held_before, reduction_held, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index 186457263d..b6276d5ee9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -18,12 +18,14 @@ "dividend_income", ] -# The Step 7 charges of ITA 2007 s. 23 other than the winter fuel payment -# charge (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child -# Benefit Charge and the pension annual allowance charge). Neither falls on -# income the pensioner schemes count: the first recovers Child Benefit, which -# council_tax_reduction_applicable_income leaves out on the pensioner route, -# and the second falls on pension contributions. +# The Step 7 charges of ITA 2007 s. 23 that income_tax_charges holds, +# other than the winter fuel payment charge +# (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child +# Benefit Charge and the pension annual allowance charge). Neither falls +# on income the pensioner schemes count: through the first, HMRC +# recovers Child Benefit, which council_tax_reduction_applicable_income +# leaves out on the pensioner route, and the second falls on pension +# contributions. STEP_7_CHARGES = ["income_tax_charges"] @@ -40,11 +42,12 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "savings interest, other investment income and dividends. It also " "leaves out the Step 7 charges of ITA 2007 s. 23 that " "income_tax_charges holds, the High Income Child Benefit Charge and " - "the pension annual allowance charge: the first recovers Child " - "Benefit, which the pensioner schemes do not count, and the second " - "falls on pension contributions, so neither is tax payable on income " - "taken into account. This is the tax on the rest of the income the " - "pensioner schemes count, with the uncounted income above it. The " + "the pension annual allowance charge: through the first, HMRC " + "recovers Child Benefit, which the pensioner schemes do not count, " + "and the second falls on pension contributions, so the formula " + "treats neither as tax payable on income taken into account. This is " + "the tax on the rest of the income the pensioner schemes count, with " + "the uncounted income above it. The " "formula zeroes those inputs and the charges on a branch that it " "drops afterwards, and builds the branch only when someone has " "uncounted income or a charge." diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index d5e754de84..7f69c57f22 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -21,12 +21,14 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "less only the personal allowance; Class 4 contributions at the main " "rate on the profit between the lower and upper profits limits, where " "Class 4 is payable at all; and, for years before 2024-25, the Class " - "2 contributions payable, which SI 2024/377 omitted from reg 38(2) " - "and reg 37(2) on 6 April 2024. SSI 2012/303 reg 38(1) allows the " - "personal relief under ITA 2007 s. 35(1); the formula reads it as the " - "allowance left after the s. 35(2) taper, because s. 35(2) reduces " - "'the allowance under subsection (1)'. SSI 2012/319 reg 37(1) allows " - "the relief under ss. 36 or 37. Parliament omitted s. 36 (Finance Act " + "2 contributions payable, which the Treasury omitted from reg 38(2) " + "and reg 37(2) from 6 April 2024 (SI 2024/377). At working age, the " + "Scottish Ministers allow the personal relief under ITA 2007 s. " + "35(1) (SSI 2012/303 reg 38(1)); the formula reads it as the " + "allowance left after the taper, because in s. 35(2) Parliament " + "reduces 'the allowance under subsection (1)'. At pension age, they " + "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " + "Parliament omitted s. 36 (Finance Act " "2014), and s. 37 gives the section 35 amount only to a person born " "before 6 April 1938; read literally, the reference gives nobody " "younger any relief. The formula reads it as the same tapered s. 35 " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index 62a83eacb7..bb4c51b8cf 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -37,10 +37,11 @@ class council_tax_reduction_tax_deduction(Variable): "income taken into account, so the formula deducts T less T(E), " "never negative; T(E, S, U), the tax on the other counted income " "with the uncounted income above it and without the Step 7 charges in " - "income_tax_charges (the High Income Child Benefit Charge recovers " - "Child Benefit, which these schemes do not count, and the pension " - "annual allowance charge falls on contributions, so neither is tax " - "payable on income taken into account); the notional tax and " + "income_tax_charges (through the High Income Child Benefit Charge, " + "HMRC recovers Child Benefit, which these schemes do not count, and " + "the pension annual allowance charge falls on contributions, so the " + "formula treats neither as tax payable on income taken into " + "account); the notional tax and " "contributions on profit (pension_credit_self_employment_deductions " "in England and Wales, the Scottish variable in Scotland); and " "primary Class 1 contributions. Earnings are the top slice of income " @@ -136,9 +137,10 @@ def total(values): ) ) # The formula prices the English and Welsh notional tax with - # Pension Credit's variable, because SI 2012/2885 Sch 1 para 30 and - # WSI 2013/3029 Sch 1 para 24 allow the same personal reliefs (ITA - # 2007 Part 3 Chapters 2, 3 and 3A). + # Pension Credit's variable, because the Secretary of State and + # the Welsh Ministers allow the same personal reliefs (SI + # 2012/2885 Sch 1 para 30; WSI 2013/3029 Sch 1 para 24): those + # under ITA 2007 Part 3 Chapters 2, 3 and 3A. england_and_wales_notional = total( person("pension_credit_self_employment_deductions", period) ) From c10280e5e9b719e9c360b598e8f52f53415643eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:33:53 +0000 Subject: [PATCH 08/19] Drop two statements of intent and rejoin two rewrapped test comments The 15,000 profit case said its notional deduction exceeds the tax paid "as the rule intends", and invariant 3's docstring said the notional amount can take the deduction above the liability "as the law intends". No quoted provision states that intent, so both now give the bound alone. Invariant 3's docstring now matches the invariant as the pull request states it. Rejoin the Class 1 and reg 37(1) comments that the actor fixes left with short lines; no word changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHf6NVfdP4sQfNWoZ5U --- .../council_tax_reduction_tax_deduction.yaml | 22 +++++++++---------- ..._tax_reduction_tax_deduction_properties.py | 11 +++++----- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index c7a0013e36..65551db1af 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -169,7 +169,7 @@ output: # SSI 2012/303 reg 38: (15,000 - 12,570) x 20% = 486 and Class 4 # (15,000 - 12,570) x 6% = 145.80. That exceeds the 461.70 + 145.80 the - # person pays, as the rule intends. + # person pays. council_tax_reduction_tax_deduction: 631.80 # 15,000 - 631.80. council_tax_reduction_applicable_income: 14_368.20 @@ -655,13 +655,12 @@ output: # Under para 19(2)(a), the council deducts the income tax and primary # Class 1 deducted from the partner's pay. T: the pension bears (20,000 - - # 12,570) x 20% = 1,486 - # and the pay the same 1,486, so 2,972. Without the pay the partner has - # no income, and the model transfers 1,260 of Marriage Allowance to the - # pensioner, whose tax falls to (20,000 - 12,570 - 1,260) x 20% = 1,234: - # T(E) = 1,234, so the pay adds 2,972 - 1,234 = 1,738. T(E, S, U) = - # 1,234. Class 1 (20,000 - 12,569.96) x 8% = 594.40. 1,738 + 1,234 + - # 594.40 = 3,566.40. + # 12,570) x 20% = 1,486 and the pay the same 1,486, so 2,972. Without the + # pay the partner has no income, and the model transfers 1,260 of + # Marriage Allowance to the pensioner, whose tax falls to (20,000 - + # 12,570 - 1,260) x 20% = 1,234: T(E) = 1,234, so the pay adds 2,972 - + # 1,234 = 1,738. T(E, S, U) = 1,234. Class 1 (20,000 - 12,569.96) x 8% = + # 594.40. 1,738 + 1,234 + 594.40 = 3,566.40. council_tax_reduction_tax_deduction: 3_566.40 # 40,000 - 3,566.40 - the 10 a week couple's earnings disregard (520). council_tax_reduction_applicable_income: 35_913.60 @@ -692,10 +691,9 @@ marriage_allowance: [1_260, 0] # The Scottish Ministers allow "only the personal relief" (SSI 2012/319 # reg 37(1)), so the Marriage Allowance does not come off: (30,000 - - # 12,570) x 20% = 3,486. - # No Class 4 above pension age. T(E, S, U): without the profit the older - # partner has no income and the pension bears no tax, so nil. No - # employed earnings. + # 12,570) x 20% = 3,486. No Class 4 above pension age. T(E, S, U): + # without the profit the older partner has no income and the pension + # bears no tax, so nil. No employed earnings. council_tax_reduction_tax_deduction: 3_486 # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). council_tax_reduction_applicable_income: 35_994 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 514cf1d7bd..7b1ac5377e 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -313,12 +313,11 @@ def any_household(draw): @PROPERTY_SETTINGS @given(st.lists(any_household(), min_size=1, max_size=4)) def test_deduction_bounds(households): - """Invariant 3. The deduction is never negative. Without self-employment - income it never exceeds the applicant's and partner's income tax before - the winter fuel payment charge plus National Insurance. With it, the - notional amount the schemes set on profit can take the deduction above - that liability, as the law intends, but never above the liability plus - the notional amount.""" + """Invariant 3. The deduction never falls below zero. Without + self-employment income it never exceeds the applicant's and partner's + income tax before the winter fuel payment charge plus National Insurance. + With it, the notional amount on profit can take the deduction above that + liability, but never above the liability plus the notional amount.""" simulation = simulate(households) schemes(simulation, households) deduction = benunit_values(simulation, DEDUCTION) From db3cdae7597c064326b7e0499303f01735479c45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:37 +0000 Subject: [PATCH 09/19] Hold the Child Benefit charge and personal allowance steps in invariant 5 The model raises CB_HITC by whole percentage points of the Child Benefit and rounds the tapered personal allowance up to whole pounds, so a small rise in pay can raise the tax the earnings add by more than the rise. Invariant 5 now holds CB_HITC and personal_allowance beside the Marriage Allowance and personal savings allowance cliffs, records an event for each kind it crosses, draws council tax of up to 30,000, and always runs three families: a lone parent across a step of the charge, a single person across a step of the personal allowance, and a single person in the starter band. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJWZ6tzriTP45hUip8p --- ..._tax_reduction_tax_deduction_properties.py | 130 +++++++++++++++--- 1 file changed, 109 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 7b1ac5377e..0f358576ce 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -22,7 +22,7 @@ """ import numpy as np -from hypothesis import HealthCheck, event, given, settings +from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -72,9 +72,9 @@ def maybe(high): def simulate(households, reform=None): """One simulation of households. Each household is a dict with country, benunits (a list of lists of person input dicts; the first benunit is - the Council Tax Reduction applicant's) and, optionally, uc and + the Council Tax Reduction applicant's) and, optionally, uc, child_benefit (whether the applicant's benefit unit claims Child - Benefit).""" + Benefit) and council_tax (2,000 where the household gives none).""" situation = {"people": {}, "benunits": {}, "households": {}} for h, household in enumerate(households): names = [] @@ -98,7 +98,7 @@ def simulate(households, reform=None): "members": names, "country": {YEAR: country}, "region": {YEAR: REGIONS[country]}, - "council_tax": {YEAR: 2_000.0}, + "council_tax": {YEAR: household.get("council_tax", 2_000.0)}, "savings": {YEAR: 0.0}, } return Simulation(situation=situation, reform=reform) @@ -367,31 +367,110 @@ def test_scottish_working_age_deducts_nothing_without_earnings(households): assert np.all(deduction == 0), (households, deduction) +@st.composite +def household_with_council_tax(draw, **kwargs): + """A household from household(), with council tax of up to 30,000, so that + families keep a reduction across the whole range of pay.""" + return {**draw(household(**kwargs)), "council_tax": draw(money(30_000))} + + +# The Hypothesis event invariant 5 records for each cliff or step it holds. +CROSSES = { + "marriage_allowance": "crosses the Marriage Allowance cliff", + "savings_allowance": "crosses a personal savings allowance cliff", + "CB_HITC": "crosses a High Income Child Benefit Charge step", + "personal_allowance": "crosses a personal allowance step", +} +# Invariant 5 runs these three families every time. A lone parent with two +# children whose pay of 10,000 rises by 1 takes adjusted net income from +# 64,999.50 to 65,000.50, across a step of the High Income Child Benefit +# Charge (560 to 584). +LONE_PARENT_AT_A_CHARGE_STEP = dict( + country="SCOTLAND", + pensioner=False, + council_tax=2_000.0, + benunits=[ + [ + dict( + age=40, + employment_income=10_000.0, + savings_interest_income=40_000.0, + other_investment_income=14_999.50, + ), + dict(age=4), + dict(age=7), + ] + ], +) +# A single person with pay of 90,000 and savings interest of 20,001.75, whose +# pay rises by 0.50, takes adjusted net income across 110,002, where the +# tapered personal allowance falls from 7,570 to 7,569. +SINGLE_PERSON_AT_A_PERSONAL_ALLOWANCE_STEP = dict( + country="SCOTLAND", + pensioner=False, + council_tax=30_000.0, + benunits=[ + [dict(age=40, employment_income=90_000.0, savings_interest_income=20_001.75)] + ], +) +# A single person whose pay rises from 13,000 to 14,000, within the Scottish +# starter band: the deduction rises by 190 of income tax and 80 of Class 1. +SINGLE_PERSON_IN_THE_STARTER_BAND = dict( + country="SCOTLAND", + pensioner=False, + council_tax=2_000.0, + benunits=[[dict(age=40, employment_income=13_000.0)]], +) + + @PROPERTY_SETTINGS @given( st.lists( - household(countries=("SCOTLAND",), pensioner=False), min_size=1, max_size=4 + household_with_council_tax(countries=("SCOTLAND",), pensioner=False), + min_size=1, + max_size=4, ), money(20_000), st.booleans(), ) +@example(households=[LONE_PARENT_AT_A_CHARGE_STEP], rise=1.0, partner=False) +@example( + households=[SINGLE_PERSON_AT_A_PERSONAL_ALLOWANCE_STEP], rise=0.5, partner=False +) +@example(households=[SINGLE_PERSON_IN_THE_STARTER_BAND], rise=1_000.0, partner=False) def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): """Invariant 5. At working age in Scotland without Universal Credit, raising either member's employment income never raises Council Tax - Reduction once the income tax system's cliffs are held still. Two cliffs - raise the tax the earnings add by more than the rise in pay, and option - (a) passes both through to the deduction: losing Marriage Allowance, and - the personal savings allowance falling as the person enters a higher - band. A Scottish couple, one partner with no income and council tax of - 15,000, whose pay rises from 43,661 to 43,663 loses Marriage Allowance: - the deduction rises by 265.39 and the reduction by 52.68 (the YAML cases - pin both). The test therefore holds marriage_allowance and - savings_allowance at their values before the rise in two more + Reduction once the test holds the income tax system's cliffs and steps + still. Option (a) passes each of them through to the deduction, and each + can raise the tax the earnings add by more than the rise in pay: + - Marriage Allowance: an earner who enters the higher band loses the + allowance, which the model takes off taxable income. A Scottish couple, + one partner with no income and council tax of 15,000, whose pay rises + from 43,661 to 43,663 loses 1,260 of it: the deduction rises by 265.39 + and the reduction by 52.68 (the YAML cases pin both). + - The personal savings allowance: it falls from 1,000 to 500 as a person + enters the higher band, and to nil in the additional band, so a pound + of pay can bring 500 of savings income into tax at 40%, adding 200. + - The High Income Child Benefit Charge: the model charges a whole + percentage point of the Child Benefit for each whole 200 of adjusted + net income above 60,000, so a pound of pay that crosses a step raises + the charge by a percent of the Child Benefit, 23 or 24 pounds for two + children. + - The personal allowance: the model rounds the tapered allowance up to a + whole pound, so a rise in pay of less than 2 can lower the allowance by + a pound, and the tax then rises on the rise plus that pound. + The test holds marriage_allowance, savings_allowance, CB_HITC and + personal_allowance at their values before the rise in two more simulations, one of the families before the rise and one of the raised families, and asserts, for every family, that the reduction in the second does not exceed the reduction in the first. Because income_tax_with_inputs_zeroed keeps stored inputs on its branches, the - branch without the pay then reads the same held values on both sides.""" + branch without the pay then reads the same held values on both sides. The + test draws council tax of up to 30,000, and always runs three families: + a lone parent who crosses a step of the charge, a single person who + crosses a step of the personal allowance, and a single person in the + starter band.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -403,10 +482,15 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p before = simulate(households) after = simulate(raised) # income_tax_with_inputs_zeroed keeps stored inputs on its branches, so - # hold the cliffs on both sides. + # hold the cliffs and steps on both sides. held_before = simulate(households) held = simulate(raised) - cliffs = ["marriage_allowance", "savings_allowance"] + cliffs = [ + "marriage_allowance", + "savings_allowance", + "CB_HITC", + "personal_allowance", + ] for variable in cliffs: values = np.asarray(before.calculate(variable, YEAR)) held_before.set_input(variable, YEAR, values) @@ -416,13 +500,17 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p held_before.calculate("council_tax_reduction", YEAR) ) reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) - cliff = np.zeros(len(households), dtype=bool) + # The model moves each of these in steps, so any change crosses one. + crossed_any = np.zeros(len(households), dtype=bool) for variable in cliffs: - cliff |= ~np.isclose( + crossed = ~np.isclose( applicant_total(before, variable), applicant_total(after, variable) ) - for crossed in cliff: - event("crosses a tax cliff" if crossed else "no tax cliff") + crossed_any |= crossed + if crossed.any(): + event(CROSSES[variable]) + if not crossed_any.all(): + event("crosses no cliff or step") assert np.all(reduction_held <= reduction_held_before + 0.01), ( households, rise, From 534028ce5caf8e978e0aeee4053d7d334b289088 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:37 +0000 Subject: [PATCH 10/19] Pin a step of the Child Benefit charge at working age in Scotland Two cases put a Scottish lone parent with two children on either side of the 25% step of the charge, at pay of 64,999 and 65,000. The pound of pay raises the charge from 560 to 584, the deduction by 24.44 and the reduction by 4.69. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJWZ6tzriTP45hUip8p --- .../council_tax_reduction_tax_deduction.yaml | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 65551db1af..05d1b9cb5f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -791,3 +791,82 @@ council_tax_reduction_applicable_income: 34_855.05 # 15,000 - 20% x (34,855.05 - 7,807.80): the reduction rises by 52.68. council_tax_reduction: 9_590.55 + +- name: Scotland's working-age scheme passes a Child Benefit charge step through at pay of 64,999 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 64_999 + child_1: + age: 4 + child_2: + age: 7 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 64,999 - 12,570 = 52,429: 3,967 x 19% = 753.73, 12,989 x 20% = + # 2,597.80, 14,136 x 21% = 2,968.56 and 21,337 x 42% = 8,961.54, so + # 15,281.63. Child Benefit (27.05 + 17.90) x 52 = 2,337.40, 2,337 in whole + # pounds. Adjusted net income exceeds 60,000 by 4,999, or 24 whole steps + # of 200, so the charge takes 24% of 2,337 = 560.88, 560 in whole pounds. + # T = 15,841.63. Without the pay, nil. Class 1: 37,700 x 8% = 3,016 up to + # the upper earnings limit of 966.73 a week (50,269.96 a year), and + # (64,999 - 50,269.96) x 2% = 294.58, so 3,310.58. 15,841.63 + 3,310.58 = + # 19,152.21. + CB_HITC: [560, 0, 0] + council_tax_reduction_tax_deduction: 19_152.21 + # The model counts the pay and the Child Benefit: 64,999 + 2,337.40 - + # 19,152.21. + council_tax_reduction_applicable_income: 48_184.19 + # 15,000 - 20% x (48,184.19 - the lone parent's allowance 95.55 x 52 = + # 4,968.60). + council_tax_reduction: 6_356.88 + +- name: Scotland's working-age scheme passes a Child Benefit charge step through at pay of 65,000 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 65_000 + child_1: + age: 4 + child_2: + age: 7 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 52,430: one more pound at 42%, so 15,282.05. Adjusted net income + # exceeds 60,000 by 5,000, or 25 steps of 200, so the charge takes 25% of + # 2,337 = 584.25, 584 in whole pounds. T = 15,866.05. Class 1 3,016 + + # (65,000 - 50,269.96) x 2% = 294.60, so 3,310.60. 15,866.05 + 3,310.60 = + # 19,176.65: a pound of pay raises the deduction by 24.44, and the charge + # gives 24 of that. + CB_HITC: [584, 0, 0] + council_tax_reduction_tax_deduction: 19_176.65 + # 65,000 + 2,337.40 - 19,176.65. + council_tax_reduction_applicable_income: 48_160.75 + # 15,000 - 20% x (48,160.75 - 4,968.60): the reduction rises by 4.69. + council_tax_reduction: 6_361.57 From 678a9975d81ac725762bc2f4c15ded82308997b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:37 +0000 Subject: [PATCH 11/19] Name who acts in the older notes on the deduction's rules The tax deduction YAML header, the two property modules' docstrings, the Scottish notional deduction's documentation and the pension-age carer case now name the Scottish Ministers, the Secretary of State, the Welsh Ministers, Parliament or the council as the actor, and keep each citation. The edits sit outside every test function, so no test's Hypothesis seed changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJWZ6tzriTP45hUip8p --- ...l_tax_reduction_carer_support_payment.yaml | 24 +++++++------ .../council_tax_reduction_tax_deduction.yaml | 35 ++++++++++--------- ...l_tax_reduction_carer_income_properties.py | 21 +++++------ ..._tax_reduction_tax_deduction_properties.py | 13 +++---- ...ion_scotland_self_employment_deductions.py | 23 ++++++------ 5 files changed, 62 insertions(+), 54 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index c4c5f44b96..37cc2e7206 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -10,13 +10,14 @@ # Neither counts the Scottish Carer Supplement, paid with CSP from 15 March # 2026: reg 57(1) does not list it and reg 27(1)(j)(xxib) excepts it. In law # the supplement's tax does not reach Council Tax Reduction either: at working -# age SSI 2021/249 deducts tax only from earnings, and at pension age SSI -# 2012/319 reg 31(12) disregards the tax payable on income taken into -# account, which the supplement is not. The model deducts only the tax each -# scheme allows (council_tax_reduction_tax_deduction). At working age that is -# the tax earnings add, and at pension age the tax on the counted income with -# the supplement above it, so without earnings the supplement does not reach -# the model's Council Tax Reduction income. +# age the Scottish Ministers let the council deduct tax only from earnings (SSI +# 2021/249), and at pension age the council disregards the tax payable on +# income taken into account (SSI 2012/319 reg 31(12)), which the supplement is +# not. The model deducts only the tax each scheme allows +# (council_tax_reduction_tax_deduction). At working age that is the tax +# earnings add, and at pension age the tax on the counted income with the +# supplement above it, so without earnings the supplement does not reach the +# model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, @@ -277,10 +278,11 @@ # first 3,967 at the Scottish starter rate of 19% and 2,566.80 at 20%. # The supplement adds 608.40 x 20% = 121.68 of that tax. income_tax: 3_967 * 0.19 + 2_566.80 * 0.20 - # SSI 2012/319 reg 31(12) disregards only the tax on income taken into - # account. The supplement is not, so its tax, as the highest part of - # income, is not deducted: 14,000 + 4,495.40 - 12,570 = 5,925.40 taxable, - # 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so 1,145.41. + # Under SSI 2012/319 reg 31(12), the council disregards only the tax on + # income taken into account. The supplement is not, so the formula leaves + # out its tax, as the highest part of income: 14,000 + 4,495.40 - 12,570 = + # 5,925.40 taxable, 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so + # 1,145.41. council_tax_reduction_tax_deduction: 3_967 * 0.19 + 1_958.40 * 0.20 # 14,000 + 4,495.40 - 1,145.41: neither the supplement nor its tax counts. council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_145.41 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 05d1b9cb5f..d15515a265 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -1,20 +1,23 @@ # Income tax and National Insurance deducted from Council Tax Reduction income. -# - Scotland, working age (SSI 2021/249): reg 50(3)(a) reads "Earnings do not -# include— (a) any amount deducted from them by way of income tax or -# primary Class 1 contributions under the 1992 Act". The Scottish Ministers -# set the same rule with Universal Credit (regs 49(6)(b) and 51(2) step 3) -# and gave no tax deduction for unearned income (regs 57 to 65). Without -# Universal Credit, reg 51(9) applies SSI 2012/303 regs 37 and 38, which -# price the tax and contributions on self-employed profit notionally: the -# basic rate on chargeable income less only the personal allowance, and -# Class 4 at the main rate between the lower and upper limits. -# - Pension age: SSI 2012/319 reg 31(12), "In the case of any income taken -# into account for the purpose of calculating a person's income, any amount -# payable by way of tax must be disregarded"; SI 2012/2885 Sch 1 para 17(13) -# and WSI 2013/3029 Sch 1 para 11(13) read the same. Reg 33(2)(a), para -# 19(2)(a) and para 13(2)(a) deduct the income tax and Class 1 deducted from -# earnings, and reg 37, para 30 and para 24 price self-employed profit -# notionally, as at working age. +# - Scotland, working age (SSI 2021/249): in reg 50(3)(a), the Scottish +# Ministers provide that "Earnings do not include— (a) any amount deducted +# from them by way of income tax or primary Class 1 contributions under the +# 1992 Act". They set the same rule with Universal Credit (regs 49(6)(b) and +# 51(2) step 3) and gave no tax deduction for unearned income (regs 57 to +# 65). Without Universal Credit, in reg 51(9) they apply SSI 2012/303 regs 37 +# and 38, under which the council prices the tax and contributions on +# self-employed profit notionally: the basic rate on chargeable income less +# only the personal allowance, and Class 4 at the main rate between the +# lower and upper limits. +# - Pension age: in SSI 2012/319 reg 31(12), the Scottish Ministers provide +# that "In the case of any income taken into account for the purpose of +# calculating a person's income, any amount payable by way of tax must be +# disregarded"; the Secretary of State (SI 2012/2885 Sch 1 para 17(13)) and +# the Welsh Ministers (WSI 2013/3029 Sch 1 para 11(13)) make the same rule. +# Under reg 33(2)(a), para 19(2)(a) and para 13(2)(a), the council deducts +# the income tax and Class 1 deducted from earnings, and under reg 37, para +# 30 and para 24 it prices self-employed profit notionally, as at working +# age. # No provision says how to split one income tax liability. The model follows # the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 # (option (a)): the tax on earnings is the extra tax the earnings cause, with diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index e8b5775481..ade0b6792c 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -13,16 +13,17 @@ Neither counts the Scottish Carer Supplement, paid with Carer Support Payment from 15 March 2026: reg 57(1) is a closed list that leaves it out, and reg 27(1)(j)(xxib) excepts it. Nor does the tax on the supplement count: at -working age SSI 2021/249 deducts income tax only from earnings (Part 6 -Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax -payable on income taken into account, which the supplement is not. The -supplement is taxable (SI 2026/93). The model deducts the tax and National -Insurance each scheme allows (council_tax_reduction_tax_deduction). At working -age that includes the extra tax earnings cause, with the earnings as the top -slice of income (the choice settled for Pension Credit on -PolicyEngine/policyengine-uk#2019, option (a)), so the supplement beneath the -earnings can raise the tax the earnings add. At pension age the formula -deducts the tax on the counted income with the supplement above it. +working age the Scottish Ministers let the council deduct income tax only from +earnings (SSI 2021/249 Part 6 Chapter 3), and at pension age the council +disregards the tax payable on income taken into account (SSI 2012/319 reg +31(12)), which the supplement is not. The supplement is taxable (SI 2026/93). +The model deducts the tax and National Insurance each scheme allows +(council_tax_reduction_tax_deduction). At working age that includes the extra +tax earnings cause, with the earnings as the top slice of income (the choice +settled for Pension Credit on PolicyEngine/policyengine-uk#2019, option (a)), +so the supplement beneath the earnings can raise the tax the earnings add. At +pension age the formula deducts the tax on the counted income with the +supplement above it. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 0f358576ce..fa30fa08de 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -1,11 +1,12 @@ """Income tax and National Insurance deducted from Council Tax Reduction income. -Scotland's working-age scheme (the Council Tax Reduction (Scotland) Regulations -2021, SSI 2021/249) takes income tax and primary Class 1 contributions off -earnings only (regs 49(6)(b), 50(3)(a) and 51(2)); its unearned income rules -(regs 57 to 65) deduct no tax. Without Universal Credit, reg 51(9) applies the -notional tax and contributions of SSI 2012/303 reg 38 to self-employed profit. -The pensioner schemes disregard tax only on income taken into account and on +In Scotland's working-age scheme (the Council Tax Reduction (Scotland) +Regulations 2021, SSI 2021/249), the Scottish Ministers let the council take +income tax and primary Class 1 contributions off earnings only (regs 49(6)(b), +50(3)(a) and 51(2)), and give no tax deduction against unearned income (regs 57 +to 65). Without Universal Credit, in reg 51(9) they apply the notional tax and +contributions of SSI 2012/303 reg 38 to self-employed profit. In the pensioner +schemes, the council disregards tax only on income taken into account and on earnings: SSI 2012/319 regs 31(12), 33(2)(a) and 37 in Scotland, SI 2012/2885 Sch 1 paras 17(13), 19(2)(a) and 30 in England, and WSI 2013/3029 Sch 1 paras 11(13), 13(2)(a) and 24 in Wales. diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index 7f69c57f22..59d3918bb9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -10,12 +10,13 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "Council Tax Reduction" ) documentation = ( - "The income tax and social security contributions that Scotland's " - "Council Tax Reduction schemes deduct from self-employed earnings in " - "place of those actually payable: SSI 2012/303 regs 37 and 38, which " - "SSI 2021/249 reg 51(9) applies at working age where nobody in the " - "family has Universal Credit, and SSI 2012/319 regs 36 and 37 at " - "pension age. The formula charges notional income tax at the basic " + "The income tax and social security contributions that a Scottish " + "council deducts from self-employed earnings for Council Tax " + "Reduction in place of those actually payable: under SSI 2012/303 " + "regs 37 and 38, which the Scottish Ministers apply in SSI 2021/249 " + "reg 51(9) at working age where nobody in the family has Universal " + "Credit, and under SSI 2012/319 regs 36 and 37 at pension age. The " + "formula charges notional income tax at the basic " "rate (the formula reads the Scottish basic rate for a Scottish " "taxpayer; both rates are 20%) on the profit " "less only the personal allowance; Class 4 contributions at the main " @@ -29,11 +30,11 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "reduces 'the allowance under subsection (1)'. At pension age, they " "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " "Parliament omitted s. 36 (Finance Act " - "2014), and s. 37 gives the section 35 amount only to a person born " - "before 6 April 1938; read literally, the reference gives nobody " - "younger any relief. The formula reads it as the same tapered s. 35 " - "allowance for every applicant. A " - "child minder's chargeable income is one-third of the earnings; the " + "2014), and in s. 37 gives the section 35 amount only to a person " + "born before 6 April 1938; a council that read the reference " + "literally would allow nobody younger any relief. The formula reads " + "it as the same tapered s. 35 allowance for every applicant. A child " + "minder's chargeable income comes to one-third of the earnings; the " "model does not identify child minders, so it treats every profit as " "chargeable income in full." ) From 3feb308eea20890984f51d3be7864f34a546c843 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:28:30 +0000 Subject: [PATCH 12/19] Give the history and basis of the Scottish pensioner personal relief reading Parliament omitted ITA 2007 s. 36 for 2015-16 (Finance Act 2014 s. 2) and s. 37 for 2016-17 (Finance Act 2015 s. 5), extending s. 35 each time. The documentation now says so and gives the Interpretation Act 1978 s. 17(2)(a) basis for reading SSI 2012/319 reg 37(1)'s reference as s. 35. The formula is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GfQq7afW2dJPbfg3p --- ...ion_scotland_self_employment_deductions.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index 59d3918bb9..f2c2831bbd 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -29,11 +29,19 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "allowance left after the taper, because in s. 35(2) Parliament " "reduces 'the allowance under subsection (1)'. At pension age, they " "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " - "Parliament omitted s. 36 (Finance Act " - "2014), and in s. 37 gives the section 35 amount only to a person " - "born before 6 April 1938; a council that read the reference " - "literally would allow nobody younger any relief. The formula reads " - "it as the same tapered s. 35 allowance for every applicant. A child " + "Parliament omitted s. 36 for 2015-16 and later years and s. 37 for " + "2016-17 and later years, and each time extended s. 35 to the people " + "the omitted section had covered (Finance Act 2014 s. 2(4), (5) and " + "(9); Finance Act 2015 s. 5(4), (5) and (11)). A council that read " + "reg 37(1) literally would allow no applicant any relief from " + "2016-17. Where an Act " + "repeals and re-enacts a provision, Parliament provides that a " + "reference to it in subordinate legislation reads as a reference to " + "the provision re-enacted (Interpretation Act 1978 ss. 17(2)(a) and " + "23(1)). The formula takes each Finance Act as repealing an " + "age-related allowance and re-enacting it in the extended s. 35, so " + "it reads the reference as the tapered s. 35 allowance, which s. " + "35(1) now gives every individual. A child " "minder's chargeable income comes to one-third of the earnings; the " "model does not identify child minders, so it treats every profit as " "chargeable income in full." @@ -48,6 +56,9 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", "https://www.legislation.gov.uk/ukpga/2007/3/section/35", "https://www.legislation.gov.uk/ukpga/2007/3/section/37", + "https://www.legislation.gov.uk/ukpga/2014/26/section/2", + "https://www.legislation.gov.uk/ukpga/2015/11/section/5", + "https://www.legislation.gov.uk/ukpga/1978/30/section/17", ) def formula(person, period, parameters): From 4af0f04297064bd258d2c37dbfccdd8995bea1ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 18:27:12 -0500 Subject: [PATCH 13/19] Cite Interpretation Act 1978 s. 23(1) and (2) for the relief reading Section 23(2) is the provision that makes a reference in "any other enactment" in s. 17(2)(a) include one in subordinate legislation, such as SSI 2012/319 reg 37(1). The documentation cited s. 23(1) alone. Text only; the formula is unchanged. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_scotland_self_employment_deductions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index f2c2831bbd..bf6bd79650 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -37,9 +37,10 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "2016-17. Where an Act " "repeals and re-enacts a provision, Parliament provides that a " "reference to it in subordinate legislation reads as a reference to " - "the provision re-enacted (Interpretation Act 1978 ss. 17(2)(a) and " - "23(1)). The formula takes each Finance Act as repealing an " - "age-related allowance and re-enacting it in the extended s. 35, so " + "the provision re-enacted (Interpretation Act 1978 s. 17(2)(a), read " + "with s. 23(1) and (2)). The formula takes each Finance Act as " + "repealing an age-related allowance and re-enacting it in the " + "extended s. 35, so " "it reads the reference as the tapered s. 35 allowance, which s. " "35(1) now gives every individual. A child " "minder's chargeable income comes to one-third of the earnings; the " @@ -59,6 +60,7 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "https://www.legislation.gov.uk/ukpga/2014/26/section/2", "https://www.legislation.gov.uk/ukpga/2015/11/section/5", "https://www.legislation.gov.uk/ukpga/1978/30/section/17", + "https://www.legislation.gov.uk/ukpga/1978/30/section/23", ) def formula(person, period, parameters): From 178852e6c247b9e273102a17987f515eac563d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:14:51 +0000 Subject: [PATCH 14/19] Keep main's tax measure in the schemes the deduction leaves alone In the English and Welsh working-age schemes, and for families outside Great Britain, council_tax_reduction_tax_deduction deducted the whole income tax before the winter fuel payment charge and all National Insurance. Main's Council Tax Reduction line deducted legacy_means_test_deducted_income_tax there instead, so the merge would have put back the tax on interest, dividends and rent from property that #1995 took out. The formula now deducts main's measure in those schemes: the applicant's and partner's legacy_means_test_deducted_income_tax, with the sum floored at nil, and all their National Insurance. The Welsh working-age case now has pay of 20,000 and savings interest of 20,000, where the two measures differ: the deduction comes to 5,594.40, not the whole liability of 5,880.40. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- .../council_tax_reduction_tax_deduction.yaml | 21 ++++++++++----- ...council_tax_reduction_applicable_income.py | 6 +++-- .../council_tax_reduction_tax_deduction.py | 26 +++++++++++++++---- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 6898ab6191..f237a1afbe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -702,7 +702,7 @@ # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). council_tax_reduction_applicable_income: 35_994 -- name: Wales's working-age scheme still deducts the whole income tax +- name: Wales's working-age scheme deducts the tax the legacy means tests deduct period: 2026 absolute_error_margin: 0.01 input: @@ -710,7 +710,7 @@ adult: age: 40 employment_income: 20_000 - private_pension_income: 10_000 + savings_interest_income: 20_000 benunits: benunit: members: [adult] @@ -722,10 +722,19 @@ region: WALES council_tax: 2_000 output: - # The model keeps the whole liability here, which the cases above do not - # cover: (30,000 - 12,570) x 20% = 3,486 and Class 1 594.40. - council_tax_reduction_tax_deduction: 4_080.40 - council_tax_reduction_applicable_income: 25_919.60 + # The formula leaves this scheme as the legacy means tests have it: the + # income tax less the tax the interest would bear without the pay + # (legacy_means_test_deducted_income_tax), and National Insurance. + # With the pay: non-savings 20,000 - 12,570 = 7,430 taxable, x 20% = + # 1,486; the interest above it gets no starting rate, a 1,000 personal + # savings allowance and 19,000 x 20% = 3,800, so 5,286. Without the pay: + # 20,000 - 12,570 = 7,430 of taxable interest, 5,000 at the starting rate + # and 1,000 allowance at nil, 1,430 x 20% = 286. 5,286 - 286 = 5,000. + # Class 1: (20,000 - 12,569.96) x 8% = 594.40. + legacy_means_test_deducted_income_tax: 5_000 + council_tax_reduction_tax_deduction: 5_594.40 + # The scheme counts the pay but not the interest: 20,000 - 5,594.40. + council_tax_reduction_applicable_income: 14_405.60 - name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,661 period: 2026 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 2b5b0a40a1..5cc22ea082 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -64,7 +64,8 @@ class council_tax_reduction_applicable_income(Variable): "The income tax and National Insurance deducted are those the scheme " "allows (council_tax_reduction_tax_deduction): on earnings only in " "Scotland's working-age scheme, on income taken into account in the " - "pensioner schemes, and the whole liability in the other schemes." + "pensioner schemes, and in the other schemes the income tax the " + "legacy means tests deduct (legacy_means_test_deducted_income_tax)." ) definition_period = YEAR unit = GBP @@ -356,7 +357,8 @@ def formula(benunit, period, parameters): ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) # The income tax and National Insurance each scheme allows: on earnings # only in Scotland's working-age scheme, on income taken into account - # in the pensioner schemes (council_tax_reduction_tax_deduction). + # in the pensioner schemes, and in the other schemes what the legacy + # means tests deduct (council_tax_reduction_tax_deduction). tax = benunit("council_tax_reduction_tax_deduction", period) # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 1 para # 11(9) and Sch 3; SSI 2012/319 reg 31(8) and Sch 2: the pensioner diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index bb4c51b8cf..72605742b4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -49,9 +49,11 @@ class council_tax_reduction_tax_deduction(Variable): "includes any tax they add on savings and dividends and through the " "marriage allowance. Benefits in kind stay in both terms of each " "difference, since no scheme counts a payment in kind as earnings. " - "The English and Welsh working-age schemes, and families outside " - "Great Britain, deduct the whole income tax before the winter fuel " - "payment charge and all National Insurance. " + DECISION + "In the English and Welsh working-age schemes, and for families " + "outside Great Britain, the formula deducts what the legacy means " + "tests deduct: the applicant's and partner's " + "legacy_means_test_deducted_income_tax, with their sum floored at " + "nil, and all their National Insurance. " + DECISION ) definition_period = YEAR unit = GBP @@ -92,10 +94,24 @@ def total(values): income_tax = person(PRE_CHARGE_TAX, period) tax = total(income_tax) - whole_liability = tax + total(person("national_insurance", period)) class_1 = total(person("ni_class_1_employee", period)) zeros = np.zeros_like(tax) + # The other schemes deduct what the legacy means tests deduct: the + # applicant's and partner's legacy_means_test_deducted_income_tax, + # whose sum the formula floors at nil because one member's value can + # turn negative where another's earnings move a Step 7 charge off + # them, and all their National Insurance. + other_schemes = ~( + with_universal_credit | without_universal_credit | pensioner_scheme + ) + if other_schemes.any(): + legacy_deduction = max_( + 0, total(person("legacy_means_test_deducted_income_tax", period)) + ) + total(person("national_insurance", period)) + else: + legacy_deduction = zeros + # Each branch of income tax is read only where a scheme uses it. if (without_universal_credit | pensioner_scheme).any(): tax_without_employment = total( @@ -155,5 +171,5 @@ def total(values): return select( [with_universal_credit, without_universal_credit, pensioner_scheme], [uc_deduction, no_uc_deduction, pensioner_deduction], - default=whole_liability, + default=legacy_deduction, ) From 023de787d8391e89265a35370c1c4016ef1ac256 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:18:16 +0000 Subject: [PATCH 15/19] Leave the tax on rent from property out of the pensioner deduction Main now treats rent from property as income from capital and no longer counts it, but council_tax_reduction_income_tax_on_other_counted_income kept property_income on its branch, so the pensioner schemes deducted the tax on rent they did not count. The branch now zeroes property_income too. Without property profits, property_finance_costs_relieved relieves no finance costs on the branch, so the finance-cost reduction goes with the rent. The formula keeps property_rental_income, which only measures receipts for the property allowance, and sublet_income, the rent for letting part of the home that the pensioner schemes count and that the model does not tax. A new case pins an English pensioner with a 20,000 pension and 10,000 of rent from a let flat: the deduction comes to 1,486 (not 3,486), Council Tax Reduction income to 18,514 and the reduction to 772.40. Invariant 2 now also varies property income, and the branch test checks that the branch takes rent, with its finance-cost reduction, out of income tax. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- .../council_tax_reduction_tax_deduction.yaml | 35 ++++++++++++++++++ ..._tax_reduction_tax_deduction_properties.py | 36 ++++++++++++++++++- ...tion_income_tax_on_other_counted_income.py | 17 +++++++-- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index f237a1afbe..2e118cd2ea 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -318,6 +318,41 @@ # 20,308 - 1,547.60. council_tax_reduction_applicable_income: 18_760.40 +- name: England's pensioner scheme deducts no tax on rent from a property the pensioner doesn't live in + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + property_income: 10_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The pensioner lets out a flat they don't live in, for a profit of + # 10,000. The scheme counts the pension, not the rent, which the model + # treats as income from capital; the case gives the flat no value, so the + # model counts no capital and no tariff income. Income tax: 30,000 - + # 12,570 = 17,430 x 20% = 3,486. Tax on the pension with the rent above + # it: (20,000 - 12,570) x 20% = 1,486. + income_tax_before_winter_fuel_payment_charge: 3_486 + council_tax_reduction_tax_deduction: 1_486 + # 20,000 - 1,486. + council_tax_reduction_applicable_income: 18_514 + # A single pensioner who reached State Pension age after 1 April 2021 + # gets 238 a week, 12,376 a year: 2,000 - 20% x (18,514 - 12,376) = + # 772.40. + council_tax_reduction_applicable_amount: 12_376 + council_tax_reduction: 772.40 + - name: England's pensioner scheme deducts the savings tax that pay adds period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index fa30fa08de..4bccfd6a49 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -51,6 +51,9 @@ "miscellaneous_income", "company_car_benefit", ] +# The same with the profits of a property business, which the strategies below +# draw on their own. +PENSIONER_UNCOUNTED = UNCOUNTED + ["property_income"] # Taxed income other than earnings that Scotland's working-age scheme counts # or treats as capital. UNEARNED = [ @@ -217,7 +220,7 @@ def test_scottish_working_age_deduction_matches_pension_credit_earnings_tax( @PROPERTY_SETTINGS @given( st.lists(household(pensioner=True, earnings=False), min_size=1, max_size=4), - st.sampled_from(UNCOUNTED), + st.sampled_from(PENSIONER_UNCOUNTED), money(60_000), st.booleans(), ) @@ -616,6 +619,7 @@ def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_firs ("dividend_income", 10_000.0), ("miscellaneous_income", 10_000.0), ("company_car_benefit", 10_000.0), + ("property_income", 10_000.0), ] @@ -640,6 +644,36 @@ def test_branch_takes_each_uncounted_income_out_of_income_tax(): tax_with_income = np.asarray(simulate(with_income).calculate(INCOME_TAX, YEAR)) assert np.all(tax_with_income > expected + 1), tax_with_income + # A landlord's finance costs earn a 20% tax reduction on the rent. The + # branch drops the reduction with the rent, so it gives the same tax with + # or without the costs. + landlords = simulate( + [ + dict( + country="ENGLAND", + benunits=[ + [ + { + **base, + "property_income": 10_000.0, + "property_finance_costs": costs, + } + ] + ], + ) + for costs in (0.0, 5_000.0) + ] + ) + landlord_tax = np.asarray(landlords.calculate(INCOME_TAX, YEAR)) + assert np.isclose(landlord_tax[0] - landlord_tax[1], 1_000, atol=0.01) + assert np.allclose( + landlords.calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + ), + expected, + atol=0.01, + ) + carer = dict( age=70, private_pension_income=14_000.0, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index b6276d5ee9..ed9e69b947 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -8,7 +8,17 @@ # pensioner schemes' general route: the Scottish Carer Supplement (SSI # 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no # scheme counts a payment in kind as earnings), savings interest and other -# investment income, and dividends. +# investment income, dividends, and the profits of a property business +# (property_income), such as rent from a property the applicant doesn't live +# in. The model taxes those profits through property_income alone. Without +# them, the finance-cost reduction comes to nil on the branch, because +# property_finance_costs_relieved caps the costs it relieves at the property +# profits, so the reduction goes with the rent. property_rental_income only +# measures receipts for the property allowance, which can't take taxable +# property income below the nil the branch leaves, so the formula keeps it. +# Rent for letting part of the home (sublet_income) counts on the pensioner +# route (council_tax_reduction_home_letting_income), so the formula keeps it +# too; the model charges no income tax on it. PENSIONER_UNCOUNTED_INCOME = [ "scottish_carer_supplement", "miscellaneous_income", @@ -16,6 +26,7 @@ "savings_interest_income", "other_investment_income", "dividend_income", + "property_income", ] # The Step 7 charges of ITA 2007 s. 23 that income_tax_charges holds, @@ -39,7 +50,9 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "and statutory sick, maternity and paternity pay), self-employment " "income, or the taxed income the pensioner schemes do not count: the " "Scottish Carer Supplement, miscellaneous income, benefits in kind, " - "savings interest, other investment income and dividends. It also " + "savings interest, other investment income, dividends and the profits " + "of a property business (property_income), with the finance-cost " + "reduction that goes with them. It also " "leaves out the Step 7 charges of ITA 2007 s. 23 that " "income_tax_charges holds, the High Income Child Benefit Charge and " "the pension annual allowance charge: through the first, HMRC " From d237386bfc78f08c98a65f4cb735ab41225b757c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:29:16 +0000 Subject: [PATCH 16/19] Describe the pensioner schemes' income from capital and the model's approximation In each pensioner scheme, a council disregards any actual income from capital except income from the capital that Part 2 of the capital Schedule lists (life interests and liferents, rights to rent without a reversionary interest, annuities and trust property for the applicant or partner), and disregards that income too while such capital totals 10,000 or less. A council counts other capital through tariff income and leaves Part 2 capital out of it. The model has no input that marks Part 2 capital, and other_investment_income takes in interest from trusts and settlements, so on the pensioner route council_tax_reduction_applicable_income counts no interest, other investment income, dividends or rent from property, and the deduction takes no tax on any of it. The documentation of council_tax_reduction_income_tax_on_other_counted_income, the comment above PENSIONER_UNCOUNTED_INCOME and the paragraph on income from capital in council_tax_reduction_applicable_income now say so, call the blanket exclusion the model's general approximation of the income rules, and keep the ordinary bank deposit and shares held outright as examples of capital outside Part 2. The variables also cite the Part 2 provisions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- ...council_tax_reduction_applicable_income.py | 49 ++++++++----- ...tion_income_tax_on_other_counted_income.py | 69 +++++++++++++++++-- 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 5cc22ea082..08e6348584 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -42,17 +42,31 @@ class council_tax_reduction_applicable_income(Variable): "Compensation Scheme payments are not counted: Scotland disregards or " "omits them, and English and Welsh councils may relieve them beyond " "the prescribed £10 a week, so the model takes the full disregard. " - "No scheme " - "counts income derived from capital, such as rent from property, " - "interest and dividends, as income, so it is not listed. The pensioner schemes " - "disregard any actual income from capital; the Welsh working-age " - "scheme and the English default scheme treat it as capital; the " - "Scottish working-age scheme counts only the unearned income it " - "lists, which includes the assumed yield from capital but not actual " - "rent, interest or dividends. Instead, the national schemes (England " - "for pensioners, Wales and Scotland) count the tariff income they " - "treat capital as yielding (council_tax_reduction_tariff_income); the " - "English local schemes for people under pension age add their own. " + "The formula counts no income derived from capital, such as rent " + "from property other than the home, interest and dividends. In each " + "pensioner scheme, a council disregards any actual income from " + "capital, except income from the capital that Part 2 of the capital " + "Schedule lists: life interests and liferents, rights to rent where " + "the applicant has no reversionary interest, annuities, and property " + "held under a trust that pays or may pay the applicant or partner. It " + "disregards that income too while such capital totals £10,000 or " + "less. The model has no input that marks capital as Part 2 capital, " + "and other_investment_income takes in interest from trusts and " + "settlements, so on the pensioner route the formula counts none of " + "the interest, other investment income, dividends or rent from " + "property, and council_tax_reduction_tax_deduction deducts no tax on " + "any of it. The model makes that general approximation of the income " + "rules: where Part 2 capital above £10,000 yields such income, a " + "council counts it and the model doesn't. The Welsh working-age " + "scheme and the English default scheme treat income from capital as " + "capital; the Scottish working-age scheme counts only the unearned " + "income it lists, which includes the assumed yield from capital but " + "not actual rent, interest or dividends. Instead, the national " + "schemes (England for pensioners, Wales and Scotland) count the " + "tariff income they treat capital as yielding " + "(council_tax_reduction_tariff_income), and in the pensioner schemes " + "a council leaves Part 2 capital out of it. The English local schemes " + "for people under pension age add their own tariff income. " "Rent the applicant and partner receive for letting part of the home " "counts, less the sub-tenant disregard, on the schemes' own income " "rules (council_tax_reduction_home_letting_income). It does not count " @@ -61,11 +75,11 @@ class council_tax_reduction_applicable_income(Variable): "Universal Credit assessment (which does not count it), or in the " "Scottish scheme for people under pension age from April 2022, which " "does not list it. " - "The income tax and National Insurance deducted are those the scheme " - "allows (council_tax_reduction_tax_deduction): on earnings only in " - "Scotland's working-age scheme, on income taken into account in the " - "pensioner schemes, and in the other schemes the income tax the " - "legacy means tests deduct (legacy_means_test_deducted_income_tax)." + "The formula deducts the income tax and National Insurance each " + "scheme allows (council_tax_reduction_tax_deduction): on earnings " + "only in Scotland's working-age scheme, on income taken into account " + "in the pensioner schemes, and in the other schemes the income tax " + "the legacy means tests deduct (legacy_means_test_deducted_income_tax)." ) definition_period = YEAR unit = GBP @@ -103,6 +117,9 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9", "https://www.legislation.gov.uk/ssi/2012/319/schedule/3", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/4/part/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/6/part/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/5/part/2", "https://www.legislation.gov.uk/ssi/2012/303/regulation/26", "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", "https://www.legislation.gov.uk/ssi/2021/249/regulation/63", diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index ed9e69b947..47755f34a7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -7,10 +7,33 @@ # Taxed income that council_tax_reduction_applicable_income leaves out on the # pensioner schemes' general route: the Scottish Carer Supplement (SSI # 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no -# scheme counts a payment in kind as earnings), savings interest and other -# investment income, dividends, and the profits of a property business -# (property_income), such as rent from a property the applicant doesn't live -# in. The model taxes those profits through property_income alone. Without +# scheme counts a payment in kind as earnings), and income from capital: +# savings interest, other investment income, dividends and the profits of a +# property business (property_income), such as rent from a property the +# applicant doesn't live in. +# +# In each pensioner scheme, a council disregards any actual income from +# capital, except income from the capital that Part 2 of the capital +# Schedule lists: life interests and liferents, rights to rent where the +# applicant has no reversionary interest, annuities, and property held under +# a trust that pays or may pay the applicant or partner. It disregards that +# income too while such capital totals £10,000 or less. A council counts +# other capital through tariff income and leaves Part 2 capital out of it +# (SSI 2012/319 reg 27(1)(i) and (2), reg 31(10), Sch 3 paras 21 and 22 and +# Sch 4 Part 2; SI 2012/2885 Sch 1 paras 16(1)(i), 17(11) and 37, Sch 5 +# paras 22 and 23 and Sch 6 Part 2; WSI 2013/3029 Sch 1 paras 10(1)(i), +# 11(11) and 31, Sch 4 paras 22 and 23 and Sch 5 Part 2). So a council +# disregards the interest on savings in an ordinary bank deposit and the +# dividends on shares held outright, and counts the deposit and the shares +# through tariff income. The model has no input that marks capital as Part 2 +# capital, and other_investment_income takes in interest from trusts and +# settlements, so council_tax_reduction_applicable_income counts none of +# this income on the pensioner route, and the formula deducts no tax on any +# of it. The model makes that general approximation of the income rules: +# where Part 2 capital above £10,000 yields such income, a council counts it +# and the model doesn't. +# +# The model taxes property profits through property_income alone. Without # them, the finance-cost reduction comes to nil on the branch, because # property_finance_costs_relieved caps the costs it relieves at the property # profits, so the reduction goes with the rent. property_rental_income only @@ -52,7 +75,24 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "Scottish Carer Supplement, miscellaneous income, benefits in kind, " "savings interest, other investment income, dividends and the profits " "of a property business (property_income), with the finance-cost " - "reduction that goes with them. It also " + "reduction that goes with them. In each pensioner scheme, a council " + "disregards any actual income from capital, except income from the " + "capital that Part 2 of the capital Schedule lists: life interests " + "and liferents, rights to rent where the applicant has no " + "reversionary interest, annuities, and property held under a trust " + "that pays or may pay the applicant or partner. It disregards that " + "income too while such capital totals £10,000 or less. A council " + "counts other capital, such as " + "savings in an ordinary bank deposit or shares held outright, through " + "tariff income, and leaves Part 2 capital out of it. The model has no " + "input that marks capital as Part 2 capital, and " + "other_investment_income takes in interest from trusts and " + "settlements, so council_tax_reduction_applicable_income counts none " + "of the interest, other investment income, dividends or rent from " + "property on the pensioner route, and the formula deducts no tax on " + "any of it. The model makes that general approximation of the income " + "rules: where Part 2 capital above £10,000 yields such income, a " + "council counts it and the model doesn't. It also " "leaves out the Step 7 charges of ITA 2007 s. 23 that " "income_tax_charges holds, the High Income Child Benefit Charge and " "the pension annual allowance charge: through the first, HMRC " @@ -67,6 +107,25 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): ) definition_period = YEAR unit = GBP + reference = [ + "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/21", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/22", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/4/part/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/37", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/22", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/23", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/6/part/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/31", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/22", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/23", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/5/part/2", + ] def formula(person, period, parameters): zeroed = PENSIONER_UNCOUNTED_INCOME + STEP_7_CHARGES From 4684c2f93a70d5925f82224afa0d49cc4fdee053 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:45:07 +0000 Subject: [PATCH 17/19] Test that the other schemes deduct the legacy means test measure Invariant 8 checks that, in the English and Welsh working-age schemes, the deduction equals the applicant's and partner's legacy_means_test_deducted_income_tax, with the sum floored at nil, plus their National Insurance. With the whole liability in its place, Hypothesis finds an English adult aged 25 whose deduction comes to 338.73 where the legacy measure gives nil. Invariant 7 now also runs a Welsh working-age case, so it reads that branch in both orders. The changelog fragment now names rent from property among the income whose tax the pensioner schemes do not deduct. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- changelog.d/2090.fixed.md | 2 +- ..._tax_reduction_tax_deduction_properties.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md index d83a5b2418..fd65a3c255 100644 --- a/changelog.d/2090.fixed.md +++ b/changelog.d/2090.fixed.md @@ -1 +1 @@ -The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends, rent from property or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 4bccfd6a49..cda3059434 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -16,6 +16,8 @@ (option (a)): the tax on earnings is the extra tax the earnings cause, with the earnings as the top slice of income. council_tax_reduction_tax_deduction applies these rules, and council_tax_reduction_applicable_income deducts it. +In the English and Welsh working-age schemes, the formula deducts what the +legacy means tests deduct (legacy_means_test_deducted_income_tax). Each test builds households by hand in 2026, states its invariant in its docstring, and records the scheme of each household it draws as a Hypothesis @@ -584,6 +586,10 @@ def test_non_dependant_income_never_changes_the_deduction( country="WALES", benunits=[[dict(age=70, self_employment_income=60_000.0)]], ), + dict( + country="WALES", + benunits=[[dict(age=40, employment_income=20_000.0)]], + ), ] @@ -695,3 +701,31 @@ def test_branch_takes_each_uncounted_income_out_of_income_tax(): with_supplement.calculate(INCOME_TAX, YEAR)[0] > without_supplement.calculate(INCOME_TAX, YEAR)[0] + 1 ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household(countries=("ENGLAND", "WALES"), pensioner=False), + min_size=1, + max_size=4, + ) +) +def test_other_schemes_deduct_the_legacy_means_test_measure(households): + """Invariant 8. In the English and Welsh working-age schemes, the + deduction equals what the legacy means tests deduct: the applicant's and + partner's legacy_means_test_deducted_income_tax, with the sum floored at + nil, plus their National Insurance. So these schemes take no tax on the + interest, dividends or rent from property that they do not count, beyond + the extra tax that earnings cause on it.""" + simulation = simulate(households) + schemes(simulation, households) + expected = np.maximum( + 0, applicant_total(simulation, "legacy_means_test_deducted_income_tax") + ) + applicant_total(simulation, "national_insurance") + deduction = benunit_values(simulation, DEDUCTION) + assert np.allclose(deduction, expected, atol=0.01), ( + households, + deduction, + expected, + ) From 87006023870e942f039593f6a0d978d4b065c64b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:45:51 +0000 Subject: [PATCH 18/19] Name main's Universal Credit award test in the deduction's documentation The Scottish working-age route with Universal Credit now follows council_tax_reduction_universal_credit_award through applicant_has_universal_credit, so it covers an award the benefit cap or deductions reduce to nil. The documentation says so, and names interest, dividends and rent from property as examples of U. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- .../council_tax_reduction_tax_deduction.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index 72605742b4..8b8bcb3686 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -23,13 +23,16 @@ class council_tax_reduction_tax_deduction(Variable): "council_tax_reduction_income_tax_on_other_counted_income). E is " "employed earnings: employment income and statutory sick, maternity " "and paternity pay. S is self-employment income. U is the taxed " - "income the pensioner schemes do not count. " + "income the pensioner schemes do not count, such as interest, " + "dividends and rent from property. " "Scotland's working-age scheme (SSI 2021/249) deducts tax from " - "earnings only. Where nobody in the family has Universal Credit, the " - "formula deducts T less T(E), never negative, primary Class 1 " + "earnings only. Where the applicant has no award of Universal Credit " + "(council_tax_reduction_universal_credit_award, which includes an " + "award the benefit cap or deductions reduce to nil), the formula " + "deducts T less T(E), never negative, primary Class 1 " "contributions, and the notional tax and contributions on profit " "(council_tax_reduction_scotland_self_employment_deductions; reg " - "51(9)). With Universal Credit it deducts T less T(E, S), never " + "51(9)). With an award it deducts T less T(E, S), never " "negative, and the Class 1, 2 and 4 contributions charged (regs " "49(6)(b) and 51(2) step 3). The pensioner schemes of England (SI " "2012/2885), Wales (WSI 2013/3029) and Scotland (SSI 2012/319) " From 3589f7ef7bb6fc82fbe24067d69f09afa97ec9f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:29:33 +0000 Subject: [PATCH 19/19] Read the Council Tax Reduction line's own deduction in the legacy property tests test_legacy_means_test_income_from_capital_properties.py, which #1995 brought, expected the Council Tax Reduction line to respond one-for-one to legacy_means_test_deducted_income_tax for every family. The line now deducts council_tax_reduction_tax_deduction, which reads that measure only in the English and Welsh working-age schemes and outside Great Britain, so at 87006023 invariant 6 failed on a Scottish working-age couple in 2025 whose Council Tax Reduction income did not move when the test raised the measure by a pound. Invariant 6 now expects no response in Scotland's working-age scheme and Great Britain's pensioner schemes, and invariant 1 reads the line's own deduction when it splits the change in Council Tax Reduction income. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- ...ans_test_income_from_capital_properties.py | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py b/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py index ee4849fb03..703726033d 100644 --- a/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py @@ -27,9 +27,11 @@ personal allowance can leave rent or dividends taxable. Where someone has earnings, each means-test income changes by exactly minus the change in the tax that test deducts (legacy_means_test_deducted_income_tax, summed - over the members it counts) and in its earnings disregard, which is - capped at net earnings, unless floored or on a route that does not use - the test's own income rules. The awards are compared wherever none of + over the members it counts; for council tax reduction, + council_tax_reduction_tax_deduction, which applies each scheme's own + rule) and in its earnings disregard, which never exceeds net earnings, + unless a floor binds or the family takes a route that does not use the + test's own income rules. The test compares the awards wherever none of those taxes or disregards moves. 2. Bounds: the counted home-letting income is between nil and the rent, and equals the rent less £20 a week, floored at nil. @@ -57,10 +59,16 @@ savings rise while the family stays on the same route (pensioner, UC award, passport, guarantee credit, savings credit only). 6. Differential: for every generated family without self-employment income, - Income Support, Housing Benefit and the council tax reduction line deduct, - for each person they count, the same income tax as Pension Credit's tax - line: the pre-charge income tax less - legacy_means_test_uncounted_income_tax_without_earnings. + Income Support and Housing Benefit deduct, for each person they count, the + same income tax as Pension Credit's tax line: the pre-charge income tax + less legacy_means_test_uncounted_income_tax_without_earnings. The council + tax reduction line deducts council_tax_reduction_tax_deduction, which + deducts that tax only in the schemes it leaves to the legacy means tests + (the English and Welsh working-age schemes and outside Great Britain). + In Scotland's working-age scheme and Great Britain's pensioner schemes it + applies those schemes' own rules + (test_council_tax_reduction_tax_deduction_properties.py), so there the + line does not read Pension Credit's tax. 7. Applicant: where the household head does not apply alone for council tax reduction, council_tax_reduction_home_letting_income equals legacy_benefits_home_letting_income; where the head applies alone, her @@ -264,6 +272,13 @@ def calculate_with_taxes(units, year, variables, **kwargs): for income, members in TEST_MEMBERS.items(): mask = np.asarray(sim.calculate(members, year), dtype=float) out[f"tax:{income}"] = sim.map_result(tax * mask, "person", "benunit") + # The council tax reduction line deducts each scheme's own measure, a + # benefit unit total for the applicant and partner, which equals the + # shared deduction only in the schemes it leaves to the legacy means + # tests. + out["tax:council_tax_reduction_applicable_income"] = np.asarray( + sim.calculate("council_tax_reduction_tax_deduction", year), dtype=float + ) earnings = sum( np.abs(np.asarray(sim.calculate(v, year), dtype=float)) for v in EARNINGS ) @@ -485,12 +500,17 @@ def test_deducted_tax_without_earnings_is_the_step_4_split(units, year): @PROPERTY_SETTINGS @given(populations, st.sampled_from(YEARS)) def test_the_four_tests_deduct_the_same_income_tax(units, year): - """Income Support, Housing Benefit and the council tax reduction line - deduct, for each person they count, the income tax Pension Credit's tax - line deducts. Each test's income is recomputed with the person's income - tax raised by one pound and compared with the original, so the check - reads each formula rather than the shared variable. The families have no - self-employment income, where Pension Credit's notional tax applies.""" + """Income Support and Housing Benefit deduct, for each person they count, + the income tax Pension Credit's tax line deducts, and so does the council + tax reduction line in the schemes council_tax_reduction_tax_deduction + leaves to the legacy means tests (the English and Welsh working-age + schemes and outside Great Britain). In Scotland's working-age scheme and + Great Britain's pensioner schemes, the line applies those schemes' own + rules and does not respond to that tax. The test recomputes each test's + income with the person's income tax raised by one pound and compares it + with the original, so the check reads each formula rather than the + shared variable. The families have no self-employment income, where + Pension Credit's notional tax applies.""" sim = Simulation(situation=situation(units, year)) people = sim.populations["person"].count pension_credit_tax = np.asarray( @@ -547,6 +567,15 @@ def test_the_four_tests_deduct_the_same_income_tax(units, year): ) == 0 ) + # Each family is one benefit unit in its own household. + region = np.array([unit["region"] for unit in units]) + pensioner = np.asarray( + sim.calculate("council_tax_reduction_pensioner", year) + ).astype(bool) + own_scheme_rules = (region != "NORTHERN_IRELAND") & ( + pensioner | (region == "SCOTLAND") + ) + counted = np.where(own_scheme_rules, 0.0, counted) checked = unfloored & own_rules # The earnings disregard is capped at net earnings, so it can fall # with them.