From ef65d9f05299d66620c3aeba569711b5e5c1bfae Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 01:45:18 -0400 Subject: [PATCH 1/7] Apply the pensioner Council Tax Reduction earnings disregards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SI 2012/2885 Sch 4 (England), WSI 2013/3029 Sch 3 (Wales; SI 2012/3144 for 2013-14) and SSI 2012/319 Sch 2 (Scotland) disregard from a pension-age applicant's and partner's net earnings £25 a week for a lone parent, £10 for a couple and £5 otherwise, plus £17.10 where a work condition is met and net earnings at least equal the other disregards, the childcare charges and £17.10. Scotland read £37.10 from 6 April 2020 to 4 April 2021 (SSI 2020/108). The model disregarded no earnings. The schedules repeat the Housing Benefit pension-age schedule word for word and net earnings are defined as for Housing Benefit (SI 2012/2885 Sch 1 para 19), so the Housing Benefit net earnings and work conditions from PolicyEngine/policyengine-uk#1908 are reused. Stacked on #1908. Co-Authored-By: Claude Opus 5.5 --- ...ctr-pensioner-earnings-disregards.fixed.md | 1 + .../pensioners/earnings_disregard.yaml | 47 ++ .../pensioners/earnings_disregard.yaml | 55 +++ .../pensioners/earnings_disregard.yaml | 55 +++ ...eduction_pensioner_earnings_disregard.yaml | 430 ++++++++++++++++++ ...pensioner_earnings_disregard_properties.py | 270 +++++++++++ ...council_tax_reduction_applicable_income.py | 17 +- ..._reduction_pensioner_earnings_disregard.py | 80 ++++ 8 files changed, 952 insertions(+), 3 deletions(-) create mode 100644 changelog.d/ctr-pensioner-earnings-disregards.fixed.md create mode 100644 policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/earnings_disregard.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/pensioners/earnings_disregard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py diff --git a/changelog.d/ctr-pensioner-earnings-disregards.fixed.md b/changelog.d/ctr-pensioner-earnings-disregards.fixed.md new file mode 100644 index 0000000000..ee352c3764 --- /dev/null +++ b/changelog.d/ctr-pensioner-earnings-disregards.fixed.md @@ -0,0 +1 @@ +- Apply the pensioner Council Tax Reduction earnings disregards in England (SI 2012/2885 Sch 4), Wales (WSI 2013/3029 Sch 3) and Scotland (SSI 2012/319 Sch 2). A pension-age family's net earnings are now reduced by £25 a week for a lone parent, £10 for a couple and £5 otherwise, capped at net earnings, plus the £17.10 additional earnings disregard where a work condition is met and net earnings cover it (£37.10 in Scotland from 6 April 2020 to 4 April 2021). These sums are fixed in law and are not uprated. Before, pensioner CTR disregarded no earnings. diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/earnings_disregard.yaml new file mode 100644 index 0000000000..9b25143c68 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/earnings_disregard.yaml @@ -0,0 +1,47 @@ +description: Weekly sums the English Council Tax Reduction prescribed requirements disregard from the net earnings of a pensioner applicant and partner. +single: + description: Weekly earnings disregard for a pensioner applicant who has no partner and is not a lone parent, where no £20 disregard applies. + values: + 2013-04-01: 5 + metadata: + label: England pensioner CTR earnings disregard, single + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 4 para. 8(a) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/4/paragraph/8 +couple: + description: Weekly earnings disregard for a pensioner applicant who has a partner, where no £20 disregard applies. + values: + 2013-04-01: 10 + metadata: + label: England pensioner CTR earnings disregard, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 4 para. 8(b) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/4/paragraph/8 +lone_parent: + description: Weekly earnings disregard for a pensioner applicant who is a lone parent. + values: + 2013-04-01: 25 + metadata: + label: England pensioner CTR earnings disregard, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 4 paras. 1(a) and 2 + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/4 +additional: + description: Weekly increase in the earnings disregard (the additional earnings disregard) where the pensioner applicant meets one of its work conditions and their net earnings at least equal the other disregards, the deductible childcare charges and this amount. + values: + 2013-04-01: 17.1 + metadata: + label: England pensioner CTR additional earnings disregard + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 4 para. 10(1) and (3)(c) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/4/paragraph/10 +metadata: + label: England pensioner CTR earnings disregards diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml new file mode 100644 index 0000000000..02e7940133 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml @@ -0,0 +1,55 @@ +description: Weekly sums the Scottish Council Tax Reduction (State Pension Credit) Regulations disregard from the net earnings of an applicant who has attained the qualifying age for state pension credit, and their partner. +single: + description: Weekly earnings disregard for a pension-age applicant who has no partner and is not a lone parent, where no £20 disregard applies. + values: + 2013-04-01: 5 + metadata: + label: Scotland pension-age CTR earnings disregard, single + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 Sch. 2 para. 8(a) + href: https://www.legislation.gov.uk/ssi/2012/319/schedule/2/paragraph/8 +couple: + description: Weekly earnings disregard for a pension-age applicant who has a partner, where no £20 disregard applies. + values: + 2013-04-01: 10 + metadata: + label: Scotland pension-age CTR earnings disregard, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 Sch. 2 para. 8(b) + href: https://www.legislation.gov.uk/ssi/2012/319/schedule/2/paragraph/8 +lone_parent: + description: Weekly earnings disregard for a pension-age applicant who is a lone parent. + values: + 2013-04-01: 25 + metadata: + label: Scotland pension-age CTR earnings disregard, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 Sch. 2 paras. 1(a) and 2 + href: https://www.legislation.gov.uk/ssi/2012/319/schedule/2 +additional: + description: Weekly increase in the earnings disregard (the additional earnings disregard) where the pension-age applicant meets one of its work conditions and their net earnings at least equal the other disregards, the deductible childcare charges and this amount. + values: + 2013-04-01: 17.1 + # The Council Tax Reduction (Scotland) Amendment (No. 3) (Coronavirus) + # Regulations 2020 (SSI 2020/108) reg. 3(a), in force 6 April 2020, + # substituted "£37.10 until and including 4 April 2021, and after that + # by £17.10" in para. 10(1) and (3)(c). + 2020-04-06: 37.1 + 2021-04-05: 17.1 + metadata: + label: Scotland pension-age CTR additional earnings disregard + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 Sch. 2 para. 10(1) and (3)(c) + href: https://www.legislation.gov.uk/ssi/2012/319/schedule/2/paragraph/10 + - title: The Council Tax Reduction (Scotland) Amendment (No. 3) (Coronavirus) Regulations 2020 reg. 3(a) + href: https://www.legislation.gov.uk/ssi/2020/108/regulation/3 +metadata: + label: Scotland pension-age CTR earnings disregards diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/pensioners/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/pensioners/earnings_disregard.yaml new file mode 100644 index 0000000000..ef67278ac0 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/pensioners/earnings_disregard.yaml @@ -0,0 +1,55 @@ +description: Weekly sums the Welsh Council Tax Reduction prescribed requirements disregard from the net earnings of a pensioner applicant and partner. +single: + description: Weekly earnings disregard for a pensioner applicant who has no partner and is not a lone parent, where no £20 disregard applies. + values: + 2013-04-01: 5 + metadata: + label: Wales pensioner CTR earnings disregard, single + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 3 para. 8(a) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/3/paragraph/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 Sch. 3 para. 8(a) (financial year 2013-14) + href: https://www.legislation.gov.uk/wsi/2012/3144/schedule/3/made +couple: + description: Weekly earnings disregard for a pensioner applicant who has a partner, where no £20 disregard applies. + values: + 2013-04-01: 10 + metadata: + label: Wales pensioner CTR earnings disregard, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 3 para. 8(b) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/3/paragraph/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 Sch. 3 para. 8(b) (financial year 2013-14) + href: https://www.legislation.gov.uk/wsi/2012/3144/schedule/3/made +lone_parent: + description: Weekly earnings disregard for a pensioner applicant who is a lone parent. + values: + 2013-04-01: 25 + metadata: + label: Wales pensioner CTR earnings disregard, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 3 paras. 1(a) and 2 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/3 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 Sch. 3 paras. 1(a) and 2 (financial year 2013-14) + href: https://www.legislation.gov.uk/wsi/2012/3144/schedule/3/made +additional: + description: Weekly increase in the earnings disregard (the additional earnings disregard) where the pensioner applicant meets one of its work conditions and their net earnings at least equal the other disregards, the deductible childcare charges and this amount. + values: + 2013-04-01: 17.1 + metadata: + label: Wales pensioner CTR additional earnings disregard + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 3 para. 10(1) and (3)(c) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/3/paragraph/10 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 Sch. 3 para. 10(1) and (3)(c) (financial year 2013-14) + href: https://www.legislation.gov.uk/wsi/2012/3144/schedule/3/made +metadata: + label: Wales pensioner CTR earnings disregards diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml new file mode 100644 index 0000000000..b7c8c040af --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml @@ -0,0 +1,430 @@ +# Pensioner Council Tax Reduction earnings disregards. +# +# SI 2012/2885 (England) Sch 4, WSI 2013/3029 (Wales) Sch 3 and SSI 2012/319 +# (Scotland) Sch 2 disregard from a pension-age applicant's and partner's net +# earnings £25 a week for a lone parent (para 2), otherwise £5 with no partner +# or £10 with a partner (para 8), plus £17.10 (para 10) where a work condition +# is met and net earnings at least equal the other disregards, the deductible +# childcare charges and £17.10. Scotland read £37.10 for £17.10 from 6 April +# 2020 to 4 April 2021 (SSI 2020/108 reg 3(a)); England and Wales did not. +# +# Every person here is aged 70 (pension age, no employee National Insurance) +# unless stated, and earns less than the personal allowance, so net earnings +# equal gross earnings unless stated. Annual figures are weekly x 52. + +- name: England pensioner without earnings has no earnings disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 15_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pensioner_earnings_disregard: 0 + +- name: England single pensioner earning 1,000 a year has 5 a week disregarded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 1_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + housing_benefit_net_earnings: 1_000 + # 5 x 52. + council_tax_reduction_pensioner_earnings_disregard: 260 + +- name: England single pensioner earning 3 a week has all 3 disregarded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 156 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + # min(260, 3 x 52 = 156). + council_tax_reduction_pensioner_earnings_disregard: 156 + +- name: England pensioner couple has 10 a week disregarded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + employment_income: 2_000 + partner: + age: 70 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + country: ENGLAND + output: + # 10 x 52. + council_tax_reduction_pensioner_earnings_disregard: 520 + +- name: England pensioner couple earning 3 and 4 a week has both disregarded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + employment_income: 156 + partner: + age: 70 + employment_income: 208 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + country: ENGLAND + output: + # min(520, 156 + 208 = 364). + council_tax_reduction_pensioner_earnings_disregard: 364 + +- name: England pensioner lone parent has 25 a week disregarded, not 25 plus 5 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + employment_income: 2_000 + weekly_hours: 10 + child: + age: 10 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + output: + # 25 x 52. Para 8's 5 is not added (as for Housing Benefit, DWP HB + # guidance BP2 para P2.159). 10 hours meets no work condition. + council_tax_reduction_pensioner_earnings_disregard: 1_300 + +- name: England pensioner working 30 hours gets the additional 17.10 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + meets_housing_benefit_additional_earnings_disregard_conditions: true + # (5 + 17.10) x 52 = 260 + 889.20; net earnings 10,000 >= 1,149.20. + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: England pensioner working 29 hours does not get the additional disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 29 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + meets_housing_benefit_additional_earnings_disregard_conditions: false + council_tax_reduction_pensioner_earnings_disregard: 260 + +- name: Net earnings exactly equal to the disregards and 17.10 meet the earnings test + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 1_149.20 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + # Para 10(1): "equal or exceed" 260 + 0 childcare + 889.20 = 1,149.20. + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: Net earnings a penny short of the disregards and 17.10 fail the earnings test + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 1_149.19 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pensioner_earnings_disregard: 260 + +- name: England disabled pensioner working 20 hours gets the additional disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 20 + is_disabled_for_benefits: true + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + # Para 10(2)(b)(iv): 16 hours and para 5(1) satisfied. The model gives + # 5 + 17.10 = 1,149.20 a year; the law gives the para 5(1) 20 in place of + # the 5, so 1,929.20. The 20 disregards are a known limitation. + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: England pensioner couple, one working 30 hours, gets 10 plus 17.10 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + partner: + age: 68 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + country: ENGLAND + output: + # (10 + 17.10) x 52 = 520 + 889.20. + council_tax_reduction_pensioner_earnings_disregard: 1_409.20 + +- name: Wales pensioner working 30 hours gets 5 plus 17.10 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + output: + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: Scotland pensioner working 30 hours gets 5 plus 17.10 in 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + output: + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: Scotland pensioner working 30 hours gets 5 plus 37.10 in 2020 + period: 2020 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + output: + # SSI 2020/108: (5 + 37.10) x 52 = 260 + 1,929.20. + council_tax_reduction_pensioner_earnings_disregard: 2_189.20 + +- name: Scotland pensioner working 30 hours is back to 5 plus 17.10 in 2021 + period: 2021 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + output: + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: England pensioner working 30 hours gets 5 plus 17.10 in 2020 + period: 2020 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 10_000 + weekly_hours: 30 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + output: + # No coronavirus increase in the English prescribed requirements. + council_tax_reduction_pensioner_earnings_disregard: 1_149.20 + +- name: Scottish working-age applicant has no pensioner earnings disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 40 + employment_income: 10_000 + weekly_hours: 35 + benunits: + benunit: + members: [worker] + households: + household: + members: [worker] + country: SCOTLAND + output: + council_tax_reduction_pensioner_earnings_disregard: 0 + +- name: Northern Ireland pensioner has no Council Tax Reduction earnings disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 1_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: NORTHERN_IRELAND + output: + council_tax_reduction_pensioner_earnings_disregard: 0 + +- name: England pensioner with State Pension and earnings has 5 a week of earnings disregarded in CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 15_000 + employment_income: 1_000 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + households: + household: + members: [pensioner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + # (15,000 + 1,000 - 12,570) x 20%. + income_tax: 686 + guarantee_credit: 0 + in_receipt_of_savings_credit_only: false + # 1,000 - 686 x 1,000 / 16,000. + housing_benefit_net_earnings: 957.13 + council_tax_reduction_pensioner_earnings_disregard: 260 + # 15,000 + 1,000 - 686 - 260 (main: 15,314). + council_tax_reduction_applicable_income: 15_054 + council_tax_reduction_applicable_amount: 13_312 + # 2,000 - 20% x (15,054 - 13,312) (main: 1,599.60). + council_tax_reduction: 1_651.60 diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py new file mode 100644 index 0000000000..f2171365e4 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -0,0 +1,270 @@ +"""Property-based tests for the pensioner Council Tax Reduction earnings disregards. + +SI 2012/2885 Sch 4 (England), WSI 2013/3029 Sch 3 (Wales) and SSI 2012/319 +Sch 2 (Scotland) disregard from a pension-age applicant's and partner's net +earnings £25 a week for a lone parent, £10 for a couple and £5 for anyone +else, plus £17.10 where a work condition is met and net earnings at least +equal the other disregards, the deductible childcare charges and £17.10. +Scotland read £37.10 for £17.10 from 6 April 2020 to 4 April 2021 (SSI +2020/108). The schedules repeat the Housing Benefit pension-age schedule (SI +2006/214 Sch 4) word for word. + +Invariants, for any generated population of families: + +1. Bounds: 0 <= disregard <= min(net earnings, (£25 + £37.10) x 52); no + earnings, no disregard; zero for a family with no member over State + Pension age, and zero in Northern Ireland. +2. The statutory sums: in every year from 2015 (the first year the model + simulates) to 2030 and in each nation, a pension-age family with enough + earnings and no work condition has exactly its weekly sum x 52 + disregarded, £5, £10 or £25; with a work condition, that plus £17.10 x 52 + (£37.10 in Scotland in 2020). The parameters themselves hold those sums, + without uprating, in every year from 2013-14, when the schemes began. +3. Metamorphic: the disregard is non-decreasing in employment income. +4. The disregard never raises Council Tax Reduction applicable income, lowers + it by no more than the claimant's and partner's gross earnings, and never + lowers the award. +5. Differential against Housing Benefit: for pension-age families without + childcare charges, the CTR disregard equals the Housing Benefit disregard + in every nation and year, except England and Wales in 2020, where Housing + Benefit's additional sum was £37.10 (SI 2020/371 reg 5) and CTR's stayed + £17.10 (an intended difference, which the test pins). +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +WEEKS = 52 +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +NATIONS = ["ENGLAND", "WALES", "SCOTLAND"] +SHAPES = { + "single": (1, 0), + "couple": (2, 0), + "lone_parent": (1, 2), + "couple_with_children": (2, 1), +} +WEEKLY = {"single": 5, "couple": 10, "lone_parent": 25, "couple_with_children": 10} +# State Pension age rises from 66 to 67 between 2026 and 2028, so 68 and over +# is pension age in every year tested and 60 and under is working age. +PENSION_AGE = st.integers(68, 90) +ADULT_AGE = st.one_of(st.integers(18, 90), st.sampled_from([24, 25, 60, 68])) +HOURS = st.one_of( + st.sampled_from([0.0, 15.0, 16.0, 29.0, 30.0]), st.floats(0, 50, allow_nan=False) +) +EARNINGS = st.one_of( + st.just(0.0), + st.floats(0, 3_000, allow_nan=False), + st.floats(0, 60_000, allow_nan=False), +) +MAX_ANNUAL = (25 + 37.1) * WEEKS + + +def additional_weekly(nation, year): + return 37.1 if (nation == "SCOTLAND" and year == 2020) else 17.1 + + +@st.composite +def adults(draw, age, earnings): + return dict( + age=draw(age), + employment_income=draw(earnings), + weekly_hours=draw(HOURS), + disabled=draw(st.booleans()), + state_pension=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), + ) + + +@st.composite +def families(draw, age=ADULT_AGE, earnings=EARNINGS, nations=NATIONS): + shape = draw(st.sampled_from(sorted(SHAPES))) + n_adults, n_children = SHAPES[shape] + return dict( + shape=shape, + nation=draw(st.sampled_from(nations)), + adults=[draw(adults(age, earnings)) for _ in range(n_adults)], + children=[draw(st.integers(0, 15)) for _ in range(n_children)], + ) + + +def situation(units, year, earnings_bump=0.0): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"a{i}_{j}" + people[name] = { + "age": {year: adult["age"]}, + "employment_income": { + year: adult["employment_income"] + (earnings_bump if j == 0 else 0) + }, + "weekly_hours": {year: adult["weekly_hours"]}, + "is_disabled_for_benefits": {year: adult["disabled"]}, + "state_pension": {year: adult["state_pension"]}, + } + names.append(name) + for k, child_age in enumerate(unit["children"]): + name = f"c{i}_{k}" + people[name] = {"age": {year: child_age}} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "claims_all_entitled_benefits": {year: True}, + } + households[f"h{i}"] = { + "members": names, + "country": {year: unit["nation"]}, + "council_tax": {year: 2_000.0}, + "savings": {year: 0.0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +VARIABLES = [ + "council_tax_reduction_pensioner_earnings_disregard", + "housing_benefit_net_earnings", + "housing_benefit_applicable_income_disregard", + "meets_housing_benefit_additional_earnings_disregard_conditions", +] + + +def calculate(units, year=2026, variables=VARIABLES, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs)) + return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in variables} + + +def gross_earnings(unit): + return sum(max(adult["employment_income"], 0) for adult in unit["adults"]) + + +def working_age(unit): + return all(adult["age"] <= 60 for adult in unit["adults"]) + + +@PROPERTY_SETTINGS +@given( + st.lists(families(nations=NATIONS + ["NORTHERN_IRELAND"]), min_size=1, max_size=30) +) +def test_disregard_is_bounded_and_only_for_pension_age_families_in_great_britain( + units, +): + values = calculate(units) + disregard = values["council_tax_reduction_pensioner_earnings_disregard"] + net = values["housing_benefit_net_earnings"] + for i, unit in enumerate(units): + assert disregard[i] >= 0, unit + assert disregard[i] <= min(net[i], MAX_ANNUAL) + 0.01, unit + if gross_earnings(unit) == 0: + assert disregard[i] == 0, unit + if working_age(unit) or unit["nation"] == "NORTHERN_IRELAND": + assert disregard[i] == 0, unit + + +@PROPERTY_SETTINGS +@given( + st.lists( + families(age=PENSION_AGE, earnings=st.floats(20_000, 40_000)), + min_size=1, + max_size=30, + ), + st.integers(2015, 2030), +) +def test_disregard_is_the_statutory_weekly_sum_in_every_year(units, year): + values = calculate(units, year=year) + disregard = values["council_tax_reduction_pensioner_earnings_disregard"] + conditions = values[ + "meets_housing_benefit_additional_earnings_disregard_conditions" + ].astype(bool) + for i, unit in enumerate(units): + weekly = WEEKLY[unit["shape"]] + if conditions[i]: + weekly += additional_weekly(unit["nation"], year) + assert abs(disregard[i] - weekly * WEEKS) < 0.01, (unit, year) + + +def test_parameters_hold_the_statutory_sums_without_uprating(): + for year in range(2013, 2031): + p = system.parameters(year).gov.local_authorities + for nation in NATIONS: + sums = getattr( + p, nation.lower() + ).council_tax_reduction.pensioners.earnings_disregard + assert (sums.single, sums.couple, sums.lone_parent) == (5, 10, 25), ( + nation, + year, + ) + assert sums.additional == additional_weekly(nation, year), (nation, year) + + +@PROPERTY_SETTINGS +@given( + st.lists(families(), min_size=1, max_size=30), + st.floats(1, 5_000, allow_nan=False, allow_infinity=False), +) +def test_disregard_is_non_decreasing_in_earnings(units, bump): + key = "council_tax_reduction_pensioner_earnings_disregard" + before = calculate(units)[key] + after = calculate(units, earnings_bump=bump)[key] + for i, unit in enumerate(units): + assert after[i] >= before[i] - 0.01, (unit, bump) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_disregard_lowers_income_by_no_more_than_earnings_and_never_cuts_ctr(units): + keys = [ + "council_tax_reduction_applicable_income", + "simulated_council_tax_reduction_benunit", + ] + with_disregard = calculate(units, variables=keys) + sim = Simulation(situation=situation(units, 2026)) + sim.set_input( + "council_tax_reduction_pensioner_earnings_disregard", + 2026, + np.zeros(len(units)), + ) + without = {k: np.asarray(sim.calculate(k, 2026), dtype=float) for k in keys} + income, award = keys + for i, unit in enumerate(units): + assert with_disregard[income][i] <= without[income][i] + 0.01, unit + drop = without[income][i] - with_disregard[income][i] + assert drop <= gross_earnings(unit) + 0.01, unit + assert with_disregard[award][i] >= without[award][i] - 0.01, unit + + +@PROPERTY_SETTINGS +@given( + st.lists(families(age=PENSION_AGE), min_size=1, max_size=30), + st.sampled_from([2019, 2020, 2021, 2026]), +) +def test_differential_against_the_housing_benefit_pension_age_disregard(units, year): + values = calculate(units, year=year) + ctr = values["council_tax_reduction_pensioner_earnings_disregard"] + hb = values["housing_benefit_applicable_income_disregard"] + net = values["housing_benefit_net_earnings"] + conditions = values[ + "meets_housing_benefit_additional_earnings_disregard_conditions" + ].astype(bool) + for i, unit in enumerate(units): + if year == 2020 and unit["nation"] != "SCOTLAND": + # Intended difference: SI 2020/371 raised Housing Benefit's + # additional sum to £37.10; the English and Welsh CTR rules kept + # £17.10. Each still gives its own statutory amount. + flat = min(WEEKLY[unit["shape"]] * WEEKS, net[i]) + for value, additional in ((ctr[i], 17.1), (hb[i], 37.1)): + expected = flat + if conditions[i] and round(net[i], 2) >= round( + flat + additional * WEEKS, 2 + ): + expected += additional * WEEKS + assert abs(value - expected) < 0.01, (unit, year) + else: + assert abs(ctr[i] - hb[i]) < 0.01, (unit, year) 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 ea624d139d..f31e22da90 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 @@ -11,8 +11,10 @@ class council_tax_reduction_applicable_income(Variable): "whose partner, is in receipt of Pension Credit guarantee credit, and " "use the Secretary of State's Pension Credit assessment of income, plus " "the savings credit payable, where the award is savings credit only. " - "The other adjustments those provisions allow (childcare charges, lone " - "parent and maintenance disregards, and the rest) are not modelled." + "Otherwise a pension-age family's net earnings are reduced by the " + "pensioner earnings disregards. The other adjustments those " + "provisions allow (childcare charges, the £20 earnings disregards, " + "maintenance disregards and the rest) are not modelled." ) definition_period = YEAR unit = GBP @@ -23,6 +25,9 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/8", "https://www.legislation.gov.uk/ssi/2012/319/regulation/24", "https://www.legislation.gov.uk/ssi/2012/319/regulation/25", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", ] def formula(benunit, period, parameters): @@ -61,8 +66,14 @@ def formula(benunit, period, parameters): pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 tax = add(benunit, period, ["income_tax", "national_insurance"]) + # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 3; SSI + # 2012/319 Sch 2: the pensioner earnings disregards, capped at net + # earnings. Zero for working-age families. + earnings_disregard = benunit( + "council_tax_reduction_pensioner_earnings_disregard", period + ) income_under_general_rules = max_( - 0, increased_income - tax - pension_contributions + 0, increased_income - tax - pension_contributions - earnings_disregard ) # SI 2012/2885 Sch 1 para 13, WSI 2013/3029 Sch 1 para 7 and SSI diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py new file mode 100644 index 0000000000..2af64f92dc --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -0,0 +1,80 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country + + +class council_tax_reduction_pensioner_earnings_disregard(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction pensioner earnings disregard" + documentation = ( + "Sums disregarded from the net earnings of a pension-age applicant and " + "partner under the national Council Tax Reduction rules for " + "pensioners in England, Wales and Scotland. A lone parent has £25 a " + "week, an applicant with a partner £10 and anyone else £5, capped at " + "net earnings. The additional earnings disregard of £17.10 (£37.10 " + "in Scotland from 6 April 2020 to 4 April 2021) is added where a work " + "condition is met and net earnings at least equal the other " + "disregards and the additional amount. The schedules repeat the " + "Housing Benefit pension-age schedule (SI 2006/214 Sch 4) word for " + "word, and net earnings are defined as for Housing Benefit, so the " + "Housing Benefit net earnings and work conditions are used. The " + "earnings test leaves out childcare charges, which the model does not " + "deduct from Council Tax Reduction income. The £20 disregards for " + "disabled people, carers and some part-time occupations, and the " + "exempt work disregard, are not modelled. Zero for working-age " + "families, whose schemes are local in England and have their own " + "schedules in Wales and Scotland, and outside Great Britain." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + england = p.england.council_tax_reduction.pensioners.earnings_disregard + wales = p.wales.council_tax_reduction.pensioners.earnings_disregard + scotland = p.scotland.council_tax_reduction.pensioners.earnings_disregard + country = benunit.household("country", period) + in_england = country == Country.ENGLAND + in_wales = country == Country.WALES + in_scotland = country == Country.SCOTLAND + + def national(amount): + return select( + [in_wales, in_scotland], + [wales[amount], scotland[amount]], + default=england[amount], + ) + + net_earnings = benunit("housing_benefit_net_earnings", period) + # SI 2012/2885 Sch 4 paras 2 and 8; WSI 2013/3029 Sch 3 paras 2 and 8; + # SSI 2012/319 Sch 2 paras 2 and 8. A lone parent has £25 in all, + # not £25 plus para 8's £5, as for Housing Benefit. + weekly_amount = select( + [benunit("is_lone_parent", period), benunit("is_couple", period)], + [national("lone_parent"), national("couple")], + default=national("single"), + ) + standard = min_(weekly_amount * WEEKS_IN_YEAR, net_earnings) + # Para 10 in each: the additional earnings disregard. + additional_amount = national("additional") * WEEKS_IN_YEAR + # "Equal or exceed", compared in pence: net earnings are float32. + covers_additional = np.round(net_earnings.astype(float), 2) >= np.round( + (standard + additional_amount).astype(float), 2 + ) + additional = where( + benunit( + "meets_housing_benefit_additional_earnings_disregard_conditions", period + ) + & covers_additional, + additional_amount, + 0, + ) + pension_age = benunit.any(benunit.members("is_SP_age", period)) + in_great_britain = in_england | in_wales | in_scotland + return where(pension_age & in_great_britain, standard + additional, 0) From 5006d9eaa36080120811220f9cde9cbfde2d47c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 01:59:00 -0400 Subject: [PATCH 2/7] Cite the Welsh and Scottish rules that apply the earnings schedules WSI 2013/3029 Sch 1 para 11(9) and 13; SSI 2012/319 regs 31(8) and 33. Co-Authored-By: Claude Opus 5.5 --- ...council_tax_reduction_applicable_income.py | 7 ++++--- ..._reduction_pensioner_earnings_disregard.py | 20 +++++++++++-------- 2 files changed, 16 insertions(+), 11 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 f31e22da90..66c596fa72 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 @@ -66,9 +66,10 @@ def formula(benunit, period, parameters): pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 tax = add(benunit, period, ["income_tax", "national_insurance"]) - # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 3; SSI - # 2012/319 Sch 2: the pensioner earnings disregards, capped at net - # earnings. Zero for working-age families. + # 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 + # families. earnings_disregard = benunit( "council_tax_reduction_pensioner_earnings_disregard", period ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py index 2af64f92dc..00de12054d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -16,14 +16,16 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "condition is met and net earnings at least equal the other " "disregards and the additional amount. The schedules repeat the " "Housing Benefit pension-age schedule (SI 2006/214 Sch 4) word for " - "word, and net earnings are defined as for Housing Benefit, so the " - "Housing Benefit net earnings and work conditions are used. The " - "earnings test leaves out childcare charges, which the model does not " - "deduct from Council Tax Reduction income. The £20 disregards for " - "disabled people, carers and some part-time occupations, and the " - "exempt work disregard, are not modelled. Zero for working-age " - "families, whose schemes are local in England and have their own " - "schedules in Wales and Scotland, and outside Great Britain." + "word, and net earnings are defined as for Housing Benefit (SI " + "2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 para 13, SSI 2012/319 " + "reg 33), so the Housing Benefit net earnings and work conditions " + "are used. The earnings test leaves out childcare charges, which the " + "model does not deduct from Council Tax Reduction income. The £20 " + "disregards for disabled people, carers and some part-time " + "occupations, and the exempt work disregard, are not modelled. Zero " + "for working-age families, whose schemes are local in England and " + "have their own schedules in Wales and Scotland, and outside Great " + "Britain." ) definition_period = YEAR unit = GBP @@ -31,7 +33,9 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", ) def formula(benunit, period, parameters): From bf7799c8209f1c54e60e0102027f0895619eb851 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:04:23 -0400 Subject: [PATCH 3/7] Test the Housing Benefit disregard is the statutory sum in every year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A property over 2015-2030 at working and pension age: £5, £10 or £25 a week, plus £17.10 (£37.10 in 2020) where a work condition holds, times 52. Co-Authored-By: Claude Opus 5.5 --- ...pensioner_earnings_disregard_properties.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py index f2171365e4..28e834825c 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -29,6 +29,12 @@ in every nation and year, except England and Wales in 2020, where Housing Benefit's additional sum was £37.10 (SI 2020/371 reg 5) and CTR's stayed £17.10 (an intended difference, which the test pins). +6. The Housing Benefit disregard (#1908) is the statutory weekly sum x 52 in + every year from 2015 to 2030, at working and pension age: £5, £10 or £25 + (SI 2006/213 Sch 4 paras 4, 7, 10; SI 2006/214 Sch 4 paras 2, 7), plus + £17.10 (£37.10 in 2020, SI 2020/371 reg 5) where a work condition holds, + for families with enough earnings and no Income Support, income-based JSA + or income-related ESA (whose working-age earnings are all disregarded). """ import numpy as np @@ -95,7 +101,7 @@ def families(draw, age=ADULT_AGE, earnings=EARNINGS, nations=NATIONS): ) -def situation(units, year, earnings_bump=0.0): +def situation(units, year, earnings_bump=0.0, no_income_related_benefits=False): people, benunits, households = {}, {}, {} for i, unit in enumerate(units): names = [] @@ -119,6 +125,9 @@ def situation(units, year, earnings_bump=0.0): "members": names, "claims_all_entitled_benefits": {year: True}, } + if no_income_related_benefits: + for benefit in ("income_support", "jsa_income", "esa_income"): + benunits[f"b{i}"][benefit] = {year: 0.0} households[f"h{i}"] = { "members": names, "country": {year: unit["nation"]}, @@ -268,3 +277,27 @@ def test_differential_against_the_housing_benefit_pension_age_disregard(units, y assert abs(value - expected) < 0.01, (unit, year) else: assert abs(ctr[i] - hb[i]) < 0.01, (unit, year) + + +@PROPERTY_SETTINGS +@given( + st.lists( + families(earnings=st.floats(20_000, 40_000)), + min_size=1, + max_size=30, + ), + st.integers(2015, 2030), +) +def test_housing_benefit_disregard_is_the_statutory_weekly_sum_in_every_year( + units, year +): + values = calculate(units, year=year, no_income_related_benefits=True) + disregard = values["housing_benefit_applicable_income_disregard"] + conditions = values[ + "meets_housing_benefit_additional_earnings_disregard_conditions" + ].astype(bool) + for i, unit in enumerate(units): + weekly = WEEKLY[unit["shape"]] + if conditions[i]: + weekly += 37.1 if year == 2020 else 17.1 + assert abs(disregard[i] - weekly * WEEKS) < 0.01, (unit, year) From 22192951f6523269c6258f63245b4e9c9110cec3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:19:41 -0400 Subject: [PATCH 4/7] Read the parameter property in each financial year An integer year before 2015 reads 1 January, before the schemes began on 1 April 2013; read 30 April explicitly. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_pensioner_earnings_disregard_properties.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py index 28e834825c..25d8c5695d 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -201,7 +201,9 @@ def test_disregard_is_the_statutory_weekly_sum_in_every_year(units, year): def test_parameters_hold_the_statutory_sums_without_uprating(): for year in range(2013, 2031): - p = system.parameters(year).gov.local_authorities + # Read in each financial year (30 April, the model's convention for + # years; an integer year before 2015 reads 1 January instead). + p = system.parameters(f"{year}-04-30").gov.local_authorities for nation in NATIONS: sums = getattr( p, nation.lower() From 8f1883a7a919e7bac73a85df6fa56d789d870fe1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:42:12 -0400 Subject: [PATCH 5/7] Apply the pensioner schedules to the schemes' pensioners Gate the disregard on council_tax_reduction_pensioner (now on main: SPC qualifying age and no income-related benefit or UC award; SI 2012/2885 reg 3, WSI 2013/3029 reg 3, SSI 2012/319 reg 12) instead of any member over State Pension age. A YAML case pins a pension-age claimant whose partner is on income-related ESA. Co-Authored-By: Claude Opus 5.5 --- ...eduction_pensioner_earnings_disregard.yaml | 24 +++++++++++++++++++ ..._reduction_pensioner_earnings_disregard.py | 16 +++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml index b7c8c040af..c077000081 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml @@ -376,6 +376,30 @@ output: council_tax_reduction_pensioner_earnings_disregard: 0 +- name: A pension-age claimant whose partner is on income-related ESA is not a CTR pensioner + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + employment_income: 1_000 + partner: + age: 50 + benunits: + benunit: + members: [claimant, partner] + esa_income: 4_000 + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + # SI 2012/2885 reg 3(2): not a pensioner where the applicant or partner + # is on income-related ESA, so the pensioner schedule does not apply. + council_tax_reduction_pensioner: false + council_tax_reduction_pensioner_earnings_disregard: 0 + - name: Northern Ireland pensioner has no Council Tax Reduction earnings disregard period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py index 00de12054d..eed34985e6 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -23,9 +23,10 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "model does not deduct from Council Tax Reduction income. The £20 " "disregards for disabled people, carers and some part-time " "occupations, and the exempt work disregard, are not modelled. Zero " - "for working-age families, whose schemes are local in England and " - "have their own schedules in Wales and Scotland, and outside Great " - "Britain." + "for families that are not the schemes' pensioners " + "(council_tax_reduction_pensioner), whose schemes are local in " + "England and have their own schedules in Wales and Scotland, and " + "outside Great Britain." ) definition_period = YEAR unit = GBP @@ -36,6 +37,7 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", ) def formula(benunit, period, parameters): @@ -79,6 +81,10 @@ def national(amount): additional_amount, 0, ) - pension_age = benunit.any(benunit.members("is_SP_age", period)) + # The pensioner schedules apply to the schemes' pensioners: the + # qualifying age for State Pension Credit, and no income-related + # benefit or Universal Credit award (SI 2012/2885 reg 3; WSI + # 2013/3029 reg 3; SSI 2012/319 reg 12). + pensioner = benunit("council_tax_reduction_pensioner", period) in_great_britain = in_england | in_wales | in_scotland - return where(pension_age & in_great_britain, standard + additional, 0) + return where(pensioner & in_great_britain, standard + additional, 0) From c86adbbf31b34f8be26241cedf3efe0aaa5dd592 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 10:13:56 -0400 Subject: [PATCH 6/7] Subtract the pensioner earnings disregard in the PC property oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_council_tax_reduction_pension_credit_properties.py (#1909) checked general-rules CTR income against main's old definition, which had no earnings disregard, so it failed for pension-age earners (a couple's £520). The oracle now subtracts an independently computed disregard from the statutory sums (£25/£10/£5 plus £17.10, capped at net earnings, with the "equal or exceed" test) for the schemes' pensioners. 8 passed. Also narrow the docstring: the amounts and work conditions are the HB pension-age schedule's and employees' net earnings use the same deductions, but the self-employed notional tax can differ (SSI 2012/319 reg 37(1) deducts only the personal allowance). Co-Authored-By: Claude Opus 5.5 --- ...tax_reduction_pension_credit_properties.py | 46 ++++++++++++++++++- ..._reduction_pensioner_earnings_disregard.py | 18 +++++--- 2 files changed, 55 insertions(+), 9 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 e32f8363e7..85b641ad15 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 @@ -260,7 +260,8 @@ def situation( ] # council_tax_reduction_applicable_income on main before this change (1c5b4d04): # these incomes and benefits, less income tax, National Insurance and half of -# pension contributions, floored at zero. +# pension contributions, floored at zero. Since #2033, a pensioner family's +# earnings disregard also comes off before the floor. MAIN_INCOME_COMPONENTS = [ "employment_income", "self_employment_income", @@ -282,6 +283,12 @@ def situation( "universal_credit", ] MAIN_DEDUCTIONS = ["income_tax", "national_insurance"] +# 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 +# 2013/3029 Sch 3; SSI 2012/319 Sch 2). +PENSIONER_FLAT_DISREGARD = {"lone_parent": 25, "couple": 10, "single": 5} +PENSIONER_ADDITIONAL_DISREGARD = 17.10 HOUSEHOLD_VARIABLES = [ "council_tax_reduction_maximum_eligible_liability", "council_tax_reduction_household_has_pensioner", @@ -300,6 +307,40 @@ def guarantee_reform(single_weekly): } +def pensioner_earnings_disregard(sim, claimants): + """The schemes' pensioner earnings disregard from the statutory sums. + + Net earnings and the work conditions are Housing Benefit's (tested with + #1908); the amounts, the cap at net earnings and the "equal or exceed" + earnings test are recomputed here. Zero for families that are not the + schemes' pensioners. + """ + + def read(variable): + return np.asarray(sim.calculate(variable, YEAR))[claimants] + + net = read("housing_benefit_net_earnings").astype(float) + lone_parent = read("is_lone_parent").astype(bool) + couple = read("is_couple").astype(bool) + weekly = np.where( + lone_parent, + PENSIONER_FLAT_DISREGARD["lone_parent"], + np.where( + couple, + PENSIONER_FLAT_DISREGARD["couple"], + PENSIONER_FLAT_DISREGARD["single"], + ), + ) + standard = np.minimum(weekly * 52, net) + additional = PENSIONER_ADDITIONAL_DISREGARD * 52 + covers = np.round(net, 2) >= np.round(standard + additional, 2) + conditions = read( + "meets_housing_benefit_additional_earnings_disregard_conditions" + ).astype(bool) + disregard = standard + np.where(conditions & covers, additional, 0) + return np.where(read("council_tax_reduction_pensioner"), disregard, 0) + + def calculate(units, guarantee=None, **kwargs): data, claimants = situation(units, **kwargs) sim = Simulation(situation=data, reform=guarantee_reform(guarantee)) @@ -320,7 +361,8 @@ def benunit_total(names): 0, benunit_total(MAIN_INCOME_COMPONENTS) - benunit_total(MAIN_DEDUCTIONS) - - 0.5 * benunit_total(["pension_contributions"]), + - 0.5 * benunit_total(["pension_contributions"]) + - pensioner_earnings_disregard(sim, claimants), ) values["national"] = np.array( [ diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py index eed34985e6..545bc42b79 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -14,13 +14,17 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "net earnings. The additional earnings disregard of £17.10 (£37.10 " "in Scotland from 6 April 2020 to 4 April 2021) is added where a work " "condition is met and net earnings at least equal the other " - "disregards and the additional amount. The schedules repeat the " - "Housing Benefit pension-age schedule (SI 2006/214 Sch 4) word for " - "word, and net earnings are defined as for Housing Benefit (SI " - "2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 para 13, SSI 2012/319 " - "reg 33), so the Housing Benefit net earnings and work conditions " - "are used. The earnings test leaves out childcare charges, which the " - "model does not deduct from Council Tax Reduction income. The £20 " + "disregards and the additional amount. The amounts and work " + "conditions are those of the Housing Benefit pension-age schedule " + "(SI 2006/214 Sch 4), and employees' net earnings are defined by the " + "same deductions (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 " + "para 13, SSI 2012/319 reg 33), so the Housing Benefit net earnings " + "and work conditions are used. For the self-employed the notional " + "tax can differ (Scotland deducts only the personal allowance, SSI " + "2012/319 reg 37(1)); the model applies the Housing Benefit " + "approximation to all of them. The earnings test leaves out " + "childcare charges, which the model does not deduct from Council Tax " + "Reduction income. The £20 " "disregards for disabled people, carers and some part-time " "occupations, and the exempt work disregard, are not modelled. Zero " "for families that are not the schemes' pensioners " From c2d9a5cbd894ff4d25c23509d0d87e76785a7405 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:34:06 -0400 Subject: [PATCH 7/7] Address the review's minors: wording and a tighter zero test - Scotland's para 10(3)(c) omits "by"; quote the two substitutions apart. - Drop "word for word" from the property test docstring. - An employee's actual earnings share HB's deductions; Scotland's notional tax on estimated earnings and self-employed profit deducts only the personal allowance (SSI 2012/319 regs 33(4), 37(1)). - The bounds property asserts zero for every family that is not a scheme pensioner (not just those aged 60 or under) and for an unknown country. - The Pension Credit property docstring describes the disregard oracle. Co-Authored-By: Claude Opus 5.5 --- .../pensioners/earnings_disregard.yaml | 3 ++- ...tax_reduction_pension_credit_properties.py | 6 +++-- ...pensioner_earnings_disregard_properties.py | 22 +++++++++++++------ ..._reduction_pensioner_earnings_disregard.py | 14 ++++++------ 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml index 02e7940133..e3fc2ed14f 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/pensioners/earnings_disregard.yaml @@ -39,7 +39,8 @@ additional: # The Council Tax Reduction (Scotland) Amendment (No. 3) (Coronavirus) # Regulations 2020 (SSI 2020/108) reg. 3(a), in force 6 April 2020, # substituted "£37.10 until and including 4 April 2021, and after that - # by £17.10" in para. 10(1) and (3)(c). + # by £17.10" in para. 10(1), and the same words without "by" in para. + # 10(3)(c). 2020-04-06: 37.1 2021-04-05: 17.1 metadata: 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 85b641ad15..4b9e0b176c 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 @@ -23,8 +23,10 @@ nothing otherwise. 4. Differential: everyone else keeps the previous formula, max(0, L - 0.2 x max(0, I - A) - ND) when household savings are within 16,000, where I is the - income definition on main before this change, recomputed here from its - components rather than read from the variable under test. + income definition on main before this change less the schemes' pensioner + earnings disregard, both recomputed here (the income from its components, + the disregard from the statutory sums) rather than read from the variables + under test. 5. Metamorphic: a guarantee credit recipient's CTR does not change when the State Pension changes. 6. Metamorphic: nor when its savings change. diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py index 25d8c5695d..34b3c49c58 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -6,14 +6,15 @@ else, plus £17.10 where a work condition is met and net earnings at least equal the other disregards, the deductible childcare charges and £17.10. Scotland read £37.10 for £17.10 from 6 April 2020 to 4 April 2021 (SSI -2020/108). The schedules repeat the Housing Benefit pension-age schedule (SI -2006/214 Sch 4) word for word. +2020/108). The sums and the work conditions the model applies correspond to +the Housing Benefit pension-age schedule (SI 2006/214 Sch 4). Invariants, for any generated population of families: 1. Bounds: 0 <= disregard <= min(net earnings, (£25 + £37.10) x 52); no - earnings, no disregard; zero for a family with no member over State - Pension age, and zero in Northern Ireland. + earnings, no disregard; zero for a family that is not one of the schemes' + pensioners (whatever its ages), and zero in Northern Ireland or where the + country is unknown. 2. The statutory sums: in every year from 2015 (the first year the model simulates) to 2030 and in each nation, a pension-age family with enough earnings and no work condition has exactly its weekly sum x 52 @@ -160,20 +161,27 @@ def working_age(unit): @PROPERTY_SETTINGS @given( - st.lists(families(nations=NATIONS + ["NORTHERN_IRELAND"]), min_size=1, max_size=30) + st.lists( + families(nations=NATIONS + ["NORTHERN_IRELAND", "UNKNOWN"]), + min_size=1, + max_size=30, + ) ) def test_disregard_is_bounded_and_only_for_pension_age_families_in_great_britain( units, ): - values = calculate(units) + values = calculate(units, variables=VARIABLES + ["council_tax_reduction_pensioner"]) disregard = values["council_tax_reduction_pensioner_earnings_disregard"] net = values["housing_benefit_net_earnings"] + pensioner = values["council_tax_reduction_pensioner"].astype(bool) for i, unit in enumerate(units): assert disregard[i] >= 0, unit assert disregard[i] <= min(net[i], MAX_ANNUAL) + 0.01, unit if gross_earnings(unit) == 0: assert disregard[i] == 0, unit - if working_age(unit) or unit["nation"] == "NORTHERN_IRELAND": + if working_age(unit): + assert not pensioner[i], unit + if not pensioner[i] or unit["nation"] in ("NORTHERN_IRELAND", "UNKNOWN"): assert disregard[i] == 0, unit diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py index 545bc42b79..17e26734c0 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -16,13 +16,13 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "condition is met and net earnings at least equal the other " "disregards and the additional amount. The amounts and work " "conditions are those of the Housing Benefit pension-age schedule " - "(SI 2006/214 Sch 4), and employees' net earnings are defined by the " - "same deductions (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 " - "para 13, SSI 2012/319 reg 33), so the Housing Benefit net earnings " - "and work conditions are used. For the self-employed the notional " - "tax can differ (Scotland deducts only the personal allowance, SSI " - "2012/319 reg 37(1)); the model applies the Housing Benefit " - "approximation to all of them. The earnings test leaves out " + "(SI 2006/214 Sch 4), and an employee's actual earnings have the same " + "deductions (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 para " + "13, SSI 2012/319 reg 33), so the Housing Benefit net earnings and " + "work conditions are used. The notional tax on estimated earnings " + "and on self-employed profit can differ (Scotland deducts only the " + "personal allowance, SSI 2012/319 regs 33(4) and 37(1)); the model " + "applies the Housing Benefit approximation to all of them. The earnings test leaves out " "childcare charges, which the model does not deduct from Council Tax " "Reduction income. The £20 " "disregards for disabled people, carers and some part-time "