diff --git a/changelog.d/2517.fixed.md b/changelog.d/2517.fixed.md new file mode 100644 index 0000000000..ce59fe4bc0 --- /dev/null +++ b/changelog.d/2517.fixed.md @@ -0,0 +1 @@ +- Carer's Allowance earnings read their pension contribution deduction share (one-half, Computation of Earnings Regulations 1996 regs. 10(4)(b) and 13) from a new parameter, `gov.dwp.carers_allowance.pension_contribution_deduction_rate`, instead of a hard-coded 0.5, so the share can be reformed. diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0f6b963c1e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml @@ -0,0 +1,17 @@ +description: Carer's Allowance deducts this share of pension contributions when it calculates the net earnings and net profit that the earnings limit tests. +values: + 1996-11-25: 0.5 +metadata: + period: year + unit: /1 + label: Carer's Allowance earnings pension contribution deduction rate + economy: false + reference: + - title: The Social Security Benefit (Computation of Earnings) Regulations 1996, regulation 10(4)(b) (employed earners) + href: https://www.legislation.gov.uk/uksi/1996/2745/regulation/10 + - title: The Social Security Benefit (Computation of Earnings) Regulations 1996, regulation 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/1996/2745/regulation/13 + - title: The Social Security Benefit (Computation of Earnings) Regulations (Northern Ireland) 1996, regulation 10(4)(b) (employed earners) + href: https://www.legislation.gov.uk/nisr/1996/520/regulation/10 + - title: The Social Security Benefit (Computation of Earnings) Regulations (Northern Ireland) 1996, regulation 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners) + href: https://www.legislation.gov.uk/nisr/1996/520/regulation/13 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..723a7a972b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml @@ -0,0 +1,123 @@ +# Carer's Allowance net earnings deduct one-half of pension contributions +# (Computation of Earnings Regulations 1996 reg. 10(4)(b)) and of pension +# premiums from trading profit (regs. 13(1)(b)(ii), (4)(c) and (10)(b)). The +# share is gov.dwp.carers_allowance.pension_contribution_deduction_rate. Rates +# other than 0.5 below are model reforms, not current law. +# +# 2026-27: personal allowance, NI primary threshold and Class 4 lower profits +# limit £12,570; Class 4 main rate 6%; basic rate 20%. The earnings limit is +# £204 a week (£10,608 a year) and Carer's Allowance is £86.45 a week. Pay of +# £12,000 is under the allowance and the threshold, so the earnings test +# deducts no tax (it takes PAYE on the pay alone) and no Class 1. + +- name: At current law Carer's Allowance earnings deduct half of pension contributions + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.5 x 3,000 = 10,500, or £201.92 a week: under the limit. + pension_contributions: 3_000 + ni_class_1_employee: 0 + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: A lower share raises earnings by contributions times the change and ends entitlement + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.25 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.25 x 3,000 = 11,250, or £216.35 a week: over the limit. + # That is 750 more than at half: 3,000 x (0.5 - 0.25). + carers_allowance_earnings: 11_250 + is_gainfully_employed_for_carers_allowance: true + carers_allowance_pre_overlap: 0 + carers_allowance: 0 + +- name: At current law a carer with smaller contributions is over the earnings limit + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.5 x 2,000 = 11,000, or £211.54 a week: over the limit. + carers_allowance_earnings: 11_000 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: A higher share lowers earnings by contributions times the change and restores entitlement + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.75 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.75 x 2,000 = 10,500, or £201.92 a week: under the limit. + # That is 500 less than at half: 2,000 x (0.75 - 0.5). + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: A lower share raises trading earnings by premiums times the change + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.4 + age: 45 + self_employment_income: 15_000 + personal_pension_contributions: 7_700 + care_hours: 40 + country: ENGLAND + output: + # Notional tax 20% x 2,430 = 486 and Class 4 6% x 2,430 = 145.80 (reg. + # 14). At half: 15,000 - 486 - 145.80 - 3,850 = 10,518.20, or £202.27 a + # week, under the limit. At 0.4: 15,000 - 486 - 145.80 - 3,080 = + # 11,288.20, or £217.08 a week, over it: 770 more, 7,700 x (0.5 - 0.4). + carers_allowance_earnings: 11_288.20 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: A reform of the Housing Benefit share leaves Carer's Allowance earnings + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # The carer is over the limit, so no Carer's Allowance is paid and the + # £12,000 of pay, less relief on the contributions, is the only income: + # no income tax. Housing Benefit net earnings: 12,000 - 0.25 x 2,000 = + # 11,500. Carer's Allowance keeps its own half: 12,000 - 0.5 x 2,000 = + # 11,000, still £211.54 a week. + income_tax: 0 + housing_benefit_net_earnings: 11_500 + carers_allowance_earnings: 11_000 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 diff --git a/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py new file mode 100644 index 0000000000..be9c21dea3 --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py @@ -0,0 +1,194 @@ +"""Property-based tests for Carer's Allowance's pension contribution share. + +The Carer's Allowance earnings limit tests net earnings under the Social +Security Benefit (Computation of Earnings) Regulations 1996, which deduct one +half of pension contributions from an employed earner's net earnings (reg. +10(4)(b)) and one half of pension premiums from a self-employed earner's net +profit (regs. 13(1)(b)(ii), (4)(c) and (10)(b)). Northern Ireland's 1996 +Regulations (SR 1996/520) say the same. The model reads the share from +gov.dwp.carers_allowance.pension_contribution_deduction_rate. + +Invariants, for any generated population of working-age carers in any UK +country and year, with pay, trading profit (a loss counts as nil) and +employee and personal pension contributions of at most 30% of earnings, so +that no floor binds: + +1. Exactness: when the share moves from 0.5 to r, carers_allowance_earnings + falls by exactly the person's contributions x (r - 0.5). +2. Locality: the Housing Benefit share is reformed at the same time to a + different value, so earnings that read Housing Benefit's share would miss + invariant 1. +3. Eligibility follows earnings through the limit: on both sides the person + is gainfully employed exactly when weekly earnings, with a fraction of a + penny disregarded (reg. 5), exceed the limit, and then has no underlying + entitlement. The pence are counted in the precision the model stores + earnings in (float32), as the formula counts them. A lower share can only end entitlement and a higher share can + only restore it. +4. Current law: the share is 0.5 in every year from 2015 (the first year the + model simulates) to 2030. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +CA_SHARE = "gov.dwp.carers_allowance.pension_contribution_deduction_rate" +HB_SHARE = "gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate" +# Every period the model simulates. +ALL_YEARS = "2000-01-01.2100-12-31" +COUNTRIES = ["ENGLAND", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +YEARS = [2016, 2020, 2024, 2026, 2030] +# Shares on a grid of twentieths, each exact in binary. +SHARES = [i / 20 for i in range(21) if i != 10] +# The model stores amounts as float32, whose spacing is about 0.008 at the +# largest earnings drawn (up to 120,000), so two stored values can differ from +# the exact difference by more than a penny. +TOLERANCE = 0.05 + +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def carers(draw): + pay = draw(st.floats(0, 60_000, allow_nan=False)) + profit = draw(st.floats(-20_000, 60_000, allow_nan=False)) + earnings = pay + max(0.0, profit) + # Net earnings before the pension deduction are more than 60% of + # earnings in every year and country tested (about 70% at the top of + # the range), so contributions of at most 30% leave them positive at + # any share up to one. + employee_share = draw(st.one_of(st.just(0.0), st.floats(0, 0.15))) + personal_share = draw(st.one_of(st.just(0.0), st.floats(0, 0.15))) + return dict( + country=draw(st.sampled_from(COUNTRIES)), + age=draw(st.integers(25, 60)), + employment_income=pay, + self_employment_income=profit, + employee_pension_contributions=round(pay * employee_share, 2), + personal_pension_contributions=round(earnings * personal_share, 2), + ) + + +@st.composite +def reforms(draw): + ca, hb = draw( + st.lists(st.sampled_from(SHARES), min_size=2, max_size=2, unique=True) + ) + return dict(ca=ca, hb=hb) + + +def situation(people, year): + persons, benunits, households = {}, {}, {} + for i, carer in enumerate(people): + name = f"carer_{i}" + persons[name] = { + key: {year: value} for key, value in carer.items() if key != "country" + } + persons[name]["care_hours"] = {year: 40} + benunits[f"b{i}"] = {"members": [name]} + households[f"h{i}"] = { + "members": [name], + "country": {year: carer["country"]}, + } + return {"people": persons, "benunits": benunits, "households": households} + + +VARIABLES = [ + "pension_contributions", + "carers_allowance_earnings", + "is_gainfully_employed_for_carers_allowance", + "carers_allowance_pre_overlap", +] + + +def calculate(people, year, reform=None): + sim = Simulation(situation=situation(people, year), reform=reform) + # Keep each variable's stored dtype: the rounding check below must count + # pence in the precision the formula does. + return {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} + + +def over_limit(earnings, limit): + # Reg. 5: a fraction of a penny is disregarded. ``earnings`` is the stored + # array, so this counts the pence as is_gainfully_employed_for_carers_allowance + # does, in float32: promoting to float64 first floors 10,608.52 a year, + # stored as 10,608.51953125, to 204.00 a week instead of 204.01. + return np.floor(earnings / 52 * 100 + 1e-6) / 100 > limit + + +# A carer one penny a week over the 2026-27 limit (10,608.52 / 52 = 204.01), on +# the float32 boundary described in over_limit. +PENNY_OVER = dict( + country="ENGLAND", + age=45, + employment_income=10_608.52, + self_employment_income=0.0, + employee_pension_contributions=0.0, + personal_pension_contributions=0.0, +) + + +@PROPERTY_SETTINGS +@given( + people=st.lists(carers(), min_size=1, max_size=24), + shares=reforms(), + year=st.sampled_from(YEARS), +) +@example(people=[PENNY_OVER], shares=dict(ca=0.25, hb=0.75), year=2026) +def test_earnings_fall_by_contributions_times_the_change_in_share(people, shares, year): + base = calculate(people, year) + moved = calculate( + people, + year, + { + CA_SHARE: {ALL_YEARS: shares["ca"]}, + HB_SHARE: {ALL_YEARS: shares["hb"]}, + }, + ) + limit = system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit + for i, carer in enumerate(people): + case = (carer, shares, year) + contributions = ( + carer["employee_pension_contributions"] + + carer["personal_pension_contributions"] + ) + stored = float(base["pension_contributions"][i]) + assert abs(stored - contributions) < TOLERANCE, case + before = float(base["carers_allowance_earnings"][i]) + after = float(moved["carers_allowance_earnings"][i]) + if contributions > 0: + # Floors are slack: earnings stay positive on both sides. + assert before > 0 and after > 0, case + # Invariants 1 and 2: exactly contributions x the change in the + # Carer's Allowance share, not the Housing Benefit share. + change = before - after + assert abs(change - contributions * (shares["ca"] - 0.5)) < TOLERANCE, case + # Invariant 3: the gainful-employment test follows earnings. + for values in (base, moved): + over = over_limit(values["carers_allowance_earnings"][i], limit) + gainful = bool(values["is_gainfully_employed_for_carers_allowance"][i]) + assert gainful == over, case + if gainful: + assert values["carers_allowance_pre_overlap"][i] == 0, case + was = bool(base["is_gainfully_employed_for_carers_allowance"][i]) + now = bool(moved["is_gainfully_employed_for_carers_allowance"][i]) + if shares["ca"] < 0.5: + assert now or not was, case + else: + assert was or not now, case + + +def test_the_share_is_one_half_in_every_year(): + for year in range(2015, 2031): + for day in ("01-01", "04-30"): + parameters = system.parameters(f"{year}-{day}") + share = parameters.gov.dwp.carers_allowance + assert share.pension_contribution_deduction_rate == 0.5, (year, day) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py index b49b6618d0..e3dc980d29 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py @@ -9,8 +9,10 @@ class carers_allowance_earnings(Variable): documentation = ( "Net earnings from employment and self-employment, as the Carer's " "Allowance gainful-employment test counts them (Computation of " - "Earnings Regulations 1996), less half of the person's pension " - "contributions. A trading loss counts as nil.\n\n" + "Earnings Regulations 1996), less a share of the person's pension " + "contributions: one half in law (regs. 10(4)(b) and 13(4)(c)), read " + "from gov.dwp.carers_allowance.pension_contribution_deduction_rate. " + "A trading loss counts as nil.\n\n" "Employment (reg. 10(4)): gross pay less the income tax and primary " "Class 1 contributions deducted from it. The law deducts the tax " "actually withheld; the model approximates it as the tax PAYE would " @@ -119,7 +121,12 @@ def formula(person, period, parameters): * class_2_contribution_weeks(period.start.year) ) - half_pension_contributions = 0.5 * person("pension_contributions", period) + # Regs. 10(4)(b) and 13(1)(b)(ii), (4)(c) and (10)(b): one half of + # pension contributions and premiums. + pension_share = parameters( + period + ).gov.dwp.carers_allowance.pension_contribution_deduction_rate + pension_deduction = pension_share * person("pension_contributions", period) return max_( 0, pay @@ -129,5 +136,5 @@ def formula(person, period, parameters): - trading_tax - trading_class_4 - trading_class_2 - - half_pension_contributions, + - pension_deduction, )