From 4cf455a9144f66349a6fc5beec25f48bdb8e467f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:37:33 -0400 Subject: [PATCH 1/2] Count the State Pension as step-one tax credit income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SI 2002/2006 reg 3(1) puts pension income (reg 5(1)) in step one, where only the excess over £300 counts. Reg 5(1)(a) covers "any pension to which section 577 ... of ITEPA applies", which is the State Pension (ITEPA 2003 s.577(1)), and reg 7(2) takes it out of social security income. The model counted it in step two through social_security_income, so a pensioner whose other step-one income was under £300 had tax credit income overstated by up to £300. tax_credits_current_year_income now lists step-one income and reg 7 social security income explicitly. YAML cases are computed by hand from the law; Hypothesis properties check the income against the law, against the previous formula (applied as a reform), and the award bounds that follow. Co-Authored-By: Claude Opus 5.5 --- ...ax-credits-state-pension-step-one.fixed.md | 1 + .../tax_credits_state_pension_step_one.yaml | 333 +++++++++++++++++ ...edits_state_pension_step_one_properties.py | 339 ++++++++++++++++++ .../dwp/tax_credits_current_year_income.py | 57 ++- 4 files changed, 722 insertions(+), 8 deletions(-) create mode 100644 changelog.d/tax-credits-state-pension-step-one.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_state_pension_step_one.yaml create mode 100644 policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py diff --git a/changelog.d/tax-credits-state-pension-step-one.fixed.md b/changelog.d/tax-credits-state-pension-step-one.fixed.md new file mode 100644 index 0000000000..6f44150c05 --- /dev/null +++ b/changelog.d/tax-credits-state-pension-step-one.fixed.md @@ -0,0 +1 @@ +Count the State Pension as step-one pension income in tax credit income, so the £300 disregard on step-one income applies to it (SI 2002/2006 regs 3(1), 5(1)(a) and 7(2); ITEPA 2003 s.577). diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_state_pension_step_one.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_state_pension_step_one.yaml new file mode 100644 index 0000000000..ea3ce3d8fd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_state_pension_step_one.yaml @@ -0,0 +1,333 @@ +# State Pension is step-one pension income for tax credits, with the £300 +# disregard. +# +# SI 2002/2006 reg 3(1), Step One adds "the pension income (as defined in +# regulation 5(1)), the investment income ..., the property income ..., the +# foreign income ... and the notional income"; "If the result of this step is +# £300 or less, it is treated as nil. If the result of this step is more than +# £300, only the excess is taken into account in the following steps." Step Two +# adds employment income, "the social security income (as defined in +# regulation 7)", student income and miscellaneous income. +# +# Reg 5(1)(a): pension income means "any pension to which section 577 or 629 +# of ITEPA applies"; ITEPA 2003 s.577(1) applies to "the state pension". Reg +# 7(2): "Pensions under the Contributions and Benefits Act which are pension +# income by virtue of regulation 5(1)(a) are not social security income." +# Carer's Allowance (SSCBA 1992 s.70) and contributory ESA (Welfare Reform Act +# 2007 Part 1) are social security income (reg 7(1)(a)); Table 3 disregards +# neither. +# +# 2024-25 rates: +# - WTC basic element £2,435, couple element £2,500 (WTC Regs 2002 Sch 2). +# - Income threshold £7,955, or £19,995 for a claim with no WTC entitlement; +# withdrawal rate 41% (SI 2002/2008 regs 3 and 7, as uprated). +# - CTC child element £3,455; family element £545 (CTC Regs 2002 reg 7). +# - Standard minimum guarantee £218.15 a week single, £332.95 couple (SPC Regs +# 2002 reg 6). WEEKS_IN_YEAR is 52. +# +# Each pensioner reached State Pension age after 6 April 2016, so has no +# savings credit (SPCA 2002 s.3(1)(a)). The CTC family element is entered at +# £545, its statutory amount, which the model uprates. + +- name: State Pension is step one, so the £300 disregard applies to it + # Tax credit income: step one 9,500 - 300 = £9,200; step two £2,600; total + # £11,800. (With State Pension in step two it would be £12,100.) + # WTC: 2,435 - 0.41 x (11,800 - 7,955) = £858.55. + # Pension Credit income is at least 9,500 + 2,600 - 5 x 52 + 858.55 = + # £12,698.55, above the 218.15 x 52 = £11,343.80 guarantee, so no Pension + # Credit, with or without the passport. No income tax: £12,100 is within the + # personal allowance. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 9_500 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + output: + tax_credits_current_year_income: 11_800 + tax_credits_applicable_income: 11_800 + working_tax_credit: 858.55 + pension_credit: 0 + +- name: Other step-one income uses part of the £300, and State Pension the rest + # Step one: 9,500 + 100 - 300 = £9,300; total 9,300 + 2,600 = £11,900. + # (With State Pension in step two: 0 + 2,600 + 9,500 = £12,100.) + # WTC: 2,435 - 0.41 x (11,900 - 7,955) = £817.55. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 9_500 + savings_interest_income: 100 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + output: + tax_credits_current_year_income: 11_900 + working_tax_credit: 817.55 + pension_credit: 0 + +- name: Private pension of £300 or more already uses the disregard + # Step one: 8,500 + 1,000 - 300 = £9,200; total £11,800, as it would be with + # State Pension in step two: (1,000 - 300) + 2,600 + 8,500 = £11,800. + # WTC: 2,435 - 0.41 x (11,800 - 7,955) = £858.55. Pension Credit income + # includes the private pension: at least 8,500 + 1,000 + 2,340 + 858.55 = + # £12,698.55, so none. No income tax on £12,100. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 8_500 + private_pension_income: 1_000 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + output: + tax_credits_current_year_income: 11_800 + working_tax_credit: 858.55 + pension_credit: 0 + +- name: A joint claim has one £300 disregard, across both State Pensions + # Step one is the claimants' aggregate: 8,000 + 7,500 - 300 = £15,200 (not + # less £600). Total: 15,200 + 2,600 = £17,800. + # WTC maximum: 2,435 + 2,500 = £4,935; less 0.41 x (17,800 - 7,955) = + # £4,036.45; so £898.55. (With State Pension in step two: £18,100 and + # £775.55.) + # Pension Credit income is at least 15,500 + 2,600 - 10 x 52 + 898.55 = + # £18,478.55, above the 332.95 x 52 = £17,313.40 couple guarantee. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner_1: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 8_000 + working_tax_credit_reported: 1 + pensioner_2: + age: 70 + is_claimant_or_partner: true + state_pension: 7_500 + benunits: + benunit: + members: [pensioner_1, pensioner_2] + would_claim_uc: false + households: + household: + members: [pensioner_1, pensioner_2] + output: + tax_credits_current_year_income: 17_800 + working_tax_credit: 898.55 + pension_credit: 0 + +- name: A grandparent's Child Tax Credit is tapered on income with the State Pension in step one + # A grandparent aged 70 responsible for a grandchild aged 10, working 10 + # hours a week (no WTC: a single claimant with a child needs 16), claiming + # CTC only. + # Tax credit income: (11,000 - 300) + 9,500 = £20,200, above the £19,995 + # threshold. CTC: 545 + 3,455 = £4,000, less 0.41 x 205 = £84.05, so + # £3,915.95. (With State Pension in step two: £20,500 and £3,792.95.) + # Pension Credit income is above the guarantee: 11,000 + 9,500 less income + # tax of 0.2 x (20,500 - 12,570) = £1,586. SPC Regs reg 6(6)(d) and (11) + # give no child amount to a tax credit recipient; it is entered because the + # model applies neither. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + employment_income: 9_500 + weekly_hours: 10 + state_pension: 11_000 + child_tax_credit_reported: 1 + grandchild: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, grandchild] + would_claim_uc: false + child_minimum_guarantee_addition: 0 + CTC_family_element: 545 + households: + household: + members: [grandparent, grandchild] + output: + tax_credits_current_year_income: 20_200 + child_tax_credit: 3_915.95 + working_tax_credit: 0 + pension_credit: 0 + +- name: Where Pension Credit is paid without the passport, it falls by the WTC gained + # The case with no consistent passport state (see + # tax_credits_pension_credit_passport.yaml), with no savings interest. + # Tax credit income: (6,600 - 300) + 2,600 = £8,900. + # Passport: with the test lifted WTC is £2,435, and Pension Credit income + # 2,340 + 6,600 + 2,435 = £11,375 is above the £11,343.80 guarantee, so the + # income test stays. + # WTC: 2,435 - 0.41 x (8,900 - 7,955) = £2,047.55. Guarantee Credit: + # 11,343.80 - (2,340 + 6,600 + 2,047.55) = £356.25. + # With State Pension in step two, income is £9,200, WTC £1,924.55 and + # Pension Credit £479.25: the £123 more WTC is £123 less Guarantee Credit. + # Pension Credit earnings are entered net of the SPC Regs Sch VI para 5 + # disregard (£5 a week), which the model does not apply. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 6_600 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + households: + household: + members: [pensioner] + output: + tax_credits_current_year_income: 8_900 + tax_credits_applicable_income: 8_900 + working_tax_credit: 2_047.55 + pension_credit: 356.25 + +- name: The State Pension disregard can bring a family within the childcare tax credit income limit + # England's two-year-old offer: a child whose parent is entitled to "child + # tax credit where that parent is not also entitled to working tax credit + # ... and has an annual gross income not exceeding £16,190" (SI 2014/2147 + # reg 1(2), "eligible child", as at 1 September 2024). + # Tax credit income: (11,490 - 300) + 5,000 = £16,190, within the limit. + # (With State Pension in step two: £16,490, above it.) + # CTC: £4,000, below the £19,995 threshold. Pension Credit income, + # 11,490 + 5,000 less income tax of 0.2 x (16,490 - 12,570) = £784, is above + # the guarantee. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + employment_income: 5_000 + weekly_hours: 10 + state_pension: 11_490 + child_tax_credit_reported: 1 + grandchild: + age: 2 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, grandchild] + would_claim_uc: false + child_minimum_guarantee_addition: 0 + CTC_family_element: 545 + working_tax_credit_run_on: false + extended_childcare_entitlement_eligible: false + households: + household: + members: [grandparent, grandchild] + country: ENGLAND + output: + tax_credits_current_year_income: 16_190 + child_tax_credit: 4_000 + pension_credit: 0 + meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: true + targeted_childcare_entitlement_eligible: true + +- name: A State Pension within the £300 is wholly disregarded + # A pensioner whose only State Pension is £200 a year of graduated + # retirement benefit (ITEPA s.577(1)), with no other step-one income: step + # one is nil, and income is the £2,600 of earnings. Pension Credit is not + # claimed, so the income test applies; WTC is the £2,435 maximum. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 200 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + output: + tax_credits_current_year_income: 2_600 + tax_credits_applicable_income: 2_600 + working_tax_credit: 2_435 + +- name: Carer's Allowance and contributory ESA stay in step two, with no disregard + # Social security income (reg 7(1)(a)): Carer's Allowance £81.90 x 52 = + # £4,258.80 and contribution-based ESA £5,000; Table 3 disregards neither. + # Step one is nil, so income is 4,258.80 + 5,000 = £9,258.80. + # CTC: £4,000, below the £19,995 threshold. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + is_claimant_or_partner: true + carers_allowance: 4_258.80 + child_tax_credit_reported: 1 + partner: + age: 45 + is_claimant_or_partner: true + esa_contrib_reported: 5_000 + child: + age: 8 + is_claimant_or_partner: false + benunits: + benunit: + members: [carer, partner, child] + would_claim_uc: false + CTC_family_element: 545 + households: + household: + members: [carer, partner, child] + output: + tax_credits_current_year_income: 9_258.80 + child_tax_credit: 4_000 diff --git a/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py b/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py new file mode 100644 index 0000000000..2e00a3db27 --- /dev/null +++ b/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py @@ -0,0 +1,339 @@ +"""State Pension is step-one pension income for tax credits. + +SI 2002/2006 reg 3(1): Step One adds "the pension income (as defined in +regulation 5(1)), the investment income ..., the property income ..."; "If the +result of this step is £300 or less, it is treated as nil. If the result of +this step is more than £300, only the excess is taken into account". Pension +income includes "any pension to which section 577 ... of ITEPA applies" (reg +5(1)(a)), which is the State Pension (ITEPA 2003 s.577(1)), and reg 7(2) takes +it out of social security income, which is step two. + +Properties, for any family: + +1. Law oracle: tax_credits_current_year_income is the claimants' step-one + income (State Pension, private pension, savings interest, dividends and + property income) less £300, floored at nil, plus their step-two income + (earnings, trading income, Carer's Allowance, Carer Support Payment, + contribution-based JSA and ESA, Incapacity Benefit and miscellaneous + income), computed here directly from the inputs. +2. Against the previous formula, which put the State Pension in step two + (applied below as a reform): income never rises, and falls by exactly + min(State Pension, max(0, £300 - other step-one income)), so by at most + £300. It is unchanged where other step-one income is £300 or more. +3. State Pension is treated exactly as private pension income: moving an + amount from one to the other never changes the income. +4. Awards in 2024-25: WTC and CTC never fall, and together rise by at most + 41% of £300 plus the £26 minimum award. Guarantee Credit, which counts + working tax credit as income, never rises, and falls by no more than the + working tax credit gained. +5. In 2025-26, with no tax credit awards, every award and Pension Credit + equal the previous formula's. +6. Every component of social_security_income is counted exactly once: the + State Pension in step one, the rest in step two. +""" + +import numpy as np +from hypothesis import HealthCheck, event, example, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp import tax_credits_current_year_income as tc +from policyengine_uk.variables.gov.hmrc.income_tax.social_security_income import ( + social_security_income, +) + +ACTIVE_YEAR = 2024 # 2024-25, the last year of tax credit awards +INACTIVE_YEAR = 2025 +DISREGARD = 300 +TAPER = 0.41 +MIN_AWARD = 26 +TOL = 0.05 # float32 arithmetic on sums up to about £100,000 +OTHER_STEP_ONE = [ + "private_pension_income", + "savings_interest_income", + "dividend_income", + "property_income", +] +STEP_TWO = [ + "employment_income", + "self_employment_income", + "carers_allowance", + "carer_support_payment", + "jsa_contrib_reported", + "esa_contrib_reported", + "incapacity_benefit_reported", + "miscellaneous_income", +] +AWARDS = ["working_tax_credit", "child_tax_credit", "tax_credits"] +PENSION_CREDIT = ["guarantee_credit", "savings_credit", "pension_credit"] + + +class previous_tax_credits_current_year_income(tc.tax_credits_current_year_income): + # The formula before this change: the State Pension in step two, through + # social_security_income. + def formula(benunit, period, parameters): + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_qualifying_young_person_for_child_tax_credit", period + ) + TC = parameters(period).gov.dwp.tax_credits + income = add_for_members(benunit, period, OTHER_STEP_ONE, members) + income = max_(income - TC.means_test.non_earned_disregard, 0) + step_two = [ + "employment_income", + "self_employment_income", + "social_security_income", + "miscellaneous_income", + ] + return income + add_for_members(benunit, period, step_two, members) + + +previous_tax_credits_current_year_income.__name__ = "tax_credits_current_year_income" + + +class state_pension_in_step_two(Reform): + def apply(self): + self.update_variable(previous_tax_credits_current_year_income) + + +# Amounts around the £300 disregard, so other step-one income falls below, at +# and above it. +small = st.sampled_from([0, 0, 0, 50, 150, 299.99, 300, 300.01, 1_000, 5_000]) + + +def sometimes(amount): + return st.sampled_from([0, 0, 0, amount]) + + +@st.composite +def adults(draw): + earnings = draw(st.sampled_from([0, 0, 2_600, 6_000, 12_000, 25_000])) + return { + "age": draw(st.integers(25, 90)), + "state_pension": draw( + st.one_of( + st.sampled_from([0, 0, 200, 4_000, 6_600, 9_500, 11_500]), + st.floats(0, 15_000, allow_nan=False, allow_infinity=False), + ) + ), + **{source: draw(small) for source in OTHER_STEP_ONE}, + "employment_income": earnings, + "weekly_hours": draw(st.sampled_from([10, 16, 20, 30])) if earnings else 0, + "self_employment_income": draw(sometimes(3_000)), + "carers_allowance": draw(sometimes(4_258.80)), + "carer_support_payment": draw(sometimes(4_258.80)), + "jsa_contrib_reported": draw(sometimes(3_000)), + "esa_contrib_reported": draw(sometimes(5_000)), + "incapacity_benefit_reported": draw(sometimes(4_000)), + "miscellaneous_income": draw(sometimes(500)), + "working_tax_credit_reported": draw(st.sampled_from([0, 1])), + "child_tax_credit_reported": draw(st.sampled_from([0, 1])), + } + + +@st.composite +def families(draw): + members = [draw(adults())] + if draw(st.booleans()): + members.append(draw(adults())) + children = [ + {"age": draw(st.integers(0, 15))} for _ in range(draw(st.integers(0, 2))) + ] + benunit = {"would_claim_pc": draw(st.sampled_from([True, True, False]))} + household = {"savings": draw(st.sampled_from([0, 5_000, 12_000]))} + return members, children, benunit, household + + +def situation(units): + def at(value): + return {year: value for year in (ACTIVE_YEAR, INACTIVE_YEAR)} + + people, benunits, households = {}, {}, {} + for i, (members, children, benunit, household) in enumerate(units): + names = [] + for j, inputs in enumerate(members): + name = f"a{i}_{j}" + people[name] = {k: at(v) for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = at(True) + names.append(name) + for j, inputs in enumerate(children): + name = f"c{i}_{j}" + people[name] = {k: at(v) for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = at(False) + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": at(False), + **{k: at(v) for k, v in benunit.items()}, + } + households[f"h{i}"] = { + "members": names, + **{k: at(v) for k, v in household.items()}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def law(members): + """Reg 3(1) steps one and two, from the claimants' inputs.""" + state_pension = sum(m["state_pension"] for m in members) + other_step_one = sum(m[s] for m in members for s in OTHER_STEP_ONE) + step_two = sum(m[s] for m in members for s in STEP_TWO) + income = max(state_pension + other_step_one - DISREGARD, 0) + step_two + # The previous formula's loss of the disregard on State Pension. + change = min(state_pension, max(0, DISREGARD - other_step_one)) + return income, change + + +def moved_to_private_pension(unit): + members, children, benunit, household = unit + moved = [ + { + **m, + "state_pension": 0, + "private_pension_income": m["private_pension_income"] + m["state_pension"], + } + for m in members + ] + return moved, children, benunit, household + + +def calculate(sim, variables, year): + return {v: np.asarray(sim.calculate(v, year)) for v in variables} + + +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +# The YAML households (tax_credits_state_pension_step_one.yaml), run as +# examples: State Pension wholly, partly and not at all within the £300, and +# the case where Pension Credit is paid without the passport. +PENSIONER = { + **{k: 0 for k in ["state_pension", *OTHER_STEP_ONE, *STEP_TWO]}, + "age": 70, + "employment_income": 2_600, + "weekly_hours": 20, + "working_tax_credit_reported": 1, + "child_tax_credit_reported": 0, +} +YAML_FAMILIES = [ + [ + ( + [{**PENSIONER, "state_pension": 9_500}], + [], + {"would_claim_pc": True}, + {"savings": 0}, + ) + ], + [ + ( + [{**PENSIONER, "state_pension": 9_500, "savings_interest_income": 100}], + [], + {"would_claim_pc": True}, + {"savings": 0}, + ) + ], + [ + ( + [{**PENSIONER, "state_pension": 8_500, "private_pension_income": 1_000}], + [], + {"would_claim_pc": True}, + {"savings": 0}, + ) + ], + [ + ( + [{**PENSIONER, "state_pension": 6_600}], + [], + {"would_claim_pc": True}, + {"savings": 0}, + ) + ], + [ + ( + [{**PENSIONER, "state_pension": 200}], + [], + {"would_claim_pc": False}, + {"savings": 0}, + ) + ], +] + + +@SETTINGS +@given(st.lists(families(), min_size=1, max_size=6)) +@example(YAML_FAMILIES[0]) +@example(YAML_FAMILIES[1]) +@example(YAML_FAMILIES[2]) +@example(YAML_FAMILIES[3]) +@example(YAML_FAMILIES[4]) +def test_state_pension_is_step_one_income(units): + n = len(units) + sit = situation(units + [moved_to_private_pension(u) for u in units]) + new = Simulation(situation=sit) + old = Simulation(situation=sit) + old.apply_reform(state_pension_in_step_two) + + income = "tax_credits_current_year_income" + outputs = [income, *AWARDS, *PENSION_CREDIT] + new_values = calculate(new, outputs, ACTIVE_YEAR) + old_values = calculate(old, outputs, ACTIVE_YEAR) + new_inactive = calculate(new, outputs, INACTIVE_YEAR) + old_inactive = calculate(old, outputs, INACTIVE_YEAR) + + def gain(v): + return new_values[v][:n] - old_values[v][:n] + + for i, (members, *_rest) in enumerate(units): + expected, change = law(members) + context = (units[i], {k: v[i] for k, v in new_values.items()}) + # 1. The law, computed from the inputs. + assert abs(new_values[income][i] - expected) < TOL, context + # 2. Against the previous formula. + assert abs(old_values[income][i] - new_values[income][i] - change) < TOL, ( + context + ) + assert 0 <= change <= DISREGARD + # 3. State Pension is treated as private pension income. + assert abs(new_values[income][n + i] - new_values[income][i]) < TOL, context + # 5. No awards from 2025-26: only the income figure can differ. + for v in [*AWARDS, *PENSION_CREDIT]: + assert abs(new_inactive[v][i] - old_inactive[v][i]) < TOL, (v, context) + assert new_inactive["tax_credits"][i] == 0 + assert abs(old_inactive[income][i] - new_inactive[income][i] - change) < TOL + + # 4. Awards in 2024-25. + for v in ["working_tax_credit", "child_tax_credit", "tax_credits"]: + assert (gain(v) > -TOL).all(), (v, units) + assert (gain("tax_credits") <= TAPER * DISREGARD + MIN_AWARD + TOL).all(), units + assert (gain("guarantee_credit") < TOL).all(), units + assert (-gain("guarantee_credit") <= gain("working_tax_credit") + TOL).all(), units + + # Coverage (pytest --hypothesis-show-statistics). + for i, (members, *_rest) in enumerate(units): + change = law(members)[1] + event( + "income change: " + + ("none" if change == 0 else "£300" if change == DISREGARD else "partial") + ) + for v in ["working_tax_credit", "child_tax_credit"]: + if gain(v)[i] > TOL: + event(f"{v} raised") + if gain("guarantee_credit")[i] < -TOL: + event("guarantee_credit lowered") + + +def test_social_security_income_is_counted_once(): + # Reg 7(2): the State Pension is pension income, not social security + # income. Every other component of the model's social_security_income is + # reg 7 social security income. A new component must be classified here. + components = set(social_security_income.adds) + assert "state_pension" in tc.STEP_ONE_INCOME + assert "state_pension" not in tc.SOCIAL_SECURITY_INCOME + assert components == set(tc.SOCIAL_SECURITY_INCOME) | {"state_pension"} + assert not set(tc.STEP_ONE_INCOME) & set(tc.SOCIAL_SECURITY_INCOME) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py index cbddd40cfc..5678bfbe28 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py @@ -1,5 +1,42 @@ from policyengine_uk.model_api import * +# Step one of SI 2002/2006 reg 3(1): pension income (reg 5), investment income +# (reg 10), property income (reg 11), foreign income (reg 12) and notional +# income (reg 13). Pension income includes "any pension to which section 577 +# ... of ITEPA applies" (reg 5(1)(a)), and ITEPA 2003 s.577(1) applies to "the +# state pension", so the State Pension is step one, with the £300 disregard. +# private_pension_income is pension income (reg 5(1)(b), (d): ITEPA ss.569, +# 579A) or, for a foreign pension, foreign income (reg 12), which is also step +# one. savings_interest_income and dividend_income are investment income (reg +# 10(1)(a), (d)), and property_income is property income. +STEP_ONE_INCOME = [ + "state_pension", + "private_pension_income", + "savings_interest_income", + "dividend_income", + "property_income", +] + +# Social security income (reg 7): benefits payable under the Contributions and +# Benefits Act, the Jobseekers Act 1995 and Part 1 of the Welfare Reform Act +# 2007 (reg 7(1)(a)), and Carer Support Payment, made under s.28 of the Social +# Security (Scotland) Act 2018 (reg 7(1)(ab), from 15 March 2023). It is the +# model's social_security_income without the State Pension: "Pensions under the +# Contributions and Benefits Act which are pension income by virtue of +# regulation 5(1)(a) are not social security income" (reg 7(2)). The model's +# other benefits are disregarded by reg 7(3) Table 3, or are not under an Act +# reg 7(1) lists (Pension Credit, Universal Credit). Two partial disregards +# are not modelled: short-term lower-rate Incapacity Benefit and Incapacity +# Benefit after Invalidity Benefit (item 14), and contribution-based JSA above +# the ITEPA s.674 taxable maximum (item 16). +SOCIAL_SECURITY_INCOME = [ + "incapacity_benefit", + "jsa_contrib_reported", + "esa_contrib_reported", + "carers_allowance", + "carer_support_payment", +] + class tax_credits_current_year_income(Variable): value_type = float @@ -18,6 +55,9 @@ class tax_credits_current_year_income(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/2002/21/section/7", "https://www.legislation.gov.uk/uksi/2002/2006/regulation/3", + "https://www.legislation.gov.uk/uksi/2002/2006/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/2006/regulation/7", + "https://www.legislation.gov.uk/ukpga/2003/1/section/577", ) def formula(benunit, period, parameters): @@ -30,18 +70,19 @@ def formula(benunit, period, parameters): "is_child_or_qualifying_young_person_for_child_tax_credit", period ) TC = parameters(period).gov.dwp.tax_credits - STEP_1_COMPONENTS = [ - "private_pension_income", - "savings_interest_income", - "dividend_income", - "property_income", - ] - income = add_for_members(benunit, period, STEP_1_COMPONENTS, members) + income = add_for_members(benunit, period, STEP_ONE_INCOME, members) + # "If the result of this step is £300 or less, it is treated as nil. If + # the result of this step is more than £300, only the excess is taken + # into account in the following steps." The £300 is per claim, not per + # joint claimant. income = max_(income - TC.means_test.non_earned_disregard, 0) + # Step two: employment income (reg 4), social security income (reg 7) + # and miscellaneous income (reg 18). Trading income is step four, which + # adds it to the same total. STEP_2_COMPONENTS = [ "employment_income", "self_employment_income", - "social_security_income", + *SOCIAL_SECURITY_INCOME, "miscellaneous_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income From 87068e4998d144171eccf4d315c6c0cc2ea76271 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:43:37 -0400 Subject: [PATCH 2/2] Draw more families with no other step-one income Half the drawn adults now have no private pension, interest, dividends or property income, where the State Pension disregard changes the income. Co-Authored-By: Claude Opus 5.5 --- .../test_tax_credits_state_pension_step_one_properties.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py b/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py index 2e00a3db27..3aa316f160 100644 --- a/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py +++ b/policyengine_uk/tests/test_tax_credits_state_pension_step_one_properties.py @@ -110,6 +110,8 @@ def sometimes(amount): @st.composite def adults(draw): earnings = draw(st.sampled_from([0, 0, 2_600, 6_000, 12_000, 25_000])) + # Half the adults have no other step-one income, where the change bites. + other_step_one = draw(st.booleans()) return { "age": draw(st.integers(25, 90)), "state_pension": draw( @@ -118,7 +120,7 @@ def adults(draw): st.floats(0, 15_000, allow_nan=False, allow_infinity=False), ) ), - **{source: draw(small) for source in OTHER_STEP_ONE}, + **{s: draw(small) if other_step_one else 0 for s in OTHER_STEP_ONE}, "employment_income": earnings, "weekly_hours": draw(st.sampled_from([10, 16, 20, 30])) if earnings else 0, "self_employment_income": draw(sometimes(3_000)),